From c82ce978fbb13d565bf9e809b88f489b29d8190e Mon Sep 17 00:00:00 2001 From: Sivert <316918682+grytchat@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:37:30 +0100 Subject: [PATCH] Forum previews and search results show mentions as names (GRYT-2025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leo found it in the community server. A topic with no title falls back to a preview of its first message, and the server built that as root.text.slice(0, 200): the raw [label](mention:user_…) markup, sometimes cut through the middle of a link. Desktop and phone both showed it as is. Search snippets came out the same way. mentionsAsText turns mention, role and channel links into words before anything is cut. Users go by their current nickname, because a link's label can be empty (as in Leo's case) or stale. Links inside code are left alone, and the end of a link a snippet cut through is trimmed down to its label. Co-Authored-By: Claude Opus 5.5 --- src/services/mentionSyntax.test.ts | 29 ++++++++++++++++++- src/services/mentionSyntax.ts | 37 +++++++++++++++++++++++++ src/socket/handlers/chat.ts | 20 +++++++++++-- src/socket/handlers/forumTopics.test.ts | 13 +++++++++ 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/services/mentionSyntax.test.ts b/src/services/mentionSyntax.test.ts index ee381ae7..6216bcc1 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 5a2f2add..5d022ef2 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 5e1cdd51..eba8561e 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 da0ebe08..a1f3cb10 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,