diff --git a/src/services/mentionSyntax.test.ts b/src/services/mentionSyntax.test.ts index ee381ae..6216bcc 100644 --- a/src/services/mentionSyntax.test.ts +++ b/src/services/mentionSyntax.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, it } from "node:test"; -import { canonicalizeMentions, mentionTarget } from "./mentionSyntax"; +import { canonicalizeMentions, mentionsAsText, mentionTarget } from "./mentionSyntax"; interface Vectors { targets: { href: string; target: unknown }[]; @@ -54,3 +54,30 @@ describe("what the server stores", () => { assert.equal(out.forNicknames.includes("[@Ada](mention:user_1)"), true); }); }); + +describe("mention links as words, for previews and search (GRYT-2025)", () => { + const nickname = async (id: string) => ({ user_leo: "Leo", user_ada: "Ada" } as Record)[id] ?? null; + + it("names users by their current nickname, even with an empty or stale label", async () => { + assert.equal(await mentionsAsText("[](mention:user_leo) look at this", nickname), "@Leo look at this"); + assert.equal(await mentionsAsText("thanks [@OldName](mention:user_ada)!", nickname), "thanks @Ada!"); + assert.equal(await mentionsAsText("[@Gone](mention:user_gone) and [](mention:user_x)", nickname), "@Gone and @someone"); + }); + + it("turns mass, role and channel links into words", async () => { + assert.equal( + await mentionsAsText("[@everyone](mention:everyone) [@here](mention:here) [@Mods](role:mods) see [#channel](channel:abc)", nickname), + "@everyone @here @Mods see #channel", + ); + }); + + it("leaves code and ordinary links alone", async () => { + assert.equal(await mentionsAsText("`[](mention:user_leo)` and [docs](https://gryt.chat)", nickname), "`[](mention:user_leo)` and [docs](https://gryt.chat)"); + }); + + it("keeps the name and drops the markup where a snippet cut through a link", async () => { + assert.equal(await mentionsAsText("eo](mention:user_leo) said hi", nickname), "eo said hi"); + assert.equal(await mentionsAsText("said hi to [@Le", nickname), "said hi to [@Le"); + assert.equal(await mentionsAsText("said hi to [@Leo](mention:user_l", nickname), "said hi to @Leo"); + }); +}); diff --git a/src/services/mentionSyntax.ts b/src/services/mentionSyntax.ts index 5a2f2ad..5d022ef 100644 --- a/src/services/mentionSyntax.ts +++ b/src/services/mentionSyntax.ts @@ -184,3 +184,40 @@ export function canonicalizeMentions(text: string, rights: MentionRights): Canon return { text: result, everyone, here, roleIds, forNicknames }; } + +/* What's left where a snippet cut through a link: the end of one at the start, the start of one at the end. */ +const CUT_HEAD = /^([^[\]\n]*)\]\((?:mention|role|channel):[^)\s]*\)?/; +const CUT_TAIL = /\[([^\]\n]*)(?:\]\((?:mention|role|channel):[^)\s]*)?$/; + +/** + * Mention, role and channel links as the words they stand for, for text shown outside a + * message (GRYT-2025). Users by their current nickname, since a link's label can be empty or stale. + */ +export async function mentionsAsText(text: string, nickname: (serverUserId: string) => Promise): Promise { + const names = new Map(); + for (const m of text.matchAll(LINK)) { + const target = mentionTarget(m[2]); + if (target?.kind === "user" && !names.has(target.id)) names.set(target.id, await nickname(target.id)); + } + const words = (segment: string) => + segment.replace(LINK, (whole: string, label: string, href: string) => { + const target = mentionTarget(href); + if (!target) return whole; + const bare = label.trim().replace(/^[@#]/, ""); + if (target.kind === "user") { + const name = names.get(target.id) ?? bare; + return name ? `@${name}` : "@someone"; + } + if (target.kind === "everyone" || target.kind === "here") return `@${target.kind}`; + if (target.kind === "role") return bare ? `@${bare}` : "@role"; + return CHANNEL_LABEL; + }); + let out = ""; + let last = 0; + for (const m of text.matchAll(LITERAL)) { + const start = m.index ?? 0; + out += words(text.slice(last, start)) + m[0]; + last = start + m[0].length; + } + return (out + words(text.slice(last))).replace(CUT_HEAD, "$1").replace(CUT_TAIL, (whole, label: string) => (whole.includes("](") ? label : whole)); +} diff --git a/src/socket/handlers/chat.ts b/src/socket/handlers/chat.ts index 5e1cdd5..eba8561 100644 --- a/src/socket/handlers/chat.ts +++ b/src/socket/handlers/chat.ts @@ -66,7 +66,7 @@ import { applyAutoRoles } from "../../services/autoRoles"; import { findMentions, type MentionableMember } from "../../services/mentions"; import { pushNotify } from "../../services/push"; import { messagePreview, previewText } from "../../services/pushPreview"; -import { canonicalizeMentions, type CanonicalMentions, type MentionRights } from "../../services/mentionSyntax"; +import { canonicalizeMentions, mentionsAsText, type CanonicalMentions, type MentionRights } from "../../services/mentionSyntax"; import { massMentionAudience } from "../../services/massMentions"; import { mayInChannel, mayViewChannel, visibleChannelIds } from "../../services/channelPermissions"; import { fileReadVerdict } from "../../services/fileAccess"; @@ -131,6 +131,18 @@ async function getMentionableMembers(): Promise { return members; } +/** Current nicknames for `mentionsAsText`, each member looked up once per request. */ +function nicknameLookup(): (serverUserId: string) => Promise { + const seen = new Map>(); + return (id) => { + const known = seen.get(id); + if (known) return known; + const next = getUserByServerId(id).then((u) => u?.nickname ?? null, () => null); + seen.set(id, next); + return next; + }; +} + /* The wire shape of a thread, the same one thread:created sends, so a client that reloads ends up with what a client that watched them arrive has. */ function toThreadSummary(t: ThreadRecord, recentRepliers?: string[]) { @@ -1065,11 +1077,12 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { const { items: hits, total } = await searchMessages({ kind, query, conversationIds, senderServerId, before, after, has, limit }); const hidden = await blockedServerIdsFor(userId); const items = hidden.size === 0 ? hits : hits.filter((h) => !hidden.has(h.sender_server_id)); + const nickname = nicknameLookup(); socket.emit("chat:search:results", { requestId, query, kind, - items: items.map((h) => ({ ...h, created_at: h.created_at.toISOString() })), + items: await Promise.all(items.map(async (h) => ({ ...h, snippet: await mentionsAsText(h.snippet, nickname), created_at: h.created_at.toISOString() }))), total, hasMore: hits.length >= Math.min(Math.max(Math.floor(limit), 1), 50), }); @@ -1473,6 +1486,7 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { // Same base fields as chat:fetch and thread:created, plus what only a // topic row needs: participant count and the root's author and preview. + const nickname = nicknameLookup(); const topics = await Promise.all(threads.map(async (t) => { const root = rootById.get(t.root_message_id); const participantCount = await countThreadParticipants(t.thread_id, t.root_message_id); @@ -1482,7 +1496,7 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { creator_server_id: t.created_by, creator_nickname: root?.sender_nickname ?? null, creator_avatar_file_id: root?.sender_avatar_file_id ?? null, - preview: root?.text ? root.text.slice(0, 200) : null, + preview: root?.text ? (await mentionsAsText(root.text, nickname)).slice(0, 200) : null, }; })); socket.emit("forum:topics:list", { conversation_id: payload.conversationId, topics }); diff --git a/src/socket/handlers/forumTopics.test.ts b/src/socket/handlers/forumTopics.test.ts index da0ebe0..a1f3cb1 100644 --- a/src/socket/handlers/forumTopics.test.ts +++ b/src/socket/handlers/forumTopics.test.ts @@ -122,6 +122,19 @@ describe("forum topics", () => { assert.ok(t.preview && t.preview.startsWith("After my laptop wakes")); }); + it("shows a mention in the preview as the person's name, not the link (GRYT-2025)", async () => { + await alice.handlers["forum:topic:create"]({ + conversationId: FORUM, + title: "Mentions in previews", + text: `[](mention:${bob.serverUserId}) can you check this?`, + accessToken: alice.accessToken, + }); + await bob.handlers["forum:topics"]({ conversationId: FORUM }); + const list = bob.received("forum:topics:list").at(-1) as { topics: TopicSummary[] }; + const t = list.topics.find((x) => x.title === "Mentions in previews"); + assert.equal(t?.preview, "@Bob can you check this?"); + }); + it("counts a second person once they reply", async () => { await bob.handlers["chat:send"]({ conversationId: FORUM,