Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2398cb7
fix(emoji): show custom emoji after a link that ends at CJK punctuation
cheng-block Nov 6, 2026
d2df590
fix(emoji): keep tags unchanged for spaced, escaped and HTTPS:// links
cheng-block Nov 6, 2026
4a9a14f
fix(emoji): find links with a case-sensitive scheme again
cheng-block Nov 6, 2026
28a7361
fix(emoji): keep a URL nested in another link whole
cheng-block Nov 6, 2026
6906428
fix(emoji): keep every URL inside a link destination whole
cheng-block Nov 6, 2026
b319962
fix(emoji): split every URL in a link destination as before
cheng-block Nov 6, 2026
80bd8c0
fix(emoji): leave a scheme with nothing after it as text
cheng-block Nov 6, 2026
ecc8d5d
fix(emoji): keep escaped and Unicode-spaced URLs inside a destination
cheng-block Nov 6, 2026
301ccf2
fix(emoji): use CommonMark whitespace for destination pieces
cheng-block Nov 6, 2026
e7815c9
fix(emoji): keep a reference definition's URL whole
cheng-block Nov 6, 2026
6eac882
fix(emoji): keep a quoted or listed reference definition's URL whole
cheng-block Nov 6, 2026
0d205e6
fix(emoji): find a reference definition after leading whitespace
cheng-block Nov 6, 2026
62080fe
fix(emoji): keep an angle destination inside another destination whole
cheng-block Nov 6, 2026
86eb30f
fix(emoji): find a link definition only after a line ending
cheng-block Nov 6, 2026
5c51fa2
fix(emoji): drop the reference-definition guard
cheng-block Nov 6, 2026
31c6441
fix(emoji): leave reference definitions out of emoji tags
cheng-block Nov 9, 2026
9fb2009
fix(emoji): tag the stored text and split around definitions
cheng-block Nov 9, 2026
c3151a3
fix(emoji): find reference definitions in deeply nested Markdown
cheng-block Nov 9, 2026
6a095e8
Merge upstream/main into cheng/fix-emoji-after-cjk-link
cheng-block Nov 10, 2026
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
20 changes: 20 additions & 0 deletions src/features/messages/MessageMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,26 @@ describe("Markdown inline extensions", () => {
);
});

it("renders custom emoji after a bare link that ends at CJK punctuation", () => {
const html = render("看这个 https://example.com,:party_parrot:", {
emoji: [party],
extensions,
media: () => "https://media.test/emoji.png",
});
expect([...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1])).toEqual([
"https://example.com/",
]);
expect(html).toContain('alt=":party_parrot:"');
expect(html.replace(/<[^>]*>/g, "")).toBe("看这个 https://example.com,");
expect(
render("@Mic [x]: https://example.com,:party_parrot:", {
emoji: [party],
extensions,
media: () => "https://media.test/emoji.png",
}),
).toContain('alt=":party_parrot:"');
});

it("keeps emoji-only sizing and readable fallback when media or extensions are unavailable", () => {
const options = { emoji: [party], extensions, largeEmoji: true };
const html = render(":party_parrot:", {
Expand Down
12 changes: 8 additions & 4 deletions src/features/messages/message-link-parts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseBuzzLink } from "../navigation/buzz-links";
import { BARE_URL_REST } from "../relay/message-content";

type LinkPart = { text: string; url?: string; label?: string };

Expand Down Expand Up @@ -82,6 +83,12 @@ function closingParenthesis(content: string, start: number, escaped: boolean) {
return undefined;
}

const linkPattern = new RegExp(
/(?<!\\)\[((?:\\[^\r\n]|[^[\]\\\r\n])+)\]\\?\(|<(?:https?|buzz):\/\/[^\s<>"`]+>|(?:https?|buzz):\/\//
.source + BARE_URL_REST,
"gi",
);

/** Display-only link syntax. Message storage and navigation keep the original destination. */
export function messageLinkParts(
content: string,
Expand All @@ -95,10 +102,7 @@ export function messageLinkParts(
): LinkPart[] {
const parts: LinkPart[] = [];
let offset = 0;
// CJK punctuation ends a bare URL, except 。.。 before a host label (URLs read them as dots).
for (const match of content.matchAll(
/(?<!\\)\[((?:\\[^\r\n]|[^[\]\\\r\n])+)\]\\?\(|<(?:https?|buzz):\/\/[^\s<>"`]+>|(?:https?|buzz):\/\/(?:[^\s<>"`:/?#,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]|[。.。](?=[a-z\d-]+(?![a-z\d-]|:\/\/)))*(?:[:/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/gi,
)) {
for (const match of content.matchAll(linkPattern)) {
if (match.index < offset) continue;
if (match[1] !== undefined) {
const start = match.index + match[0].length;
Expand Down
81 changes: 81 additions & 0 deletions src/features/relay/emoji.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest";
import { createEmojiDirectory } from "./emoji-directory";
import {
EMOJI_SET,
contentEmojiTags,
emojiTags,
referencedEmoji,
suggestShortcode,
Expand Down Expand Up @@ -82,6 +83,86 @@ it("normalizes first-wins scheme-agnostic emoji tags and leaves URL fragments ou
),
).toEqual(["party", "other"]);
});
it("tags a shortcode after a link and CJK punctuation, but not inside links", () => {
const entries = [{ shortcode: "party", url }];
for (const content of [
"看这个 https://example.com,:party:",
"https://example.com/a。:party:",
"链接=https://example.com,:party:",
"价格<100看这个https://example.com,:party:",
"[a](HTTPS://example.com),:party:",
"[a](HTTPS://example.com),:party:https://b.com",
"Check this\n[x]: https://example.com,:party:",
"](\n\n[x]: https://example.com\n\nhttps://example.com,:party:",
]) {
expect(contentEmojiTags(content, entries)).toEqual([
["emoji", "party", url],
]);
expect(referencedEmoji(content)).toEqual(["party"]);
}
for (const content of [
"https://example.com/a:party:",
"<https://example.com/a,:party:>",
"[a](https://example.com/a,:party:)",
"[a]( https://example.com/a,:party:)",
String.raw`[a]\(https://example.com/a,:party:\)`,
"HTTPS://example.com/?redirect=https://other.com/:party:",
"[a](HTTPS://example.com/?redirect=https://other.com/a,:party:)",
"<HTTPS://example.com/?redirect=https://other.com/a,:party:>",
"[a](HTTPS://x.com/(https://y.com/a,:party:))",
"[a](HTTPS://x.com/?urls=HTTPS://one.com,https://two.com/a,:party:)",
'[a](https://x.com/?url="https://y.com/a,:party:")',
"[a](<https://x.com/a b?u=https://y.com/a,:party:>)",
String.raw`[a](<https://x.com/a\>https://y.com/a,:party:>)`,
String.raw`[a](<https://x.com/a\<x?url=https://y.com/a,:party:>)`,
"[a](https://x.com/a\u3000https://y.com/a,:party:)",
"<https://x.com/a\u3000https://y.com/a,:party:>",
"[a](<https://x.com/a\\\u2028https://y.com/a,:party:>)",
"[a](<https://x.com/a\\\u2029https://y.com/a,:party:>)",
"[a](\u2028<x>https://y.com/a,:party:)",
"https://github。COM/:party:",
"[ref]: https://example.com/a,:party:",
"Hi\n\n> [ref]: https://example.com/a,:party:",
"- [ref]:\n https://example.com/a,:party:",
]) {
expect(contentEmojiTags(content, entries)).toEqual([]);
expect(referencedEmoji(content)).toEqual([]);
}
expect(referencedEmoji("see :http://")).toEqual(["http"]);
expect(
referencedEmoji("[ref]: https://example.com/a,:party:\n:other:"),
).toEqual(["other"]);
});
it("sends a reference definition's shortcode without the catalog", async () => {
const h = session();
// The app stores trimmed text, where these are definitions too.
const contents = [
"[ref]: https://example.com/a,:party:",
"\u3000[ref]: https://example.com/a,:party:",
" [ref]: https://example.com/a,:party:",
`${"> ".repeat(101)}[ref]: https://example.com/a,:party:`,
];
const sent = () =>
contents.flatMap((content) => {
const id = h.session.messages.send("c", content);
return (
h.session.outbox
?.snapshot()
.find((item) => item.event.id === id)
?.event.tags.filter(([name]) => name === "emoji") ?? [["missing"]]
);
});
expect(sent()).toEqual([]);
expect(h.session.emoji.snapshot().status).not.toBe("ready");
for (const pending of h.wire.pending.splice(0)) pending.respond([]);
await flush();
const ready = h.session.emoji.ensure();
await flush();
h.wire.next().fail(new Error("offline"));
await ready;
expect(h.session.emoji.snapshot().status).toBe("error");
expect(sent()).toEqual([]);
});
it("unions latest complete member sets; empty replacements and ties survive stale reads", async () => {
const h = session();
const loading = h.session.emoji.ensure();
Expand Down
28 changes: 24 additions & 4 deletions src/features/relay/emoji.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EventData } from "./events.ts";
import { BARE_URL_REST, definitionRanges } from "./message-content.ts";

export type CustomEmoji = Readonly<{ shortcode: string; url: string }>;
export const EMOJI_SET = "buzz:custom-emoji";
Expand Down Expand Up @@ -78,10 +79,29 @@ export function emojiTags(event: {
}
return Object.freeze([...entries.values()]);
}
/** Share token boundaries with the renderer: never substitute inside HTTP(S) links. */
/** Share token boundaries with the renderer: never substitute inside HTTP(S) links.
* Bare links end at the same CJK punctuation as rendered ones. Within a `<url>` or
* `](url)` destination, a URL ends only at whitespace or `<>"\``. */
const destinations =
/<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^ \t\r\n<>]*|\]\\?\([ \t\r\n]*(?:<(?:\\[^\r\n]|[^<>\r\n\\])*|[^ \t\r\n]*)/
.source;
const scheme = /https?:\/\/(?=[^\s<>"`])/.source;
const links = new RegExp(`(${destinations}|${scheme}${BARE_URL_REST})`, "g");
const runs = /(https?:\/\/[^\s<>"`]+)/;
export const messageParts = (content: string) =>
content.split(/(https?:\/\/[^\s<>"`]+)/g);
content.split(links).flatMap((part) => part.split(runs));
export const linkPart = (part: string) => /^https?:\/\//.test(part);
/** Message parts outside reference definitions, which render no emoji. */
function sentParts(content: string) {
if (!/:[\w-]{1,64}:/.test(content)) return messageParts(content);
const parts: string[] = [];
let offset = 0;
for (const { start, end } of definitionRanges(content)) {
parts.push(...messageParts(content.slice(offset, start)));
offset = end;
}
return [...parts, ...messageParts(content.slice(offset))];
}
const shortcodePattern = () => /(?=(:([a-zA-Z0-9_-]{1,64}):))/g;
export function* emojiMatches(
content: string,
Expand Down Expand Up @@ -127,7 +147,7 @@ export function contentEmojiTags(
entries: readonly CustomEmoji[],
): string[][] {
const used = new Map<string, string>();
for (const part of messageParts(content)) {
for (const part of sentParts(content)) {
if (linkPart(part)) continue;
for (const { emoji } of emojiMatches(part, entries))
used.set(emoji.shortcode, emoji.url);
Expand All @@ -137,7 +157,7 @@ export function contentEmojiTags(
export function referencedEmoji(content: string): string[] {
return [
...new Set(
messageParts(content).flatMap((part) =>
sentParts(content).flatMap((part) =>
linkPart(part)
? []
: [...part.matchAll(shortcodePattern())].map((m) =>
Expand Down
28 changes: 28 additions & 0 deletions src/features/relay/message-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export function safeMessageUrl(value: string): string | undefined {
return url?.startsWith("https:") ? url : undefined;
}

/** A bare URL after `scheme://`. CJK punctuation ends it, except 。.。 before
* a host label (URLs read them as dots). */
export const BARE_URL_REST =
/(?:[^\s<>"`:/?#,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]|[。.。](?=[a-zA-Z\d-]+(?![a-zA-Z\d-]|:\/\/)))*(?:[:/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/
.source;

type MarkdownNode = {
type: string;
value?: string;
Expand Down Expand Up @@ -112,6 +118,28 @@ export function scanMarkdown(content: string): MarkdownScan {
return { tree, tooDeep, definitions, images, links };
}

/** Source ranges of reference definitions, in order. They render no text. */
export function definitionRanges(
content: string,
): readonly { start: number; end: number }[] {
if (!content.includes("]:") || content.length > MAX_MARKDOWN_LENGTH)
return [];
const { tree } = scanMarkdown(content);
const ranges: { start: number; end: number }[] = [];
const pending = [tree];
for (let node = pending.pop(); node; node = pending.pop()) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (node.type === "definition" && start !== undefined && end !== undefined)
ranges.push({ start, end });
for (let index = (node.children?.length ?? 0) - 1; index >= 0; index--) {
const child = node.children?.[index];
if (child) pending.push(child);
}
}
return ranges;
}

function resolvedNodeUrl(
node: MarkdownNode,
definitions: ReadonlyMap<string, string>,
Expand Down
6 changes: 3 additions & 3 deletions src/features/relay/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export function createMessages(
["h", channelId],
["e", targetId],
...attachmentSource.tags.filter((tag) => tag[0] === "imeta"),
...emojiTags(content),
...emojiTags(content.trim()),
],
});
const id = sendEdit(messageId);
Expand Down Expand Up @@ -318,7 +318,7 @@ export function createMessages(
...mentionTags(channelId, withDmAgents(channelId, mentions)),
...referenceTags(references),
...pickTags(channelId, mentions, picks),
...emojiTags(content),
...emojiTags(content.trim()),
...message.tags,
],
},
Expand Down Expand Up @@ -441,7 +441,7 @@ export function createMessages(
...referenceTags(references),
...pickTags(channelId, mentions, picks),
...(mediaTarget ? [[MEDIA_TARGET_TAG, mediaTarget]] : []),
...emojiTags(content),
...emojiTags(content.trim()),
...message.tags,
],
});
Expand Down
Loading