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:
@@ -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