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:
@@ -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