feat(web): mention people with @ when replying to a comment

The reply box suggests the project's members as the main comment box
does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 13:29:28 +02:00
parent f986f17968
commit 3c7c1f7f0e

View File

@@ -608,9 +608,33 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
novalidate novalidate
> >
<pi-textarea <pi-textarea
[placeholder]="'Responde a ' + thread.comment.author.name + '…'" [placeholder]="
'Responde a ' +
thread.comment.author.name +
'… Escribe @ para mencionar a alguien.'
"
[(value)]="replyBody" [(value)]="replyBody"
/> />
@if (replyMentionOptions().length) {
<div
class="ts-mentions"
role="group"
aria-label="Mencionar a"
>
@for (member of replyMentionOptions(); track member.id) {
<button
type="button"
class="ts-mentions__option"
(click)="mentionInReply(member)"
>
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(member.name)
}}</span>
{{ member.name }}
</button>
}
</div>
}
<div class="ts-reply__actions"> <div class="ts-reply__actions">
<pi-action-button <pi-action-button
color="primary" color="primary"
@@ -1024,17 +1048,13 @@ export class IssuePage {
protected readonly commentFiles = signal<File[]>([]); protected readonly commentFiles = signal<File[]>([]);
protected readonly segments = mentionSegments; protected readonly segments = mentionSegments;
/** Members matching what is typed after an `@` at the end of the comment. */ /** Members matching what is typed after an `@` at the end of the comment. */
protected readonly mentionOptions = computed(() => { protected readonly mentionOptions = computed(() =>
const query = mentionQuery(this.commentBody()); this.membersMatching(this.commentBody()),
if (query === null) return []; );
return this.project /** The same, for the answer being written. */
.members() protected readonly replyMentionOptions = computed(() =>
.map((m) => m.user) this.membersMatching(this.replyBody()),
.filter( );
(user) => user.id !== this.facade.me() && matches(user.name, query),
)
.slice(0, 6);
});
/** Pull requests first, then the newest. */ /** Pull requests first, then the newest. */
protected readonly devLinks = computed(() => protected readonly devLinks = computed(() =>
[...(this.facade.issue()?.devLinks ?? [])].sort( [...(this.facade.issue()?.devLinks ?? [])].sort(
@@ -1226,6 +1246,23 @@ export class IssuePage {
this.replyingTo.set(null); this.replyingTo.set(null);
} }
/** Members whose name fits the `@…` being typed at the end of `text`. */
private membersMatching(text: string) {
const query = mentionQuery(text);
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 mentionInReply(user: { id: string; name: string }): void {
this.replyBody.update((text) => insertMention(text, user));
}
protected mention(user: { id: string; name: string }): void { protected mention(user: { id: string; name: string }): void {
this.commentBody.update((text) => insertMention(text, user)); this.commentBody.update((text) => insertMention(text, user));
} }