feat: mentions and following tickets

Mention project members in comments with @, follow and unfollow tickets,
and get told when something changes on the tickets you follow. Commenting
on a ticket starts following it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:41:32 +02:00
parent efa06a0723
commit 88928b402f
49 changed files with 1318 additions and 41 deletions

View File

@@ -35,6 +35,7 @@ export const issuePageEvents = eventGroup({
/** Uploaded under the new comment once it exists. */
files?: File[];
}>(),
watchRequested: type<{ issueId: string; watching: boolean }>(),
attachRequested: type<{
issueId: string;
files: File[];

View File

@@ -30,6 +30,8 @@ export class IssueFacade {
readonly error = this.store.error;
/** Signed-in user's id (authors can edit their comments). */
readonly me = this.store.me;
/** Whether the signed-in person follows the open ticket. */
readonly watching = this.store.watching;
/** Loads the ticket (by id or key, e.g. BE-3) and follows its changes. */
open(idOrKey: string): void {
@@ -85,6 +87,13 @@ export class IssueFacade {
);
}
/** Follow the ticket (or stop): be told when it moves or gets a comment. */
setWatching(watching: boolean): void {
this.withIssue((issueId) =>
this.dispatch.watchRequested({ issueId, watching }),
);
}
/** Attaches files to the ticket (or under one of its comments). */
attach(files: File[], commentId: string | null = null): void {
if (!files.length) return;

View File

@@ -60,6 +60,11 @@ export const IssueStore = signalStore(
}),
withComputed(({ issue }, auth = inject(AuthFacade)) => ({
me: computed(() => auth.user()?.id ?? null),
watching: computed(
() =>
!!auth.user() &&
(issue()?.watchers ?? []).some((w) => w.id === auth.user()?.id),
),
outwardLinks: computed(
() => issue()?.links.filter((l) => l.direction === 'OUTWARD') ?? [],
),

View File

@@ -3,3 +3,4 @@ export * from './lib/models/models';
export * from './lib/models/inputs';
export * from './lib/models/workflow';
export * from './lib/ports/gateways';
export * from './lib/models/mentions';

View File

@@ -0,0 +1,30 @@
import {
insertMention,
matches,
mentionQuery,
mentionSegments,
} from './mentions';
describe('mentions', () => {
it('splits a text into words and mentions', () => {
expect(mentionSegments('Hola @[Ana](u1), ¿lo miras?')).toEqual([
{ text: 'Hola ' },
{ mention: 'Ana', userId: 'u1' },
{ text: ', ¿lo miras?' },
]);
});
it('notices an @ being typed and completes it', () => {
expect(mentionQuery('Pregunta a @an')).toBe('an');
expect(mentionQuery('Pregunta a @')).toBe('');
expect(mentionQuery('correo@dominio')).toBeNull();
expect(
insertMention('Pregunta a @an', { id: 'u1', name: 'Ana García' }),
).toBe('Pregunta a @[Ana García](u1) ');
});
it('finds people ignoring accents and case', () => {
expect(matches('José Pérez', 'jose pe')).toBe(true);
expect(matches('Ana', 'bob')).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
/**
* Mentions are stored as `@[Ana García](<user id>)` — the API reads the id to
* tell that person; people see `@Ana García`.
*/
const MENTION = /@\[([^\]\n]{1,80})\]\(([\w-]{1,64})\)/g;
export type TextSegment =
| { text: string }
| { mention: string; userId: string };
/** The text in pieces, to show mentions highlighted. */
export function mentionSegments(text: string): TextSegment[] {
const segments: TextSegment[] = [];
let last = 0;
for (const match of text.matchAll(MENTION)) {
const at = match.index ?? 0;
if (at > last) segments.push({ text: text.slice(last, at) });
segments.push({ mention: match[1], userId: match[2] });
last = at + match[0].length;
}
if (last < text.length) segments.push({ text: text.slice(last) });
return segments;
}
const TYPING = /(^|\s)@([^\s@[\]()]{0,30})$/u;
/** What is being typed after an `@` at the end of the text, or null. */
export function mentionQuery(text: string): string | null {
return TYPING.exec(text)?.[2] ?? null;
}
/** Replaces the `@what-was-typed` at the end with the mention. */
export function insertMention(
text: string,
user: { id: string; name: string },
): string {
const name = user.name.replace(/[[\]()\n]/g, ' ').trim();
return text.replace(
TYPING,
(_, before: string) => `${before}@[${name}](${user.id}) `,
);
}
/** Case- and accent-insensitive "contains". */
export function matches(name: string, query: string): boolean {
const fold = (s: string) =>
s
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase();
return fold(name).includes(fold(query));
}

View File

@@ -86,6 +86,8 @@ export interface Issue extends IssueSummary {
dueDate: string | null;
links: IssueLink[];
attachments: Attachment[];
/** People following the ticket. */
watchers: UserRef[];
allowedTransitions: string[];
createdAt: string;
}

View File

@@ -87,6 +87,8 @@ export abstract class IssuesGateway {
issueId: string,
releaseId: string | null,
): Observable<CommandResult>;
/** The signed-in person follows (or stops following) the ticket. */
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
}
export abstract class CommentsGateway {

View File

@@ -25,6 +25,8 @@ import {
SearchIssuesDocument,
SetIssueReleaseDocument,
UnlinkIssuesDocument,
UnwatchIssueDocument,
WatchIssueDocument,
UpdateIssueDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@@ -119,6 +121,16 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.pipe(map((d) => toCommandResult(d.unlinkIssues)));
}
watch(issueId: string, watching: boolean): Observable<CommandResult> {
return watching
? this.gql
.mutate(WatchIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.watchIssue)))
: this.gql
.mutate(UnwatchIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.unwatchIssue)));
}
setRelease(
issueId: string,
releaseId: string | null,

View File

@@ -88,6 +88,9 @@ fragment IssueFields on Issue {
issueId
issueKey
}
watchers {
...UserRefFields
}
attachments {
id
fileName

View File

@@ -248,6 +248,7 @@ export type IssueFieldsFragment = {
issueId: string;
issueKey: string;
}>;
watchers: Array<{ id: string; name: string }>;
attachments: Array<{
id: string;
fileName: string;
@@ -362,6 +363,7 @@ export type IssueQuery = {
issueId: string;
issueKey: string;
}>;
watchers: Array<{ id: string; name: string }>;
attachments: Array<{
id: string;
fileName: string;
@@ -451,6 +453,22 @@ export type UnlinkIssuesMutation = {
unlinkIssues: { id: string; key: string | null; version: number };
};
export type WatchIssueMutationVariables = Exact<{
issueId: string;
}>;
export type WatchIssueMutation = {
watchIssue: { id: string; key: string | null; version: number };
};
export type UnwatchIssueMutationVariables = Exact<{
issueId: string;
}>;
export type UnwatchIssueMutation = {
unwatchIssue: { id: string; key: string | null; version: number };
};
export type SetIssueReleaseMutationVariables = Exact<{
issueId: string;
releaseId?: string | null | undefined;
@@ -1400,6 +1418,19 @@ export const IssueFieldsFragmentDoc = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'watchers' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'attachments' },
@@ -2066,6 +2097,19 @@ export const IssueDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'watchers' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'attachments' },
@@ -2671,6 +2715,143 @@ export const UnlinkIssuesDocument = {
UnlinkIssuesMutation,
UnlinkIssuesMutationVariables
>;
export const WatchIssueDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'WatchIssue' },
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: 'watchIssue' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommandResultFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommandResultFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'CommandResult' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
],
},
},
],
} as unknown as DocumentNode<WatchIssueMutation, WatchIssueMutationVariables>;
export const UnwatchIssueDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UnwatchIssue' },
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: 'unwatchIssue' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'CommandResultFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'CommandResultFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'CommandResult' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
],
},
},
],
} as unknown as DocumentNode<
UnwatchIssueMutation,
UnwatchIssueMutationVariables
>;
export const SetIssueReleaseDocument = {
kind: 'Document',
definitions: [

View File

@@ -66,6 +66,18 @@ mutation UnlinkIssues($input: LinkIssuesInput!) {
}
}
mutation WatchIssue($issueId: ID!) {
watchIssue(issueId: $issueId) {
...CommandResultFields
}
}
mutation UnwatchIssue($issueId: ID!) {
unwatchIssue(issueId: $issueId) {
...CommandResultFields
}
}
mutation SetIssueRelease($issueId: ID!, $releaseId: ID) {
setIssueRelease(issueId: $issueId, releaseId: $releaseId) {
...CommandResultFields

View File

@@ -345,3 +345,39 @@ a.ts-key {
gap: 0.5rem;
margin: 0.5rem 0;
}
.ts-mention {
padding: 0 0.2em;
border-radius: 0.25em;
background: color-mix(in oklab, var(--ts-brand) 14%, transparent);
color: inherit;
font-weight: 600;
}
.ts-mention.is-me {
background: color-mix(in oklab, var(--ts-brand) 30%, transparent);
}
.ts-mentions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.ts-mentions__option {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.625rem 0.25rem 0.25rem;
border: 1px solid rgba(127, 127, 127, 0.3);
border-radius: 999px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.ts-mentions__option:hover,
.ts-mentions__option:focus-visible {
border-color: currentColor;
}

View File

@@ -35,9 +35,13 @@ import {
import {
Attachment,
Comment,
insertMention,
IssueLink,
IssueLinkType,
IssuePriority,
matches,
mentionQuery,
mentionSegments,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
import {
@@ -95,6 +99,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</div>
<div class="ts-issue__title-row">
<h1>{{ issue.title }}</h1>
@if (project.myRole()) {
<pi-action-button
size="small"
color="grey"
appearance="outline"
[text]="
facade.watching()
? 'Siguiendo · ' + issue.watchers.length
: 'Seguir'
"
[attr.aria-pressed]="facade.watching()"
(execute)="facade.setWatching(!facade.watching())"
/>
}
@if (project.canContribute()) {
<pi-icon-button
icon="edit"
@@ -287,7 +305,19 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
/>
}
</div>
<p class="ts-prewrap">{{ comment.body }}</p>
<p class="ts-prewrap">
@for (part of segments(comment.body); track $index) {
@if ('mention' in part) {
<span
class="ts-mention"
[class.is-me]="part.userId === facade.me()"
>@{{ part.mention }}</span
>
} @else {
{{ part.text }}
}
}
</p>
@if (filesOf(comment).length) {
<div class="ts-attachments">
@for (file of filesOf(comment); track file.id) {
@@ -308,9 +338,29 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
novalidate
>
<pi-textarea
placeholder="Deja una decisión, una explicación, un paso para reproducir…"
placeholder="Deja una decisión, una explicación, un paso para reproducir… Escribe @ para mencionar a alguien."
[(value)]="commentBody"
/>
@if (mentionOptions().length) {
<div
class="ts-mentions"
role="group"
aria-label="Mencionar a"
>
@for (member of mentionOptions(); track member.id) {
<button
type="button"
class="ts-mentions__option"
(click)="mention(member)"
>
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(member.name)
}}</span>
{{ member.name }}
</button>
}
</div>
}
<pi-file-upload
label="Adjuntar al comentario"
[multiple]="true"
@@ -576,6 +626,19 @@ export class IssuePage {
protected readonly linkTarget = signal('');
protected readonly commentBody = signal('');
protected readonly commentFiles = signal<File[]>([]);
protected readonly segments = mentionSegments;
/** Members matching what is typed after an `@` at the end of the comment. */
protected readonly mentionOptions = computed(() => {
const query = mentionQuery(this.commentBody());
if (query === null) return [];
return this.project
.members()
.map((m) => m.user)
.filter(
(user) => user.id !== this.facade.me() && matches(user.name, query),
)
.slice(0, 6);
});
protected readonly pendingFiles = signal<File[]>([]);
protected readonly maxFileBytes = MAX_FILE_BYTES;
@@ -679,6 +742,10 @@ export class IssuePage {
this.commentFiles.set([]);
}
protected mention(user: { id: string; name: string }): void {
this.commentBody.update((text) => insertMention(text, user));
}
protected attach(): void {
this.facade.attach(this.pendingFiles());
this.pendingFiles.set([]);

View File

@@ -190,7 +190,7 @@ const ICON: Record<NotificationKind, string> = {
height: 0.5rem;
margin-top: 0.5rem;
border-radius: 50%;
background: #a347e1;
background: var(--ts-brand, #a347e1);
}
.ts-inbox__empty {
opacity: 0.7;