Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/services/mentionSyntax.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
Expand Down Expand Up @@ -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<string, string>)[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");
});
});
37 changes: 37 additions & 0 deletions src/services/mentionSyntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>): Promise<string> {
const names = new Map<string, string | null>();
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));
}
20 changes: 17 additions & 3 deletions src/socket/handlers/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -131,6 +131,18 @@ async function getMentionableMembers(): Promise<MentionableMember[]> {
return members;
}

/** Current nicknames for `mentionsAsText`, each member looked up once per request. */
function nicknameLookup(): (serverUserId: string) => Promise<string | null> {
const seen = new Map<string, Promise<string | null>>();
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[]) {
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand Down
13 changes: 13 additions & 0 deletions src/socket/handlers/forumTopics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading