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

@@ -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);
}

View File

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