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:
@@ -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[]>(),
|
||||
},
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
})),
|
||||
);
|
||||
Reference in New Issue
Block a user