feat: presence on tickets

A ticket's page beats every 10 s (touchPresence / PUT
/issues/{issue}/presence, with whether I am editing) and gets back who
else is there, editing ones first. Entries live in Redis, one hash per
ticket, and fade after 30 s without a beat; leaving the page takes me out
at once. Only for people who can read the ticket; never in the event
store. The ticket shows «Bob está editando · Ana también lo está viendo»
above its title; editing means a ticket or comment form is open or a
comment is being written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 16:53:47 +02:00
parent 869acd3f1f
commit f60d4c8346
33 changed files with 1197 additions and 22 deletions

View File

@@ -11,6 +11,7 @@ export * from './lib/facades/inbox.facade';
export * from './lib/facades/epics.facade';
export * from './lib/facades/insights.facade';
export * from './lib/facades/my-views.facade';
export * from './lib/facades/presence.facade';
export * from './lib/facades/backlog.facade';
export * from './lib/facades/list.facade';
export * from './lib/facades/propose.facade';

View File

@@ -0,0 +1,19 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { Presence } from '@task-stream/web/tracker/domain';
export const presencePageEvents = eventGroup({
source: 'Issue Presence',
events: {
opened: type<string>(),
editingChanged: type<boolean>(),
left: type<void>(),
},
});
export const presenceApiEvents = eventGroup({
source: 'Presence API',
events: {
seen: type<Presence[]>(),
},
});

View File

@@ -0,0 +1,28 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { presencePageEvents } from '../events/presence.events';
import { PresenceStore } from '../stores/presence.store';
/** Who else is on the ticket's page, and whether they are editing it. */
@Injectable()
export class PresenceFacade {
private readonly store = inject(PresenceStore);
private readonly dispatch = injectDispatch(presencePageEvents);
/** The others, the ones editing first. */
readonly others = this.store.others;
/** I am on this ticket: beats until the page closes. */
open(issueId: string): void {
if (this.store.issueId() !== issueId) this.dispatch.opened(issueId);
}
setEditing(editing: boolean): void {
this.dispatch.editingChanged(editing);
}
}
/** Store + facade of a ticket's presence, for the ticket's page. */
export function providePresenceFacade(): Provider[] {
return [PresenceStore, PresenceFacade];
}

View File

@@ -0,0 +1,93 @@
import { inject } from '@angular/core';
import { signalStore, withHooks, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain';
import {
catchError,
combineLatest,
distinctUntilChanged,
EMPTY,
map,
mergeMap,
pairwise,
startWith,
switchMap,
takeUntil,
timer,
} from 'rxjs';
import {
presenceApiEvents,
presencePageEvents,
} from '../events/presence.events';
/** A heartbeat every so often; the API forgets a page after 30 s without one. */
export const PRESENCE_BEAT_MS = 10_000;
interface PresenceState {
issueId: string | null;
others: Presence[];
}
/**
* Who else is on the ticket's page. Beats while the page is open (and at
* once when I start or stop editing); says goodbye when it closes. Scoped
* to the ticket's page.
*/
export const PresenceStore = signalStore(
withState<PresenceState>({ issueId: null, others: [] }),
withReducer(
on(presencePageEvents.opened, ({ payload }) => ({
issueId: payload,
others: [],
})),
on(presenceApiEvents.seen, ({ payload }) => ({ others: payload })),
on(presencePageEvents.left, () => ({ issueId: null, others: [] })),
),
withEventHandlers(
(store, events = inject(Events), presence = inject(PresenceGateway)) => ({
beat$: events.on(presencePageEvents.opened).pipe(
switchMap(({ payload: issueId }) =>
combineLatest([
events.on(presencePageEvents.editingChanged).pipe(
map(({ payload }) => payload),
startWith(false),
distinctUntilChanged(),
),
timer(0, PRESENCE_BEAT_MS),
]).pipe(
switchMap(([editing]) =>
// A missed beat is no error worth showing: the next one retries.
presence.touch(issueId, editing).pipe(catchError(() => EMPTY)),
),
map((others) => presenceApiEvents.seen(others)),
takeUntil(events.on(presencePageEvents.left)),
),
),
),
// Moving to another ticket in the same page: goodbye to the previous one.
moved$: events.on(presencePageEvents.opened).pipe(
map(({ payload }) => payload),
pairwise(),
mergeMap(([previous]) =>
presence.leave(previous).pipe(catchError(() => EMPTY)),
),
switchMap(() => EMPTY),
),
}),
),
withHooks((store, presence = inject(PresenceGateway)) => ({
onDestroy() {
const issueId = store.issueId();
if (issueId)
presence
.leave(issueId)
.pipe(catchError(() => EMPTY))
.subscribe();
},
})),
);