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:
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') ?? [],
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -88,6 +88,9 @@ fragment IssueFields on Issue {
|
||||
issueId
|
||||
issueKey
|
||||
}
|
||||
watchers {
|
||||
...UserRefFields
|
||||
}
|
||||
attachments {
|
||||
id
|
||||
fileName
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user