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:
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
})),
|
||||
);
|
||||
@@ -197,6 +197,15 @@ export interface Issue extends IssueSummary {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Someone else on a ticket's page right now. */
|
||||
export interface Presence {
|
||||
user: UserRef;
|
||||
/** Editing the ticket or writing a comment. */
|
||||
editing: boolean;
|
||||
/** Last heartbeat. */
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** A commit, branch or pull request that mentions a ticket. */
|
||||
export interface DevLink {
|
||||
kind: 'COMMIT' | 'BRANCH' | 'PULL_REQUEST';
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Release,
|
||||
ReleaseBurnup,
|
||||
SavedFilter,
|
||||
Presence,
|
||||
MyView,
|
||||
MyViewCriteria,
|
||||
SavedFilterCriteria,
|
||||
@@ -430,3 +431,9 @@ export abstract class TrackerRealtimeGateway {
|
||||
*/
|
||||
abstract reconnects(): Observable<void>;
|
||||
}
|
||||
|
||||
/** Who is on a ticket's page: a heartbeat answers who else is. */
|
||||
export abstract class PresenceGateway {
|
||||
abstract touch(issueId: string, editing: boolean): Observable<Presence[]>;
|
||||
abstract leave(issueId: string): Observable<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
LeavePresenceDocument,
|
||||
TouchPresenceDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlPresenceGateway extends PresenceGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
touch(issueId: string, editing: boolean): Observable<Presence[]> {
|
||||
return this.gql
|
||||
.mutate(TouchPresenceDocument, { issueId, editing })
|
||||
.pipe(map((d) => d.touchPresence));
|
||||
}
|
||||
|
||||
leave(issueId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(LeavePresenceDocument, { issueId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
}
|
||||
@@ -1546,6 +1546,25 @@ export type NotificationAddedSubscription = {
|
||||
};
|
||||
};
|
||||
|
||||
export type TouchPresenceMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
editing: boolean;
|
||||
}>;
|
||||
|
||||
export type TouchPresenceMutation = {
|
||||
touchPresence: Array<{
|
||||
editing: boolean;
|
||||
at: string;
|
||||
user: { id: string; name: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
export type LeavePresenceMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
|
||||
export type LeavePresenceMutation = { leavePresence: boolean };
|
||||
|
||||
export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type MyProjectsQuery = {
|
||||
@@ -9536,6 +9555,153 @@ export const NotificationAddedDocument = {
|
||||
NotificationAddedSubscription,
|
||||
NotificationAddedSubscriptionVariables
|
||||
>;
|
||||
export const TouchPresenceDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'TouchPresence' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'editing' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'touchPresence' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'editing' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'editing' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'user' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editing' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
TouchPresenceMutation,
|
||||
TouchPresenceMutationVariables
|
||||
>;
|
||||
export const LeavePresenceDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'LeavePresence' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'leavePresence' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
LeavePresenceMutation,
|
||||
LeavePresenceMutationVariables
|
||||
>;
|
||||
export const MyProjectsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
mutation TouchPresence($issueId: ID!, $editing: Boolean!) {
|
||||
touchPresence(issueId: $issueId, editing: $editing) {
|
||||
user {
|
||||
...UserRefFields
|
||||
}
|
||||
editing
|
||||
at
|
||||
}
|
||||
}
|
||||
|
||||
mutation LeavePresence($issueId: ID!) {
|
||||
leavePresence(issueId: $issueId)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IssuesGateway,
|
||||
NotificationsGateway,
|
||||
CalendarGateway,
|
||||
PresenceGateway,
|
||||
PushDevice,
|
||||
ProjectsGateway,
|
||||
ReleasesGateway,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
|
||||
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
|
||||
import { GraphqlCalendarGateway } from './adapters/graphql-calendar.gateway';
|
||||
import { GraphqlPresenceGateway } from './adapters/graphql-presence.gateway';
|
||||
import { BrowserPushDevice } from './adapters/browser-push.device';
|
||||
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
||||
import { HttpProposalsGateway } from './adapters/http-proposals.gateway';
|
||||
@@ -56,6 +58,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
||||
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
||||
{ provide: CalendarGateway, useClass: GraphqlCalendarGateway },
|
||||
{ provide: PresenceGateway, useClass: GraphqlPresenceGateway },
|
||||
{ provide: PushDevice, useClass: BrowserPushDevice },
|
||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
||||
|
||||
@@ -551,3 +551,19 @@ a.ts-key {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.ts-presence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.375rem 0 0.25rem;
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.ts-presence__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-c-green, #2e9e6a);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
input,
|
||||
signal,
|
||||
TemplateRef,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
@@ -38,6 +39,8 @@ import {
|
||||
IssueFacade,
|
||||
ProjectFacade,
|
||||
provideIssueFacade,
|
||||
PresenceFacade,
|
||||
providePresenceFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
Attachment,
|
||||
@@ -105,7 +108,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
PiTextFieldComponent,
|
||||
PiTextareaComponent,
|
||||
],
|
||||
providers: [provideIssueFacade()],
|
||||
providers: [provideIssueFacade(), providePresenceFacade()],
|
||||
styleUrl: './issue.page.scss',
|
||||
template: `
|
||||
<a class="ts-back" routerLink="../../board">← Tablero</a>
|
||||
@@ -152,6 +155,12 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<ts-blocked [blockers]="issue.blockedBy" />
|
||||
}
|
||||
</div>
|
||||
@if (presence.others().length) {
|
||||
<p class="ts-presence" aria-live="polite">
|
||||
<span class="ts-presence__dot" aria-hidden="true"></span>
|
||||
{{ presenceText() }}
|
||||
</p>
|
||||
}
|
||||
<div class="ts-issue__title-row">
|
||||
<h1>{{ issue.title }}</h1>
|
||||
<div class="ts-issue__actions">
|
||||
@@ -1273,8 +1282,47 @@ export class IssuePage {
|
||||
required(p.body),
|
||||
);
|
||||
|
||||
/* Presence: who else is here; I count as editing while a form is open. */
|
||||
protected readonly presence = inject(PresenceFacade);
|
||||
private readonly editingInDrawer = signal(false);
|
||||
protected readonly presenceText = computed(() => {
|
||||
const others = this.presence.others();
|
||||
const editing = others.filter((p) => p.editing).map((p) => p.user.name);
|
||||
const looking = others.filter((p) => !p.editing).map((p) => p.user.name);
|
||||
const list = (names: string[]) =>
|
||||
names.length > 1
|
||||
? `${names.slice(0, -1).join(', ')} y ${names[names.length - 1]}`
|
||||
: names.join('');
|
||||
return [
|
||||
editing.length
|
||||
? `${list(editing)} ${editing.length > 1 ? 'están editando' : 'está editando'}`
|
||||
: '',
|
||||
looking.length
|
||||
? `${list(looking)} ${looking.length > 1 ? 'también lo están viendo' : 'también lo está viendo'}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => this.facade.open(this.issueKey()));
|
||||
effect(() => {
|
||||
const id = this.facade.issue()?.id;
|
||||
if (id) untracked(() => this.presence.open(id));
|
||||
});
|
||||
effect(() => {
|
||||
const editing = this.editingInDrawer() || !!this.commentBody().trim();
|
||||
untracked(() => this.presence.setEditing(editing));
|
||||
});
|
||||
}
|
||||
|
||||
/** While a form is open in the drawer, I am editing. */
|
||||
private trackEditing(ref: {
|
||||
afterClosed(): { subscribe(f: () => void): unknown };
|
||||
}): void {
|
||||
this.editingInDrawer.set(true);
|
||||
ref.afterClosed().subscribe(() => this.editingInDrawer.set(false));
|
||||
}
|
||||
|
||||
protected statusLabel = (status: string) =>
|
||||
@@ -1492,7 +1540,9 @@ export class IssuePage {
|
||||
protected openEditComment(comment: Comment): void {
|
||||
this.editingComment = comment.id;
|
||||
this.commentEditModel.set({ body: comment.body });
|
||||
this.drawer.open('Editar comentario', this.commentEditForm());
|
||||
this.trackEditing(
|
||||
this.drawer.open('Editar comentario', this.commentEditForm()),
|
||||
);
|
||||
}
|
||||
|
||||
protected saveComment(): void {
|
||||
@@ -1515,7 +1565,9 @@ export class IssuePage {
|
||||
labels: issue.labels.join(', '),
|
||||
figmaUrl: issue.figmaUrl ?? '',
|
||||
});
|
||||
this.drawer.open(`Editar ${issue.key}`, this.editForm(), '36rem');
|
||||
this.trackEditing(
|
||||
this.drawer.open(`Editar ${issue.key}`, this.editForm(), '36rem'),
|
||||
);
|
||||
}
|
||||
|
||||
protected saveEdit(): void {
|
||||
|
||||
Reference in New Issue
Block a user