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

@@ -32,3 +32,5 @@ export * from './lib/saved-filters/saved-filters';
export * from './lib/saved-filters/my-views';
export * from './lib/ports/my-views.repository';
export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store';
export * from './lib/presence.service';

View File

@@ -0,0 +1,24 @@
/** Someone on a ticket's page just now. */
export interface PresenceEntry {
user: { id: string; name: string };
/** Editing the ticket or writing a comment, not only looking. */
editing: boolean;
/** Last heartbeat (ISO). */
at: string;
}
/**
* Who is on each ticket's page, shared by every instance of the API.
* Entries fade on their own: a page that stops beating is gone.
*/
export abstract class PresenceStore {
/** Records (or refreshes) one person on a ticket; lasts `ttlMs`. */
abstract touch(
issueId: string,
entry: PresenceEntry,
ttlMs: number,
): Promise<void>;
/** Everyone whose heartbeat is newer than `since` (ms epoch). */
abstract list(issueId: string, since: number): Promise<PresenceEntry[]>;
abstract leave(issueId: string, userId: string): Promise<void>;
}

View File

@@ -0,0 +1,69 @@
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from './ports/find-issues.repository';
import { PresenceEntry, PresenceStore } from './ports/presence.store';
import { PRESENCE_TTL_MS, PresenceService } from './presence.service';
import { ReadAccess } from './queries/read-access';
class MemoryPresence extends PresenceStore {
readonly entries = new Map<string, Map<string, PresenceEntry>>();
async touch(issueId: string, entry: PresenceEntry) {
const here = this.entries.get(issueId) ?? new Map();
here.set(entry.user.id, entry);
this.entries.set(issueId, here);
}
async list(issueId: string, since: number) {
return [...(this.entries.get(issueId)?.values() ?? [])].filter(
(e) => Date.parse(e.at) >= since,
);
}
async leave(issueId: string, userId: string) {
this.entries.get(issueId)?.delete(userId);
}
}
const issues = {
findById: async (id: string) =>
id === 'i1'
? ({ id: 'i1', projectId: 'p1', trashedAt: null } as IssueReadModel)
: null,
} as unknown as FindIssuesRepository;
const access = {
assertCanView: async (_projectId: string, viewerId: string) => {
if (viewerId === 'eve') throw new ForbiddenError('No');
return 'MEMBER';
},
} as unknown as ReadAccess;
describe('PresenceService', () => {
it('tells who else is on a ticket, the ones editing first', async () => {
const store = new MemoryPresence();
const presence = new PresenceService(store, issues, access);
await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false);
await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true);
const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false);
expect(seen.map((p) => [p.user.name, p.editing])).toEqual([
['Cris', true],
['Ana', false],
]);
// A page that stops beating fades out; leaving takes one out at once.
store.entries.get('i1')!.get('ana')!.at = new Date(
Date.now() - PRESENCE_TTL_MS - 1000,
).toISOString();
await presence.leave('cris', 'i1');
expect(
await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false),
).toEqual([]);
});
it('only for whoever can read the ticket', async () => {
const presence = new PresenceService(new MemoryPresence(), issues, access);
await expect(
presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false),
).rejects.toBeInstanceOf(ForbiddenError);
await expect(
presence.touch({ id: 'ana', name: 'Ana' }, 'nope', false),
).rejects.toBeInstanceOf(NotFoundError);
});
});

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { FindIssuesRepository } from './ports/find-issues.repository';
import { PresenceEntry, PresenceStore } from './ports/presence.store';
import { ReadAccess } from './queries/read-access';
/** A page beats every 10 s; three missed beats and it is gone. */
export const PRESENCE_TTL_MS = 30_000;
/**
* Who else is on a ticket right now, and whether they are editing it. Not
* history: kept in Redis for a few seconds, never in the event store.
*/
@Injectable()
export class PresenceService {
constructor(
private readonly store: PresenceStore,
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
/**
* I am on the ticket (editing or not): answers who else is there, the
* ones editing first. Only for people who can read it.
*/
async touch(
viewer: { id: string; name: string },
issueId: string,
editing: boolean,
): Promise<PresenceEntry[]> {
await this.assertCanView(viewer.id, issueId);
const now = Date.now();
await this.store.touch(
issueId,
{
user: { id: viewer.id, name: viewer.name },
editing,
at: new Date(now).toISOString(),
},
PRESENCE_TTL_MS,
);
const here = await this.store.list(issueId, now - PRESENCE_TTL_MS);
return here
.filter((entry) => entry.user.id !== viewer.id)
.sort(
(a, b) =>
Number(b.editing) - Number(a.editing) ||
a.user.name.localeCompare(b.user.name, 'es'),
);
}
/** I left the ticket's page. */
leave(viewerId: string, issueId: string): Promise<void> {
return this.store.leave(issueId, viewerId);
}
private async assertCanView(viewerId: string, issueId: string) {
const issue = await this.findIssues.findById(issueId);
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
await this.access.assertCanView(issue.projectId, viewerId);
}
}

View File

@@ -45,6 +45,7 @@ import { WEBHOOK_HANDLERS } from './webhooks/webhooks';
import { TransferService } from './transfer.service';
import { TRANSFER_HANDLERS } from './transfer/import-export';
import { AutomationsService } from './automations.service';
import { PresenceService } from './presence.service';
import { AUTOMATION_HANDLERS } from './automations/automations';
import { CALENDAR_HANDLERS } from './calendar/calendar';
import { CalendarService } from './calendar.service';
@@ -79,6 +80,7 @@ const SERVICES = [
WebhooksService,
TransferService,
AutomationsService,
PresenceService,
ProposalQuota,
];

View File

@@ -0,0 +1,57 @@
import { Inject, Injectable } from '@nestjs/common';
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
import {
PresenceEntry,
PresenceStore,
} from '@task-stream/api/tracker/application';
import Redis from 'ioredis';
/**
* One hash per ticket (field: user id, value: the entry). The hash expires
* when nobody beats on it; stale fields are skipped and dropped on reading.
*/
@Injectable()
export class RedisPresenceStore extends PresenceStore {
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {
super();
}
private key(issueId: string): string {
return `task-stream:presence:${issueId}`;
}
async touch(
issueId: string,
entry: PresenceEntry,
ttlMs: number,
): Promise<void> {
const key = this.key(issueId);
await this.redis
.multi()
.hset(key, entry.user.id, JSON.stringify(entry))
.pexpire(key, ttlMs)
.exec();
}
async list(issueId: string, since: number): Promise<PresenceEntry[]> {
const key = this.key(issueId);
const all = await this.redis.hgetall(key);
const fresh: PresenceEntry[] = [];
const stale: string[] = [];
for (const [userId, raw] of Object.entries(all)) {
try {
const entry = JSON.parse(raw) as PresenceEntry;
if (Date.parse(entry.at) >= since) fresh.push(entry);
else stale.push(userId);
} catch {
stale.push(userId);
}
}
if (stale.length) await this.redis.hdel(key, ...stale);
return fresh;
}
async leave(issueId: string, userId: string): Promise<void> {
await this.redis.hdel(this.key(issueId), userId);
}
}

View File

@@ -37,6 +37,7 @@ import {
UpsertMaterializedTimelineRepository,
UserDirectory,
RateLimitStore,
PresenceStore,
} from '@task-stream/api/tracker/application';
import { IdentityUserDirectory } from './identity/identity-user-directory';
import {
@@ -68,6 +69,7 @@ import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repos
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
import { RedisChangeFeed } from './realtime/redis-change-feed';
import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
import { RedisPresenceStore } from './redis/redis-presence.store';
import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
@@ -160,6 +162,7 @@ const PORTS = [
// Realtime → Redis pub/sub
{ provide: ChangeFeed, useClass: RedisChangeFeed },
{ provide: RateLimitStore, useClass: RedisRateLimitStore },
{ provide: PresenceStore, useClass: RedisPresenceStore },
// Identity context (anti-corruption layer)
{ provide: UserDirectory, useClass: IdentityUserDirectory },
];

View File

@@ -17,6 +17,7 @@ import {
BlockersService,
CommentsService,
IssuesService,
PresenceService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
import {
@@ -48,6 +49,7 @@ import {
IssueGql,
IssuePageGql,
IssueParentGql,
PresenceGql,
} from './types';
@Resolver(() => IssueGql)
@@ -57,8 +59,30 @@ export class IssueResolver {
private readonly commentsService: CommentsService,
private readonly workflows: WorkflowsService,
private readonly blockers: BlockersService,
private readonly presence: PresenceService,
) {}
@Mutation(() => [PresenceGql], {
description:
'I am on this ticket (send every ~10 s while the page is open): answers who else is, the ones editing first',
})
touchPresence(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('editing', { defaultValue: false }) editing: boolean,
) {
return this.presence.touch({ id: me.id, name: me.name }, issueId, editing);
}
@Mutation(() => Boolean, { description: "I left this ticket's page" })
async leavePresence(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
) {
await this.presence.leave(me.id, issueId);
return true;
}
@OptionalAuth()
@Query(() => IssueGql)
issue(

View File

@@ -1063,3 +1063,13 @@ export class ProjectInsightsGql {
})
fields!: FieldBreakdownGql[];
}
@ObjectType('Presence', {
description: 'Someone on a ticket right now (heartbeat in the last 30 s)',
})
export class PresenceGql {
@Field(() => UserRefGql) user!: UserRefGql;
@Field({ description: 'Editing the ticket or writing a comment' })
editing!: boolean;
@Field({ description: 'Last heartbeat' }) at!: string;
}

View File

@@ -1188,6 +1188,16 @@ export class SetIssueParentDto {
parent!: string | null;
}
export class PresenceBodyDto {
@ApiPropertyOptional({
default: false,
description: 'Editing the ticket or writing a comment',
})
@IsOptional()
@IsBoolean()
editing = false;
}
export class ChecklistItemBodyDto {
@ApiProperty({ example: 'Actualizar el changelog', maxLength: 300 })
@IsString()

View File

@@ -764,6 +764,14 @@ export class ProjectStatsDto {
byAssignee!: CountByAssigneeDto[];
}
export class PresenceDto {
@ApiProperty({ type: UserRefDto }) user!: UserRefDto;
@ApiProperty({ description: 'Editing the ticket or writing a comment' })
editing!: boolean;
@ApiProperty({ format: 'date-time', description: 'Last heartbeat' })
at!: string;
}
export class ActivityDto {
@ApiProperty() id!: string;
@ApiProperty({ example: 'IssueStatusChanged' }) type!: string;

View File

@@ -13,6 +13,7 @@ import {
} from '@nestjs/common';
import {
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
@@ -31,6 +32,7 @@ import {
import {
CommentsService,
IssuesService,
PresenceService,
} from '@task-stream/api/tracker/application';
import { IssueLinkType } from '@task-stream/api/tracker/domain';
import { withoutNulls } from '../common/issue-input';
@@ -42,6 +44,7 @@ import {
AddCommentDto,
CreateIssueDto,
IssueSearchQueryDto,
PresenceBodyDto,
LinkIssueDto,
LinkTypeParamDto,
MyIssuesQueryDto,
@@ -60,6 +63,7 @@ import {
EpicDto,
IssueDto,
IssuePageDto,
PresenceDto,
} from './dto/responses.dto';
import { viewerOf } from '../common/viewer';
import { toBulkChange } from '../common/bulk';
@@ -85,8 +89,40 @@ export class IssuesController {
private readonly issues: IssuesService,
private readonly comments: CommentsService,
private readonly refs: Refs,
private readonly presence: PresenceService,
) {}
@Put('issues/:issue/presence')
@ISSUE
@ApiOperation({
summary: 'I am on this ticket (heartbeat)',
description:
'Send it every ~10 s while the page is open; entries fade after 30 s without one. Answers who else is there, the ones editing first.',
})
@ApiOkResponse({ type: [PresenceDto] })
@ApiReadErrors()
async touchPresence(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: PresenceBodyDto,
) {
return this.presence.touch(
{ id: me.id, name: me.name },
await this.refs.issueId(me.id, issue),
body.editing,
);
}
@Delete('issues/:issue/presence')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: "I left this ticket's page" })
@ApiNoContentResponse()
@ApiReadErrors()
async leavePresence(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.presence.leave(me.id, await this.refs.issueId(me.id, issue));
}
@Put('issues/:issue/watch')
@ISSUE
@ApiOperation({

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();
},
})),
);

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 },

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 {