feat: conversations on a ticket are settled, and you see who is typing
A top-level comment and its replies can be resolved by whoever can
change the ticket (CommentThreadResolvedEvent), with an optional
conclusion added as a reply marked as a decision, so it shows above the
ticket. A settled conversation folds to one line (topic, who settled it
and the conclusion) and can be unfolded or reopened; the header counts
the open ones, and finishing the ticket with one open asks first.
«Ana está escribiendo…» shows in the conversation being answered, at
once: a `typing` mutation publishes to a Redis channel per ticket
(`typing` subscription), never stored. In replies, Enter sends and
Shift+Enter breaks the line, except inside lists and code.
API: resolveThread / PUT-DELETE /api/comments/{id}/resolution, Comment
.resolved/resolvedBy/resolvedAt, typing mutation and subscription. MCP:
resolve_thread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,11 @@ export const issuePageEvents = eventGroup({
|
||||
commentEditRequested: type<{ commentId: string; body: string }>(),
|
||||
reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(),
|
||||
decisionRequested: type<{ commentId: string; on: boolean }>(),
|
||||
threadResolveRequested: type<{
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
conclusion: string | null;
|
||||
}>(),
|
||||
voteRequested: type<{ issueId: string; on: boolean }>(),
|
||||
/** An image pasted into a text was uploaded (it joins the ticket's files). */
|
||||
imagePasted: type<{ issueId: string }>(),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import { Presence } from '@task-stream/web/tracker/domain';
|
||||
import { Presence, Typing } from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const presencePageEvents = eventGroup({
|
||||
source: 'Issue Presence',
|
||||
events: {
|
||||
opened: type<string>(),
|
||||
editingChanged: type<boolean>(),
|
||||
/** I typed in an answer to this conversation (null: a new comment). */
|
||||
typed: type<string | null>(),
|
||||
left: type<void>(),
|
||||
},
|
||||
});
|
||||
@@ -15,5 +17,9 @@ export const presenceApiEvents = eventGroup({
|
||||
source: 'Presence API',
|
||||
events: {
|
||||
seen: type<Presence[]>(),
|
||||
/** Someone else is writing. */
|
||||
typing: type<Typing>(),
|
||||
/** …and stopped a while ago (the `at` it was last seen). */
|
||||
typingStopped: type<{ userId: string; at: string }>(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -231,6 +231,22 @@ export class IssueFacade {
|
||||
this.dispatch.reactRequested({ commentId, emoji, on });
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a conversation (or reopens it); a conclusion is added as a
|
||||
* reply marked as a decision.
|
||||
*/
|
||||
resolveThread(
|
||||
commentId: string,
|
||||
resolved: boolean,
|
||||
conclusion: string | null = null,
|
||||
): void {
|
||||
this.dispatch.threadResolveRequested({
|
||||
commentId,
|
||||
resolved,
|
||||
conclusion: conclusion?.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
|
||||
markDecision(commentId: string, on: boolean): void {
|
||||
this.dispatch.decisionRequested({ commentId, on });
|
||||
|
||||
@@ -11,6 +11,8 @@ export class PresenceFacade {
|
||||
|
||||
/** The others, the ones editing first. */
|
||||
readonly others = this.store.others;
|
||||
/** Who else is writing now, and where (`threadId` null: a new comment). */
|
||||
readonly typists = this.store.typists;
|
||||
|
||||
/** I am on this ticket: beats until the page closes. */
|
||||
open(issueId: string): void {
|
||||
@@ -20,6 +22,11 @@ export class PresenceFacade {
|
||||
setEditing(editing: boolean): void {
|
||||
this.dispatch.editingChanged(editing);
|
||||
}
|
||||
|
||||
/** I am writing an answer to `threadId` (null: a new comment). */
|
||||
typed(threadId: string | null): void {
|
||||
this.dispatch.typed(threadId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Store + facade of a ticket's presence, for the ticket's page. */
|
||||
|
||||
@@ -479,6 +479,26 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.threadResolveRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments.resolveThread(
|
||||
payload.commentId,
|
||||
payload.resolved,
|
||||
payload.conclusion,
|
||||
),
|
||||
!payload.resolved
|
||||
? 'Conversación reabierta'
|
||||
: payload.conclusion
|
||||
? 'Resuelta: la conclusión queda como decisión'
|
||||
: 'Conversación resuelta',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.reactRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
comments.react(payload.commentId, payload.emoji, payload.on).pipe(
|
||||
|
||||
@@ -6,18 +6,26 @@ import {
|
||||
withEventHandlers,
|
||||
withReducer,
|
||||
} from '@ngrx/signals/events';
|
||||
import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import {
|
||||
Presence,
|
||||
PresenceGateway,
|
||||
Typing,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
filter,
|
||||
groupBy,
|
||||
map,
|
||||
mergeMap,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
throttleTime,
|
||||
timer,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
@@ -27,10 +35,16 @@ import {
|
||||
|
||||
/** A heartbeat every so often; the API forgets a page after 30 s without one. */
|
||||
export const PRESENCE_BEAT_MS = 10_000;
|
||||
/** While typing, I say so at most this often… */
|
||||
export const TYPING_EVERY_MS = 3_000;
|
||||
/** …and someone silent this long has stopped. */
|
||||
export const TYPING_GONE_MS = 6_000;
|
||||
|
||||
interface PresenceState {
|
||||
issueId: string | null;
|
||||
others: Presence[];
|
||||
/** Who else is writing now, one entry each. */
|
||||
typists: Typing[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,17 +53,75 @@ interface PresenceState {
|
||||
* to the ticket's page.
|
||||
*/
|
||||
export const PresenceStore = signalStore(
|
||||
withState<PresenceState>({ issueId: null, others: [] }),
|
||||
withState<PresenceState>({ issueId: null, others: [], typists: [] }),
|
||||
withReducer(
|
||||
on(presencePageEvents.opened, ({ payload }) => ({
|
||||
issueId: payload,
|
||||
others: [],
|
||||
typists: [],
|
||||
})),
|
||||
on(presenceApiEvents.seen, ({ payload }) => ({ others: payload })),
|
||||
on(presencePageEvents.left, () => ({ issueId: null, others: [] })),
|
||||
on(presenceApiEvents.typing, ({ payload }, state) => ({
|
||||
typists: [
|
||||
...state.typists.filter((t) => t.user.id !== payload.user.id),
|
||||
payload,
|
||||
],
|
||||
})),
|
||||
on(presenceApiEvents.typingStopped, ({ payload }, state) => ({
|
||||
typists: state.typists.filter(
|
||||
(t) => !(t.user.id === payload.userId && t.at === payload.at),
|
||||
),
|
||||
})),
|
||||
on(presencePageEvents.left, () => ({
|
||||
issueId: null,
|
||||
others: [],
|
||||
typists: [],
|
||||
})),
|
||||
),
|
||||
withEventHandlers(
|
||||
(store, events = inject(Events), presence = inject(PresenceGateway)) => ({
|
||||
(
|
||||
store,
|
||||
events = inject(Events),
|
||||
presence = inject(PresenceGateway),
|
||||
auth = inject(AuthFacade),
|
||||
) => ({
|
||||
// Each answer (or the new comment) tells the others at most every 3 s.
|
||||
typed$: events.on(presencePageEvents.typed).pipe(
|
||||
map(({ payload }) => payload),
|
||||
groupBy((threadId) => threadId ?? ''),
|
||||
mergeMap((thread$) => thread$.pipe(throttleTime(TYPING_EVERY_MS))),
|
||||
mergeMap((threadId) => {
|
||||
const issueId = store.issueId();
|
||||
// Telling is a nicety: nothing to say if it fails.
|
||||
return issueId
|
||||
? presence.typing(issueId, threadId).pipe(catchError(() => EMPTY))
|
||||
: EMPTY;
|
||||
}),
|
||||
switchMap(() => EMPTY),
|
||||
),
|
||||
typists$: events.on(presencePageEvents.opened).pipe(
|
||||
switchMap(({ payload: issueId }) =>
|
||||
presence.typists(issueId).pipe(
|
||||
filter((typing) => typing.user.id !== auth.user()?.id),
|
||||
map((typing) => presenceApiEvents.typing(typing)),
|
||||
catchError(() => EMPTY),
|
||||
takeUntil(events.on(presencePageEvents.left)),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Silent for a while: stopped (unless a newer one came meanwhile).
|
||||
typingStopped$: events.on(presenceApiEvents.typing).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
timer(TYPING_GONE_MS).pipe(
|
||||
map(() =>
|
||||
presenceApiEvents.typingStopped({
|
||||
userId: payload.user.id,
|
||||
at: payload.at,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
beat$: events.on(presencePageEvents.opened).pipe(
|
||||
switchMap(({ payload: issueId }) =>
|
||||
combineLatest([
|
||||
|
||||
Reference in New Issue
Block a user