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:
@@ -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';
|
||||
|
||||
30
libs/web/tracker/domain/src/lib/models/mentions.spec.ts
Normal file
30
libs/web/tracker/domain/src/lib/models/mentions.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
52
libs/web/tracker/domain/src/lib/models/mentions.ts
Normal file
52
libs/web/tracker/domain/src/lib/models/mentions.ts
Normal 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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user