feat: steadier live updates and notices for public proposals

- The web pings the live connection, reconnects when it goes quiet and
  reloads what each screen shows after reconnecting.
- Dropping a card on a column it cannot go to puts it back.
- Owners and admins get a notice when a proposal arrives.
- The proposal limit lives in Redis, so it holds across instances.
- REST tells whether I voted; pasted images leave the attachment list.
- Deleting a project removes its Git integration; no lint warnings left.
- Reactions and the blocked badge are components of their own.
- Docs: what is done and what is left (chapter 10), Word and PDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 19:57:21 +02:00
parent 74903f7842
commit c91df110f8
50 changed files with 615 additions and 321 deletions

View File

@@ -177,9 +177,10 @@ export function normalizeRule(
},
action: {
kind,
// `check` passed, so there is a value whenever one is needed.
value: check
? kind === AutomationActionKind.ADD_LABEL
? value!.trim().toLowerCase()
? (value ?? '').trim().toLowerCase()
: value
: null,
},

View File

@@ -1,3 +1,4 @@
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain';
import { ChangeFeed } from '../ports/change-feed';
@@ -7,7 +8,8 @@ import { versionOf } from './read-model-notifier';
/**
* Removes the project (and its activity and timeline) from the read model,
* and tells its members, whose project lists drop it. Tickets, comments and
* forgets its Git repositories' secrets, and tells its members, whose project
* lists drop it. Tickets, comments and
* releases go as the cascade saga deletes them.
*/
@EventsHandler(ProjectDeletedEvent)
@@ -18,11 +20,14 @@ export class ProjectDeletedEventHandler
private readonly findProjects: FindProjectsRepository,
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly changeFeed: ChangeFeed,
private readonly gitIntegrations: GitIntegrationsRepository,
) {}
async handle(event: ProjectDeletedEvent) {
const project = await this.findProjects.findById(event.projectId);
await this.upsertMaterializedProjectRepository.remove(event.projectId);
// No webhook of it should be accepted any more.
await this.gitIntegrations.saveAll(event.projectId, []);
if (!project) return; // Already removed (event handled twice).
await this.changeFeed.publishProject(
{

View File

@@ -10,10 +10,13 @@ import {
mentionedUserIds,
NotificationKind,
ProjectMemberAddedEvent,
ProjectRole,
StatusCategory,
SYSTEM_ACTOR_ID,
TrackerEvent,
withoutMentionMarkup,
} from '@task-stream/api/tracker/domain';
import { PROPOSAL_LABEL } from '../commands/submit-proposal.command-handler';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
@@ -117,6 +120,25 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
const memberIds = new Set(project.members.map((m) => m.user.id));
const drafts: NotificationDraft[] = [];
// A proposal from the public form: whoever runs the project should know.
if (
event instanceof IssueCreatedEvent &&
event.actorId === SYSTEM_ACTOR_ID &&
event.issue.labels?.includes(PROPOSAL_LABEL)
) {
for (const member of project.members) {
if (
member.role !== ProjectRole.OWNER &&
member.role !== ProjectRole.ADMIN
)
continue;
drafts.push({
userId: member.user.id,
kind: NotificationKind.PROPOSED,
summary: `Nueva propuesta en ${project.key}: ${ticket}`,
});
}
}
if (event instanceof IssueCreatedEvent && assigneeId) {
drafts.push({
userId: assigneeId,

View File

@@ -1,6 +1,7 @@
import {
CommentAddedEvent,
IssueAssignedEvent,
IssueCreatedEvent,
IssuePriority,
IssueSprintChangedEvent,
IssueStatusChangedEvent,
@@ -100,7 +101,7 @@ function setup(mailEnabled = false) {
name: 'Web',
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({
user: { id, name: id },
role: ProjectRole.MEMBER,
role: id === 'ana' ? ProjectRole.OWNER : ProjectRole.MEMBER,
})),
}),
} as unknown as FindProjectsRepository,
@@ -298,6 +299,28 @@ describe('notifications', () => {
]);
});
it('tells whoever runs the project of a proposal from the public form', async () => {
const { handler, repo } = setup();
await handler.handle(
at(
new IssueCreatedEvent('system', {
id: 'i-9',
projectId: 'p-1',
key: 'WEB-9',
title: 'Exportar a PDF',
reporterId: 'system',
assigneeId: null,
labels: ['propuesta'],
} as never),
),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['ana', NotificationKind.PROPOSED],
]);
expect(repo.all[0].summary).toContain('Nueva propuesta en WEB');
});
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
const { handler, repo, mailed } = setup(true);
repo.emailOn.add('bob');

View File

@@ -26,3 +26,4 @@ export * from './upsert-materialized-sprint.repository';
export * from './git-integrations.repository';
export * from './outgoing-webhooks.repository';
export * from './automations.repository';
export * from './rate-limit.store';

View File

@@ -0,0 +1,8 @@
/**
* Counts hits per key within a window, shared by every instance of the API
* (so a limit holds however many there are).
*/
export abstract class RateLimitStore {
/** Counts one more for `key` and answers how many within the window. */
abstract hit(key: string, windowMs: number): Promise<number>;
}

View File

@@ -14,6 +14,8 @@ export enum NotificationKind {
MENTIONED = 'MENTIONED',
/** Someone answered a comment of yours. */
REPLIED = 'REPLIED',
/** A bug report or an idea came in through a project's public form. */
PROPOSED = 'PROPOSED',
/** A ticket you follow changed. */
WATCHED = 'WATCHED',
/** You were added to a project. */

View File

@@ -0,0 +1,19 @@
import { Inject, Injectable } from '@nestjs/common';
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
import { RateLimitStore } from '@task-stream/api/tracker/application';
import Redis from 'ioredis';
/** A fixed window per key: the first hit opens it, it expires on its own. */
@Injectable()
export class RedisRateLimitStore extends RateLimitStore {
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {
super();
}
async hit(key: string, windowMs: number): Promise<number> {
const name = `task-stream:rate:${key}`;
const count = await this.redis.incr(name);
if (count === 1) await this.redis.pexpire(name, windowMs);
return count;
}
}

View File

@@ -32,6 +32,7 @@ import {
UpsertMaterializedReleaseRepository,
UpsertMaterializedTimelineRepository,
UserDirectory,
RateLimitStore,
} from '@task-stream/api/tracker/application';
import { IdentityUserDirectory } from './identity/identity-user-directory';
import {
@@ -64,6 +65,7 @@ import {
import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository';
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
import { RedisChangeFeed } from './realtime/redis-change-feed';
import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
@@ -144,6 +146,7 @@ const PORTS = [
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
// Realtime → Redis pub/sub
{ provide: ChangeFeed, useClass: RedisChangeFeed },
{ provide: RateLimitStore, useClass: RedisRateLimitStore },
// Identity context (anti-corruption layer)
{ provide: UserDirectory, useClass: IdentityUserDirectory },
];

View File

@@ -443,6 +443,10 @@ export class IssueDto {
})
blockedBy!: IssueParentDto[];
@ApiProperty({ description: 'How many want it done' }) votes!: number;
@ApiProperty({
description: 'Whether I voted for it (false without a token)',
})
voted!: boolean;
@ApiProperty({
description:
'Place in the backlog and its board column (ascending, as text)',

View File

@@ -92,7 +92,7 @@ export class ExploreController {
): Promise<ProposalResultDto> {
// Bots fill in every field: pretend it worked, and keep nothing.
if (body.website?.trim()) return { key: null, path: null };
this.limiter.take(addressOf(req));
await this.limiter.take(addressOf(req));
const result = await this.projects.propose(
project,
body.kind,

View File

@@ -135,7 +135,10 @@ export class IssuesController {
limit: query.limit,
offset: query.offset,
});
return { ...page, items: await this.refs.issueDtos(page.items) };
return {
...page,
items: await this.refs.issueDtos(page.items, viewerOf(me)),
};
}
@Get('projects/:project/issues')
@@ -173,7 +176,10 @@ export class IssuesController {
limit: query.limit,
offset: query.offset,
});
return { ...page, items: await this.refs.issueDtos(page.items) };
return {
...page,
items: await this.refs.issueDtos(page.items, viewerOf(me)),
};
}
@Post('projects/:project/issues')
@@ -219,7 +225,7 @@ export class IssuesController {
return Promise.all(
epics.map(async (epic) => ({
...epic,
issue: await this.refs.issueDto(epic.issue),
issue: await this.refs.issueDto(epic.issue, viewerOf(me)),
})),
);
}
@@ -234,7 +240,10 @@ export class IssuesController {
@CurrentUser() me: User | undefined,
@Param('issue') issue: string,
) {
return this.refs.issueDto(await this.issues.findOne(viewerOf(me), issue));
return this.refs.issueDto(
await this.issues.findOne(viewerOf(me), issue),
viewerOf(me),
);
}
@Patch('issues/:issue')
@@ -392,6 +401,7 @@ export class IssuesController {
viewerOf(me),
await this.refs.projectId(viewerOf(me), project),
),
viewerOf(me),
);
}

View File

@@ -194,7 +194,7 @@ export class ProjectsController {
return Promise.all(
columns.map(async (column) => ({
...column,
issues: await this.refs.issueDtos(column.issues),
issues: await this.refs.issueDtos(column.issues, viewerOf(me)),
})),
);
}

View File

@@ -1,33 +1,24 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { RateLimitStore } from '@task-stream/api/tracker/application';
/** Proposals from one address within the window. */
const LIMIT = 5;
const WINDOW_MS = 15 * 60_000;
/**
* Keeps one address from flooding a project with proposals. In memory:
* enough for one instance, and an approximation with several (each counts
* its own), which is fine for what it guards against.
* Keeps one address from flooding a project with proposals. Counted in
* Redis, so the limit holds across every instance of the API.
*/
@Injectable()
export class ProposalLimiter {
private readonly sent = new Map<string, number[]>();
constructor(private readonly store: RateLimitStore) {}
take(address: string): void {
const now = Date.now();
const recent = (this.sent.get(address) ?? []).filter(
(at) => now - at < WINDOW_MS,
);
if (recent.length >= LIMIT)
async take(address: string): Promise<void> {
const count = await this.store.hit(`proposals:${address}`, WINDOW_MS);
if (count > LIMIT)
throw new HttpException(
'Too many proposals from here; try again in a few minutes',
HttpStatus.TOO_MANY_REQUESTS,
);
recent.push(now);
this.sent.set(address, recent);
// Forget quiet addresses now and then, so the map does not grow forever.
if (this.sent.size > 10_000)
for (const [key, times] of this.sent)
if (times.every((at) => now - at >= WINDOW_MS)) this.sent.delete(key);
}
}

View File

@@ -37,19 +37,27 @@ export class Refs {
return (await this.issues.findOne(viewerId, ref)).id;
}
/** The ticket as the REST API returns it: read model, allowed transitions and what blocks it. */
async issueDto(issue: IssueReadModel) {
/**
* The ticket as the REST API returns it: read model, allowed transitions,
* what blocks it and whether `viewerId` voted for it.
*/
async issueDto(issue: IssueReadModel, viewerId: string | null) {
const [allowedTransitions, blockedBy] = await Promise.all([
this.workflows.allowedTransitions(issue),
this.blockers.blockedBy(issue),
]);
// Who voted is only for telling someone whether they did.
const { voterIds: _voters, ...shown } = issue;
return { ...shown, allowedTransitions, blockedBy };
const { voterIds, ...shown } = issue;
return {
...shown,
allowedTransitions,
blockedBy,
voted: !!viewerId && voterIds.includes(viewerId),
};
}
issueDtos(issues: IssueReadModel[]) {
return Promise.all(issues.map((issue) => this.issueDto(issue)));
issueDtos(issues: IssueReadModel[], viewerId: string | null) {
return Promise.all(issues.map((issue) => this.issueDto(issue, viewerId)));
}
}

View File

@@ -177,7 +177,10 @@ export class ReleasesController {
limit: page.limit,
offset: page.offset,
});
return { ...found, items: await this.refs.issueDtos(found.items) };
return {
...found,
items: await this.refs.issueDtos(found.items, viewerOf(me)),
};
}
@Post('releases/:releaseId/publish')

View File

@@ -223,7 +223,10 @@ export class SprintsController {
limit: query.limit,
offset: query.offset,
});
return { ...page, items: await this.refs.issueDtos(page.items) };
return {
...page,
items: await this.refs.issueDtos(page.items, viewerOf(me)),
};
}
@Put('issues/:issue/sprint')

View File

@@ -1,12 +1,43 @@
/** Internal type. DO NOT USE DIRECTLY. */
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
/** Internal type. DO NOT USE DIRECTLY. */
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
export type Incremental<T> =
| T
| {
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
};
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
export type MeQueryVariables = Exact<{ [key: string]: never }>;
export type MeQuery = {
me: { id: string; name: string; email: string; appRoles: Array<string> };
};
export type MeQuery = { me: { id: string, name: string, email: string, appRoles: Array<string> } };
export const MeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"appRoles"}}]}}]}}]} as unknown as DocumentNode<MeQuery, MeQueryVariables>;
export const MeDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Me' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'me' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
{ kind: 'Field', name: { kind: 'Name', value: 'appRoles' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<MeQuery, MeQueryVariables>;

View File

@@ -2,3 +2,4 @@ export * from './lib/api-config';
export * from './lib/access-token-source';
export * from './lib/graphql.provider';
export * from './lib/graphql-errors';
export * from './lib/realtime-connection';

View File

@@ -14,6 +14,7 @@ import { HttpLink } from 'apollo-angular/http';
import { createClient } from 'graphql-ws';
import { AccessTokenSource } from './access-token-source';
import { API_CONFIG } from './api-config';
import { RealtimeConnection } from './realtime-connection';
/**
* Apollo client for the task-stream API: HTTP for queries/mutations,
@@ -22,6 +23,10 @@ import { API_CONFIG } from './api-config';
* The cache is disabled on purpose: state lives in the NgRx signal stores,
* which are fed by queries and kept fresh by subscriptions.
*/
/** How often the socket is pinged, and how long a pong may take. */
const PING_EVERY_MS = 10_000;
const PONG_WITHIN_MS = 5_000;
export function provideGraphql(): EnvironmentProviders {
return makeEnvironmentProviders([
provideApollo(() => {
@@ -50,16 +55,43 @@ export function provideGraphql(): EnvironmentProviders {
}
});
const connection = inject(RealtimeConnection);
// A socket that goes quiet is cut by proxies (Traefik's idle timeout),
// and one that dies without a close frame is never noticed: subscribers
// just stop hearing. Pings keep it busy; a ping with no pong in time
// closes it, and the client reconnects (with a fresh token).
let socket: WebSocket | null = null;
let pongTimeout: ReturnType<typeof setTimeout> | undefined;
let connectedBefore = false;
const ws = new GraphQLWsLink(
createClient({
url: config.graphqlWsUrl,
lazy: true,
retryAttempts: Infinity,
shouldRetry: () => true,
keepAlive: PING_EVERY_MS,
connectionParams: async () => {
const token = await tokens.accessToken();
return token ? { authorization: `Bearer ${token}` } : {};
},
on: {
connected: (opened) => {
socket = opened as WebSocket;
// What happened while it was down was lost: views reload.
if (connectedBefore) connection.reconnected();
connectedBefore = true;
},
ping: (received) => {
if (received) return;
pongTimeout = setTimeout(() => {
if (socket?.readyState === WebSocket.OPEN)
socket.close(4408, 'No pong');
}, PONG_WITHIN_MS);
},
pong: (received) => {
if (received) clearTimeout(pongTimeout);
},
},
}),
);

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
/**
* The state of the subscriptions' socket that the app cares about: when it
* comes back after a cut. Whatever happened meanwhile was not received, so
* views reload when it does.
*/
@Injectable({ providedIn: 'root' })
export class RealtimeConnection {
private readonly back = new Subject<void>();
/** Each time the socket reconnects (not the first time it connects). */
readonly reconnected$: Observable<void> = this.back.asObservable();
/** Called by the GraphQL client. */
reconnected(): void {
this.back.next();
}
}

View File

@@ -41,6 +41,7 @@ const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
/** Every command succeeds, every query answers once, nothing arrives in real time. */
function gateways() {
const reconnected = new Subject<void>();
return {
projects: {
project: vi.fn(() => of(project)),
@@ -92,7 +93,9 @@ function gateways() {
releaseChanges: () => NEVER,
sprintChanges: () => NEVER,
projectChanges: vi.fn((): unknown => NEVER),
reconnects: () => reconnected,
},
reconnected,
};
}
@@ -161,6 +164,19 @@ describe('BoardFacade', () => {
expect(board.filter()).toEqual({ projectId: 'p2' });
});
it('reloads what it shows when the live connection comes back', () => {
const { board, fakes } = setup();
board.show({ projectId: 'p1', assigneeId: 'u2' });
fakes.issues.board.mockClear();
fakes.reconnected.next();
expect(fakes.issues.board).toHaveBeenCalledWith({
projectId: 'p1',
assigneeId: 'u2',
});
});
it('saves the current filter as a view and knows when it is showing one', () => {
const { board, fakes } = setup();
const view = {
@@ -480,7 +496,7 @@ describe('InboxFacade', () => {
{ provide: NotificationsGateway, useValue: gateway },
{
provide: TrackerRealtimeGateway,
useValue: { notifications: () => arrivals },
useValue: { notifications: () => arrivals, reconnects: () => NEVER },
},
{
provide: Notifications,

View File

@@ -79,6 +79,7 @@ export const BacklogStore = signalStore(
switchMap(({ payload }) =>
merge(
realtime.issueChanges(payload.projectId).pipe(debounceTime(400)),
realtime.reconnects(),
events.on(backlogApiEvents.moveFailed, backlogApiEvents.bulkDone),
).pipe(
startWith(null),

View File

@@ -20,6 +20,7 @@ import {
distinctUntilChanged,
EMPTY,
exhaustMap,
filter,
map,
merge,
mergeMap,
@@ -112,6 +113,12 @@ export const BoardStore = signalStore(
load$: events
.on(boardPageEvents.opened, boardPageEvents.filterChanged)
.pipe(switchMap(({ payload }) => load(payload))),
// Back after a cut: what moved meanwhile was not received.
resync$: realtime.reconnects().pipe(
map(() => store.filter()),
filter((current): current is BoardFilter => !!current),
switchMap((current) => load(current)),
),
live$: events.on(boardPageEvents.opened).pipe(
map(({ payload }) => payload.projectId),
distinctUntilChanged(),

View File

@@ -49,6 +49,7 @@ export const EpicsStore = signalStore(
switchMap(({ payload }) =>
merge(
realtime.issueChanges(payload.projectId),
realtime.reconnects(),
// One's own vote, without waiting for the live update.
events.on(epicsApiEvents.voted),
).pipe(

View File

@@ -85,7 +85,11 @@ export const InboxStore = signalStore(
) => {
const closed$ = events.on(inboxPageEvents.closed);
return {
load$: events.on(inboxPageEvents.opened).pipe(
// Also when the live connection comes back: what arrived meanwhile was missed.
load$: merge(
events.on(inboxPageEvents.opened),
realtime.reconnects(),
).pipe(
switchMap(() =>
gateway.mine(false, LIMIT).pipe(
mapResponse({

View File

@@ -243,39 +243,41 @@ export const IssueStore = signalStore(
* API projects its read model a moment after answering, so the
* ticket, its comments and its subtasks are asked for twice.
*/
catchUp$: events
.on(
catchUp$: merge(
events.on(
issueApiEvents.commandSucceeded,
issueApiEvents.checklistSaved,
issuePageEvents.imagePasted,
)
.pipe(
map(() => store.issue()),
filter((issue): issue is Issue => !!issue),
switchMap((issue) =>
merge(
...[300, 1500].map((ms) =>
timer(ms).pipe(
switchMap(() =>
merge(
fetchIssue(issue.id),
comments
.list(issue.id)
.pipe(
map((list) => issueApiEvents.commentsLoaded(list)),
),
[
issueRealtimeEvents.childrenTouched({
issueId: issue.id,
}),
],
),
),
// Back after a cut: comments or changes may have been missed.
realtime.reconnects(),
).pipe(
map(() => store.issue()),
filter((issue): issue is Issue => !!issue),
switchMap((issue) =>
merge(
...[300, 1500].map((ms) =>
timer(ms).pipe(
switchMap(() =>
merge(
fetchIssue(issue.id),
comments
.list(issue.id)
.pipe(
map((list) => issueApiEvents.commentsLoaded(list)),
),
[
issueRealtimeEvents.childrenTouched({
issueId: issue.id,
}),
],
),
),
),
),
),
),
),
liveComments$: events.on(issuePageEvents.opened).pipe(
switchMap(() => loaded$.pipe(map(({ payload }) => payload.id))),
filter((id, index) => index === 0 || id !== store.issue()?.id),

View File

@@ -100,6 +100,7 @@ export const ListStore = signalStore(
),
merge(
realtime.issueChanges(payload.projectId),
realtime.reconnects(),
events.on(
listApiEvents.changed,
listApiEvents.changeFailed,

View File

@@ -242,6 +242,20 @@ export const ProjectStore = signalStore(
),
),
),
// Back after a cut: refetch it, and its details follow `loaded`.
resync$: realtime.reconnects().pipe(
map(() => store.project()?.key),
filter((key): key is string => !!key),
switchMap((key) =>
projects.project(key).pipe(
mapResponse({
next: (project) => projectApiEvents.loaded(project),
error: (error) =>
projectApiEvents.loadFailed(describeError(error)),
}),
),
),
),
details$: loaded$.pipe(
switchMap(({ payload }) =>
merge(

View File

@@ -14,7 +14,7 @@ import {
ProjectsGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import { exhaustMap, map, switchMap, tap } from 'rxjs';
import { exhaustMap, map, merge, switchMap, tap } from 'rxjs';
import {
projectsApiEvents,
projectsPageEvents,
@@ -74,7 +74,11 @@ export const ProjectsStore = signalStore(
router = inject(Router),
auth = inject(AuthFacade),
) => ({
load$: events.on(projectsPageEvents.opened).pipe(
load$: merge(
events.on(projectsPageEvents.opened),
// Back after a cut: being added or removed meanwhile was missed.
realtime.reconnects(),
).pipe(
switchMap(() =>
projects.myProjects().pipe(
mapResponse({

View File

@@ -54,6 +54,7 @@ export enum NotificationKind {
COMMENTED = 'COMMENTED',
MENTIONED = 'MENTIONED',
REPLIED = 'REPLIED',
PROPOSED = 'PROPOSED',
WATCHED = 'WATCHED',
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
REMINDER = 'REMINDER',

View File

@@ -370,4 +370,9 @@ export abstract class TrackerRealtimeGateway {
abstract projectChanges(projectId: string): Observable<ProjectChanged>;
abstract myProjectsChanges(): Observable<ProjectChanged>;
abstract notifications(): Observable<Notification>;
/**
* Each time the live connection comes back after a cut: what happened
* meanwhile was not received, so views reload.
*/
abstract reconnects(): Observable<void>;
}

View File

@@ -1,5 +1,8 @@
import { inject, Injectable } from '@angular/core';
import { AccessTokenSource } from '@task-stream/web/shared/infrastructure';
import {
AccessTokenSource,
RealtimeConnection,
} from '@task-stream/web/shared/infrastructure';
import {
Activity,
CommentChanged,
@@ -38,6 +41,11 @@ import {
export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
private readonly gql = inject(GraphqlOperations);
private readonly tokens = inject(AccessTokenSource);
private readonly connection = inject(RealtimeConnection);
reconnects(): Observable<void> {
return this.connection.reconnected$;
}
private signedIn<T>(stream: () => Observable<T>): Observable<T> {
return defer(() => this.tokens.accessToken()).pipe(

View File

@@ -212,6 +212,7 @@ export type NotificationKind =
| 'ASSIGNED'
| 'COMMENTED'
| 'MENTIONED'
| 'PROPOSED'
| 'QA_FAILED'
| 'REMINDER'
| 'REPLIED'

View File

@@ -772,6 +772,9 @@ export class BoardPage {
event: CdkDragDrop<BoardColumn, BoardColumn, IssueSummary>,
): void {
if (event.previousContainer === event.container) return;
// Let go over a column it may not enter: the CDK leaves it in the last
// one it could, which is not where the person aimed. Back where it was.
if (!event.isPointerOverContainer) return;
const to = event.container.data.status;
const issueId = event.item.data.id;
const target = this.board.columns().find((c) => c.status === to);

View File

@@ -562,102 +562,6 @@ a.ts-key {
align-items: center;
}
// Waiting for other tickets: shown next to the key, with links to them.
.ts-blocked {
display: inline-flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d33) 12%, transparent);
color: var(--ts-c-red, var(--ts-ink));
font-size: 0.8125rem;
font-weight: 600;
.ts-key {
color: inherit;
}
}
// 👍 2 · 🎉 1 · (+)
.ts-reactions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
margin-top: 0.5rem;
}
.ts-reaction {
display: inline-flex;
gap: 0.25rem;
align-items: center;
// @pi/ui styles every <button> too specifically to override otherwise.
min-width: 0 !important;
height: auto !important;
padding: 0.125rem 0.5rem !important;
border: 1px solid var(--ts-line) !important;
border-radius: 999px !important;
background: var(--ts-surface);
color: inherit;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
.ts-mono {
font-size: 0.75rem;
}
&.is-mine {
border-color: var(--ts-brand) !important;
background: color-mix(in srgb, var(--ts-brand) 12%, var(--ts-surface));
}
&:disabled {
cursor: default;
}
}
.ts-reaction-picker {
position: relative;
summary {
display: inline-grid;
place-items: center;
width: 1.75rem;
height: 1.625rem;
border: 1px dashed var(--ts-line-strong, var(--ts-line));
border-radius: 999px;
color: var(--ts-muted);
cursor: pointer;
list-style: none;
&::-webkit-details-marker {
display: none;
}
}
}
.ts-reaction-picker__menu {
position: absolute;
z-index: 5;
bottom: calc(100% + 0.375rem);
left: 0;
display: flex;
gap: 0.125rem;
padding: 0.25rem;
border: 1px solid var(--ts-line);
border-radius: 0.75rem;
background: var(--ts-surface);
box-shadow: 0 8px 24px rgb(0 0 0 / 14%);
button {
width: 2rem;
min-width: 0 !important;
height: 2rem;
padding: 0 !important;
border: 0 !important;
border-radius: 0.5rem !important;
background: none;
font-size: 1.125rem;
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--ts-brand) 12%, transparent);
}
}
}
.ts-votes {
padding: 0.25rem 0.625rem;
border: 1px solid var(--ts-line);

View File

@@ -49,7 +49,6 @@ import {
mentionQuery,
POINT_SCALE,
ReleaseStatus,
REACTIONS,
} from '@task-stream/web/tracker/domain';
import {
clock,
@@ -63,6 +62,8 @@ import {
typeLook,
} from '../ui/copy';
import { AttachmentItem } from '../ui/attachments';
import { BlockedComponent } from '../ui/blocked';
import { ReactionsComponent } from '../ui/reactions';
import {
AttachmentLoader,
ImageUploader,
@@ -79,6 +80,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
selector: 'ts-issue-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
BlockedComponent,
ReactionsComponent,
MarkdownComponent,
MarkdownHintComponent,
PasteImagesDirective,
@@ -144,19 +147,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</a>
}
@if (issue.blockedBy.length) {
<span
class="ts-blocked"
role="status"
[title]="blockedTitle(issue.blockedBy)"
>
<pi-icon icon="lock" size="x-small" aria-hidden="true" />
Bloqueado por
@for (blocker of issue.blockedBy; track blocker.id) {
<a class="ts-key" [routerLink]="['..', blocker.key]">{{
blocker.key
}}</a>
}
</span>
<ts-blocked [blockers]="issue.blockedBy" />
}
</div>
<div class="ts-issue__title-row">
@@ -634,50 +625,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
!comment.deleted &&
(comment.reactions.length || project.myRole())
) {
<div class="ts-reactions">
@for (
reaction of comment.reactions;
track reaction.emoji
) {
<button
type="button"
class="ts-reaction"
[class.is-mine]="reacted(reaction)"
[attr.aria-pressed]="reacted(reaction)"
[title]="reactionTitle(reaction)"
[disabled]="!project.myRole()"
(click)="
facade.react(
comment.id,
reaction.emoji,
!reacted(reaction)
)
"
>
{{ reaction.emoji }}
<span class="ts-mono">{{
reaction.users.length
}}</span>
</button>
}
@if (project.myRole()) {
<details class="ts-reaction-picker">
<summary title="Reaccionar" aria-label="Reaccionar">
<pi-icon icon="add" size="x-small" />
</summary>
<div class="ts-reaction-picker__menu">
@for (emoji of reactions; track emoji) {
<button
type="button"
(click)="pickReaction(comment, emoji, $event)"
>
{{ emoji }}
</button>
}
</div>
</details>
}
</div>
<ts-reactions
[reactions]="comment.reactions"
[me]="facade.me()"
[canReact]="!!project.myRole()"
(react)="
facade.react(comment.id, $event.emoji, $event.on)
"
/>
}
@if (!comment.deleted && filesOf(comment).length) {
<div class="ts-attachments">
@@ -1194,30 +1149,6 @@ export class IssuePage {
() => this.facade.comments().filter((c) => !c.deleted).length,
);
protected readonly commentFiles = signal<File[]>([]);
protected readonly reactions = REACTIONS;
protected reacted(reaction: { users: { id: string }[] }): boolean {
return reaction.users.some((u) => u.id === this.facade.me());
}
protected reactionTitle(reaction: {
emoji: string;
users: { name: string }[];
}): string {
return `${reaction.users.map((u) => u.name).join(', ')} · ${reaction.emoji}`;
}
/** From the picker: adds it, or takes it back if it was already there. */
protected pickReaction(comment: Comment, emoji: string, event: Event): void {
const mine = comment.reactions.find((r) => r.emoji === emoji);
this.facade.react(comment.id, emoji, !(mine && this.reacted(mine)));
(event.target as HTMLElement).closest('details')?.removeAttribute('open');
}
protected blockedTitle(blockers: { key: string; title: string }[]): string {
return blockers.map((b) => `${b.key}: ${b.title}`).join('\n');
}
protected duplicate(): void {
this.facade.duplicate().subscribe({
next: (key) => {
@@ -1285,8 +1216,24 @@ export class IssuePage {
protected readonly maxFileBytes = MAX_FILE_BYTES;
/** Files on the ticket itself; those under a comment are shown there. */
/** Files shown inside the description or a comment (pasted images). */
private readonly inlineFiles = computed(() => {
const texts = [
this.facade.issue()?.description ?? '',
...this.facade.comments().map((c) => c.body),
].join('\n');
return new Set(
[...texts.matchAll(/\(attachment:([\w-]{1,64})\)/g)].map((m) => m[1]),
);
});
/** The ticket's own files, not the ones already shown inside a text. */
protected readonly ticketFiles = computed(
() => this.facade.issue()?.attachments.filter((a) => !a.commentId) ?? [],
() =>
this.facade
.issue()
?.attachments.filter(
(a) => !a.commentId && !this.inlineFiles().has(a.id),
) ?? [],
);
protected filesOf = (comment: Comment): Attachment[] =>
this.facade

View File

@@ -29,6 +29,7 @@ import {
IssueSummary,
StatusCategory,
} from '@task-stream/web/tracker/domain';
import { BlockedComponent } from '../ui/blocked';
import { BulkBarComponent } from '../ui/bulk-bar';
import { VoteComponent } from '../ui/vote';
import {
@@ -77,6 +78,7 @@ const numberOf = (key: string) => Number(key.split('-').pop());
selector: 'ts-list-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
BlockedComponent,
BulkBarComponent,
VoteComponent,
RouterLink,
@@ -183,12 +185,11 @@ const numberOf = (key: string) => Number(key.split('-').pop());
issue.title
}}</a>
@if (issue.blockedBy.length) {
<span
<ts-blocked
class="ts-list__blocked"
[title]="'Bloqueado por ' + blockersOf(issue)"
>
<pi-icon icon="lock" size="x-small" /> Bloqueado
</span>
[blockers]="issue.blockedBy"
[compact]="true"
/>
}
@if (issue.parent; as parent) {
<span class="ts-muted ts-mono" [title]="parent.title">
@@ -388,16 +389,7 @@ const numberOf = (key: string) => Number(key.split('-').pop());
}
}
.ts-list__blocked {
display: inline-flex;
gap: 0.25rem;
align-items: center;
margin-left: 0.5rem;
padding: 0 0.375rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d33) 12%, transparent);
color: var(--ts-c-red, var(--ts-ink));
font-size: 0.75rem;
font-weight: 600;
}
.ts-list__type-label {
display: inline-flex;
@@ -644,10 +636,6 @@ export class ListPage {
this.facade.assign(issue.id, assigneeId);
}
protected blockersOf(issue: IssueSummary): string {
return issue.blockedBy.map((b) => b.key).join(', ');
}
protected isLate(issue: IssueSummary): boolean {
return (
!!issue.dueDate &&

View File

@@ -0,0 +1,54 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiIconComponent } from '@pi/ui';
import { IssueParent } from '@task-stream/web/tracker/domain';
/**
* 🔒 «Bloqueado»: the ticket waits for others still open. `compact` for
* cards and rows (the keys go in the tooltip); in full, they link.
*/
@Component({
selector: 'ts-blocked',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent, RouterLink],
host: { role: 'status', '[title]': 'tooltip()' },
template: `
<pi-icon icon="lock" size="x-small" aria-hidden="true" />
@if (compact()) {
Bloqueado
} @else {
Bloqueado por
@for (blocker of blockers(); track blocker.id) {
<a class="ts-key" [routerLink]="['..', blocker.key]">{{
blocker.key
}}</a>
}
}
`,
styles: `
:host {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem 0.375rem;
align-items: center;
padding: 0.0625rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d33) 12%, transparent);
color: var(--ts-c-red, var(--ts-ink));
font-size: 0.75rem;
font-weight: 600;
}
.ts-key {
color: inherit;
}
`,
})
export class BlockedComponent {
readonly blockers = input.required<IssueParent[]>();
readonly compact = input(false);
protected tooltip(): string {
return this.blockers()
.map((b) => `${b.key}: ${b.title}`)
.join('\n');
}
}

View File

@@ -1,3 +1,4 @@
import { BlockedComponent } from './blocked';
import {
ChangeDetectionStrategy,
Component,
@@ -18,7 +19,7 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
@Component({
selector: 'ts-issue-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiIconComponent],
imports: [RouterLink, PiIconComponent, BlockedComponent],
template: `
<a
class="ts-card"
@@ -32,15 +33,7 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
</span>
<span class="ts-card__key">{{ issue().key }}</span>
@if (issue().blockedBy.length) {
<span
class="ts-card__blocked"
[attr.title]="
'Bloqueado por ' + issue().blockedBy.map(blockerKey).join(', ')
"
>
<pi-icon icon="lock" size="x-small" />
Bloqueado
</span>
<ts-blocked [blockers]="issue().blockedBy" [compact]="true" />
}
@if (issue().area; as area) {
<span class="ts-card__area">{{ areaLabel(area) }}</span>
@@ -191,17 +184,6 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
font-weight: 600;
color: var(--ts-ink-soft);
}
.ts-card__blocked {
display: inline-flex;
gap: 0.25rem;
align-items: center;
padding: 0 0.375rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-c-red, #d33) 12%, transparent);
color: var(--ts-c-red, var(--ts-ink));
font-size: 0.6875rem;
font-weight: 600;
}
.ts-card__area {
margin-left: auto;
font-family: var(--ts-font-mono);
@@ -281,8 +263,6 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
`,
})
export class IssueCardComponent {
protected readonly blockerKey = (blocker: { key: string }) => blocker.key;
/** Late, due today or in the next two days. */
protected readonly due = computed(() =>
dueState(this.issue().dueDate, this.issue().statusCategory),

View File

@@ -28,6 +28,7 @@ const ICON: Record<NotificationKind, string> = {
[NotificationKind.COMMENTED]: 'chat',
[NotificationKind.MENTIONED]: 'chat',
[NotificationKind.REPLIED]: 'chat',
[NotificationKind.PROPOSED]: 'light-bulb',
[NotificationKind.WATCHED]: 'focus',
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
[NotificationKind.REMINDER]: 'calendar',

View File

@@ -0,0 +1,150 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { PiIconComponent } from '@pi/ui';
import { REACTIONS, UserRef } from '@task-stream/web/tracker/domain';
/**
* 👍 2 · 🎉 1 · (+) under a comment: a click adds or takes back one's own;
* the (+) offers the rest. Read-only for whoever cannot react.
*/
@Component({
selector: 'ts-reactions',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent],
template: `
@for (reaction of reactions(); track reaction.emoji) {
<button
type="button"
class="ts-reaction"
[class.is-mine]="mine(reaction)"
[attr.aria-pressed]="mine(reaction)"
[title]="title(reaction)"
[disabled]="!canReact()"
(click)="react.emit({ emoji: reaction.emoji, on: !mine(reaction) })"
>
{{ reaction.emoji }}
<span class="ts-mono">{{ reaction.users.length }}</span>
</button>
}
@if (canReact()) {
<details class="ts-reaction-picker">
<summary title="Reaccionar" aria-label="Reaccionar">
<pi-icon icon="add" size="x-small" />
</summary>
<div class="ts-reaction-picker__menu">
@for (emoji of offered; track emoji) {
<button type="button" (click)="pick(emoji, $event)">
{{ emoji }}
</button>
}
</div>
</details>
}
`,
styles: `
:host {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
margin-top: 0.5rem;
}
/* @pi/ui styles every <button> too specifically to override without !important. */
.ts-reaction {
display: inline-flex;
gap: 0.25rem;
align-items: center;
min-width: 0 !important;
height: auto !important;
padding: 0.125rem 0.5rem !important;
border: 1px solid var(--ts-line) !important;
border-radius: 999px !important;
background: var(--ts-surface);
color: inherit;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
.ts-mono {
font-size: 0.75rem;
}
&.is-mine {
border-color: var(--ts-brand) !important;
background: color-mix(in srgb, var(--ts-brand) 12%, var(--ts-surface));
}
&:disabled {
cursor: default;
}
}
.ts-reaction-picker {
position: relative;
summary {
display: inline-grid;
place-items: center;
width: 1.75rem;
height: 1.625rem;
border: 1px dashed var(--ts-line-strong, var(--ts-line));
border-radius: 999px;
color: var(--ts-muted);
cursor: pointer;
list-style: none;
&::-webkit-details-marker {
display: none;
}
}
}
.ts-reaction-picker__menu {
position: absolute;
z-index: 5;
bottom: calc(100% + 0.375rem);
left: 0;
display: flex;
gap: 0.125rem;
padding: 0.25rem;
border: 1px solid var(--ts-line);
border-radius: 0.75rem;
background: var(--ts-surface);
box-shadow: 0 8px 24px rgb(0 0 0 / 14%);
button {
width: 2rem;
min-width: 0 !important;
height: 2rem;
padding: 0 !important;
border: 0 !important;
border-radius: 0.5rem !important;
background: none;
font-size: 1.125rem;
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--ts-brand) 12%, transparent);
}
}
}
`,
})
export class ReactionsComponent {
readonly reactions = input.required<{ emoji: string; users: UserRef[] }[]>();
/** Signed-in user's id. */
readonly me = input<string | null>(null);
readonly canReact = input(false);
readonly react = output<{ emoji: string; on: boolean }>();
protected readonly offered = REACTIONS;
protected mine(reaction: { users: UserRef[] }): boolean {
return reaction.users.some((u) => u.id === this.me());
}
protected title(reaction: { emoji: string; users: UserRef[] }): string {
return `${reaction.users.map((u) => u.name).join(', ')} · ${reaction.emoji}`;
}
/** From the picker: adds it, or takes it back if it was already there. */
protected pick(emoji: string, event: Event): void {
const current = this.reactions().find((r) => r.emoji === emoji);
this.react.emit({ emoji, on: !(current && this.mine(current)) });
(event.target as HTMLElement).closest('details')?.removeAttribute('open');
}
}