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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user