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

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