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

@@ -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([]);