From be75602285fa0fd875accd0f09ce020bdb4b7548 Mon Sep 17 00:00:00 2001 From: eti Date: Wed, 23 Sep 2026 03:29:42 +0200 Subject: [PATCH] web: recommend participants and collaborators on a bare mention Signed-off-by: eti --- .../repo/issues/IssueThreadView.svelte | 14 +++++++ .../components/ui/MarkdownTypeahead.svelte | 34 ++++++++++++++- .../components/ui/markdownSuggestions.test.ts | 41 +++++++++++++++++++ .../lib/components/ui/markdownSuggestions.ts | 34 +++++++++++---- web/src/lib/components/ui/mentionPeople.ts | 14 +++++++ .../[version]/[[range]]/PullViewPage.svelte | 3 ++ 6 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/components/ui/markdownSuggestions.test.ts create mode 100644 web/src/lib/components/ui/mentionPeople.ts diff --git a/web/src/lib/components/repo/issues/IssueThreadView.svelte b/web/src/lib/components/repo/issues/IssueThreadView.svelte index e54e23613..4e043ff3a 100644 --- a/web/src/lib/components/repo/issues/IssueThreadView.svelte +++ b/web/src/lib/components/repo/issues/IssueThreadView.svelte @@ -41,6 +41,7 @@ import Button from "$lib/components/ui/Button.svelte"; import Spinner from "$lib/components/ui/Spinner.svelte"; import Card from "$lib/components/ui/Card.svelte"; + import { setMentionPeople, type MentionPerson } from "$lib/components/ui/mentionPeople"; import { renderMarkup } from "$lib/markup"; import type { MarkupContext } from "$lib/markup/paths"; import type { Did } from "@atcute/lexicons/syntax"; @@ -66,6 +67,19 @@ comments = props.comments; }); + const participants = $derived.by(() => { + const people: MentionPerson[] = [{ did: issue.authorDid, handle: issue.authorHandle }]; + for (const thread of comments) { + for (const comment of [thread.self, ...thread.replies]) { + if (people.some((person) => person.did === comment.authorDid)) continue; + people.push({ did: comment.authorDid, handle: comment.authorHandle }); + } + } + return people; + }); + + setMentionPeople(() => participants); + const entityUri = $derived(`at://${issue.authorDid}/sh.tangled.repo.issue/${issue.rkey}`); $effect(() => { diff --git a/web/src/lib/components/ui/MarkdownTypeahead.svelte b/web/src/lib/components/ui/MarkdownTypeahead.svelte index 627c46e07..8cf3fa8c0 100644 --- a/web/src/lib/components/ui/MarkdownTypeahead.svelte +++ b/web/src/lib/components/ui/MarkdownTypeahead.svelte @@ -4,14 +4,19 @@ import { resolve } from "$app/paths"; import { page } from "$app/state"; import { createBobbinClient } from "$lib/api/client"; + import { listCollaborators } from "$lib/api/collaborators"; + import { getAuth } from "$lib/auth.svelte"; import type { RepoInfo } from "$lib/components/repo/types"; import { TICKET_STATE_ICONS } from "$lib/components/repo/tickets/TicketStatePill.svelte"; import Avatar from "$lib/components/ui/Avatar.svelte"; import { tagInput } from "$lib/components/ui/TagInput.svelte"; import { applyEdit, findTrigger, type Trigger } from "$lib/components/ui/markdownFormat"; + import { getMentionPeople, type MentionPerson } from "$lib/components/ui/mentionPeople"; import { filterTickets, loadTickets, + RECOMMENDATION_LIMIT, + recommendUsers, suggestUsers, type Suggestion, type TicketSuggestion @@ -32,6 +37,8 @@ const bobbin = $derived(bobbinUrl ? createBobbinClient({ serviceUrl: bobbinUrl }) : undefined); const repo = $derived(page.data.repo as Promise | RepoInfo | undefined); const slots = tagInput({ size: "regular" }); + const auth = getAuth(); + const mentionPeople = getMentionPeople(); let trigger = $state(null); let items = $state([]); @@ -43,6 +50,7 @@ let timer: ReturnType | undefined; let request: AbortController | undefined; let tickets: Promise | undefined; + let collaborators: Promise | undefined; const open = $derived( trigger !== null && (items.length > 0 || status !== "ready" || trigger.query !== "") @@ -72,6 +80,25 @@ return tickets; }; + const collaboratorList = () => { + collaborators ??= Promise.resolve(repo).then((info) => + info && bobbin ? listCollaborators(bobbin, info) : [] + ); + collaborators.catch(() => { + collaborators = undefined; + }); + return collaborators; + }; + + const recommendations = async () => { + const known = mentionPeople?.() ?? []; + const exclude = auth?.currentDid ?? undefined; + const participants = recommendUsers(known, exclude); + if (participants.length >= RECOMMENDATION_LIMIT) return participants; + const members = await collaboratorList().catch(() => []); + return recommendUsers([...known, ...members], exclude); + }; + const settle = async (controller: AbortController, pending: Promise) => { try { const result = await pending; @@ -98,7 +125,12 @@ ); return; } - if (next.query === "" || !bobbin) { + if (next.query === "") { + status = "loading"; + void settle(controller, recommendations()); + return; + } + if (!bobbin) { items = []; status = "ready"; return; diff --git a/web/src/lib/components/ui/markdownSuggestions.test.ts b/web/src/lib/components/ui/markdownSuggestions.test.ts new file mode 100644 index 000000000..d88939b0d --- /dev/null +++ b/web/src/lib/components/ui/markdownSuggestions.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { recommendUsers } from "$lib/components/ui/markdownSuggestions"; + +const person = (n: number) => ({ did: `did:plc:${n}`, handle: `user${n}.test` }); + +describe("recommendUsers", () => { + it("keeps the first five distinct people in order", () => { + const people = [ + person(1), + person(2), + person(1), + person(3), + person(4), + person(5), + person(6) + ]; + expect(recommendUsers(people, undefined).map((user) => user.handle)).toEqual([ + "user1.test", + "user2.test", + "user3.test", + "user4.test", + "user5.test" + ]); + }); + + it("skips the viewer and people without a usable handle", () => { + const people = [ + person(1), + { did: "did:plc:x" }, + { did: "did:plc:y", handle: "handle.invalid" }, + person(2) + ]; + expect(recommendUsers(people, "did:plc:1").map((user) => user.handle)).toEqual([ + "user2.test" + ]); + }); + + it("inserts a mention with a trailing space", () => { + expect(recommendUsers([person(7)], undefined)[0].insert).toBe("@user7.test "); + }); +}); diff --git a/web/src/lib/components/ui/markdownSuggestions.ts b/web/src/lib/components/ui/markdownSuggestions.ts index 65c2da56a..8f1ddf285 100644 --- a/web/src/lib/components/ui/markdownSuggestions.ts +++ b/web/src/lib/components/ui/markdownSuggestions.ts @@ -1,9 +1,11 @@ import type { BobbinContext } from "$lib/api/client"; +import { INVALID_HANDLE } from "$lib/api/identity"; import { listIssues } from "$lib/api/issue"; import { listPulls } from "$lib/api/records"; import { searchActorsTypeahead } from "$lib/api/search"; import { rkeyFromUri } from "$lib/api/uri"; import type { TicketState } from "$lib/components/repo/tickets/TicketStatePill.svelte"; +import type { MentionPerson } from "$lib/components/ui/mentionPeople"; export interface UserSuggestion { kind: "user"; @@ -32,6 +34,7 @@ export interface TicketRepo { } const SUGGESTION_LIMIT = 8; +export const RECOMMENDATION_LIMIT = 5; const TICKET_FETCH_LIMIT = 100; export const suggestUsers = async ( @@ -45,13 +48,30 @@ export const suggestUsers = async ( { q: query, limit: SUGGESTION_LIMIT }, { signal } ); - return actors.map((actor) => ({ - kind: "user", - key: actor.did, - did: actor.did, - handle: actor.handle, - insert: `@${actor.handle} ` - })); + return actors.map((actor) => userSuggestion(actor.did, actor.handle)); +}; + +const userSuggestion = (did: string, handle: string): UserSuggestion => ({ + kind: "user", + key: did, + did, + handle, + insert: `@${handle} ` +}); + +export const recommendUsers = ( + people: MentionPerson[], + exclude: string | undefined +): UserSuggestion[] => { + const seen = new Set(exclude ? [exclude] : []); + const picked: UserSuggestion[] = []; + for (const { did, handle } of people) { + if (!handle || handle === INVALID_HANDLE || seen.has(did)) continue; + seen.add(did); + picked.push(userSuggestion(did, handle)); + if (picked.length === RECOMMENDATION_LIMIT) break; + } + return picked; }; const ticket = ( diff --git a/web/src/lib/components/ui/mentionPeople.ts b/web/src/lib/components/ui/mentionPeople.ts new file mode 100644 index 000000000..af29d88ff --- /dev/null +++ b/web/src/lib/components/ui/mentionPeople.ts @@ -0,0 +1,14 @@ +import { getContext, setContext } from "svelte"; + +export interface MentionPerson { + did: string; + handle?: string; +} + +const MENTION_PEOPLE = Symbol("mention-people"); + +export const setMentionPeople = (people: () => MentionPerson[]) => + setContext(MENTION_PEOPLE, people); + +export const getMentionPeople = () => + getContext<(() => MentionPerson[]) | undefined>(MENTION_PEOPLE); diff --git a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte index 0b3c53130..721849389 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte +++ b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte @@ -55,6 +55,7 @@ import ErrorAlert from "$lib/components/ui/Error.svelte"; import DiffView from "$lib/components/repo/DiffView.svelte"; import PullDiscussion from "$lib/components/repo/pulls/PullDiscussion.svelte"; + import { setMentionPeople } from "$lib/components/ui/mentionPeople"; import PullReviewCommentForm from "$lib/components/repo/pulls/PullReviewCommentForm.svelte"; import type { Range } from "$lib/components/ui/RangeSelector.svelte"; import type { CommitSummary } from "$lib/components/repo/types"; @@ -251,6 +252,8 @@ return people; }); + setMentionPeople(() => participants); + let mockAssigneeDid = $state(); const mockAssignee = $derived( -- 2.51.2