From 2398cb78dfa0e58fb4755a1d6a2055bd6b3d697e Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 00:12:21 -0800 Subject: [PATCH 01/18] fix(emoji): show custom emoji after a link that ends at CJK punctuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom emoji typed right after a bare link and fullwidth or CJK punctuation, as in `看这个 https://example.com,:party:`, stayed text. messageParts still ended http(s) links only at whitespace, so the sender added no NIP-30 emoji tag and the renderer kept the shortcode inside the link part. Bare links now end with the same pattern messageLinkParts uses, moved to relay/message-content.ts so relay/emoji.ts stays loadable by Node's native loader. `` and `](url)` keep CJK punctuation as before. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- .../messages/MessageMarkdown.test.tsx | 13 +++++++++++ src/features/messages/message-link-parts.ts | 12 ++++++---- src/features/relay/emoji.test.ts | 22 +++++++++++++++++++ src/features/relay/emoji.ts | 11 ++++++---- src/features/relay/message-content.ts | 6 +++++ 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx index 994460bebd..c259346b39 100644 --- a/src/features/messages/MessageMarkdown.test.tsx +++ b/src/features/messages/MessageMarkdown.test.tsx @@ -922,6 +922,19 @@ 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,"); + }); + 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:", { diff --git a/src/features/messages/message-link-parts.ts b/src/features/messages/message-link-parts.ts index fe717199d7..c5e7e54524 100644 --- a/src/features/messages/message-link-parts.ts +++ b/src/features/messages/message-link-parts.ts @@ -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 }; @@ -82,6 +83,12 @@ function closingParenthesis(content: string, start: number, escaped: boolean) { return undefined; } +const linkPattern = new RegExp( + /(?"`]+>|(?:https?|buzz):\/\// + .source + BARE_URL_REST, + "gi", +); + /** Display-only link syntax. Message storage and navigation keep the original destination. */ export function messageLinkParts( content: string, @@ -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( - /(?"`]+>|(?: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; diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index 633002990e..cce0740bee 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { createEmojiDirectory } from "./emoji-directory"; import { EMOJI_SET, + contentEmojiTags, emojiTags, referencedEmoji, suggestShortcode, @@ -82,6 +83,27 @@ it("normalizes first-wins scheme-agnostic emoji tags and leaves URL fragments ou ), ).toEqual(["party", "other"]); }); +it("tags a shortcode after a bare link that ends at CJK punctuation, but not inside links", () => { + const entries = [{ shortcode: "party", url }]; + for (const content of [ + "看这个 https://example.com,:party:", + "https://example.com/a。: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:)", + ]) { + expect(contentEmojiTags(content, entries)).toEqual([]); + expect(referencedEmoji(content)).toEqual([]); + } +}); it("unions latest complete member sets; empty replacements and ties survive stale reads", async () => { const h = session(); const loading = h.session.emoji.ensure(); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index 003d2a853d..0d04c5e16f 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -1,4 +1,5 @@ import type { EventData } from "./events.ts"; +import { BARE_URL_REST } from "./message-content.ts"; export type CustomEmoji = Readonly<{ shortcode: string; url: string }>; export const EMOJI_SET = "buzz:custom-emoji"; @@ -78,10 +79,12 @@ export function emojiTags(event: { } return Object.freeze([...entries.values()]); } -/** Share token boundaries with the renderer: never substitute inside HTTP(S) links. */ -export const messageParts = (content: string) => - content.split(/(https?:\/\/[^\s<>"`]+)/g); -export const linkPart = (part: string) => /^https?:\/\//.test(part); +/** Share token boundaries with the renderer: never substitute inside HTTP(S) links. + * Bare links end where rendered ones do; `` and `](url)` keep CJK punctuation. */ +const written = /(?<=<|\]\()https?:\/\/[^\s<>"`]+/.source; +const links = new RegExp(`(${written}|https?://${BARE_URL_REST})`, "gi"); +export const messageParts = (content: string) => content.split(links); +export const linkPart = (part: string) => /^https?:\/\//i.test(part); const shortcodePattern = () => /(?=(:([a-zA-Z0-9_-]{1,64}):))/g; export function* emojiMatches( content: string, diff --git a/src/features/relay/message-content.ts b/src/features/relay/message-content.ts index 35d27882e4..18822d779b 100644 --- a/src/features/relay/message-content.ts +++ b/src/features/relay/message-content.ts @@ -23,6 +23,12 @@ export function safeMessageUrl(value: string): string | undefined { return url?.startsWith("https:") ? url : undefined; } +/** A bare URL after `scheme://`, as a case-insensitive pattern. CJK punctuation + * ends it, except 。.。 before a host label (URLs read them as dots). */ +export const BARE_URL_REST = + /(?:[^\s<>"`:/?#,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]|[。.。](?=[a-z\d-]+(?![a-z\d-]|:\/\/)))*(?:[:/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/ + .source; + type MarkdownNode = { type: string; value?: string; From d2df590150008f8cfc7043d0c740f864dfcf242f Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 01:13:34 -0800 Subject: [PATCH 02/18] fix(emoji): keep tags unchanged for spaced, escaped and HTTPS:// links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first commit also changed two cases outside the bare-link fix. A Markdown destination with whitespace after `](`, or a pasted `]\(`, ended at CJK punctuation like a bare link, so `[a]( https://example.com/a,:party:)` tagged a shortcode inside the link, and a send could wait for the emoji catalog. The case-insensitive linkPart also hid the shortcode in `[a](HTTPS://example.com),:party:`, which showed the emoji before. The destination check now accepts the `]\(` opener and the whitespace that messageLinkParts accepts, and linkPart is case-sensitive again. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 6 ++++-- src/features/relay/emoji.ts | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index cce0740bee..c95bcd17ac 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -83,11 +83,12 @@ it("normalizes first-wins scheme-agnostic emoji tags and leaves URL fragments ou ), ).toEqual(["party", "other"]); }); -it("tags a shortcode after a bare link that ends at CJK punctuation, but not inside links", () => { +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:", + "[a](HTTPS://example.com),:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([ ["emoji", "party", url], @@ -96,9 +97,10 @@ it("tags a shortcode after a bare link that ends at CJK punctuation, but not ins } 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:\)`, ]) { expect(contentEmojiTags(content, entries)).toEqual([]); expect(referencedEmoji(content)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index 0d04c5e16f..3b39fd46aa 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -80,11 +80,11 @@ export function emojiTags(event: { return Object.freeze([...entries.values()]); } /** Share token boundaries with the renderer: never substitute inside HTTP(S) links. - * Bare links end where rendered ones do; `` and `](url)` keep CJK punctuation. */ -const written = /(?<=<|\]\()https?:\/\/[^\s<>"`]+/.source; + * Bare links end at the same CJK punctuation as rendered ones; `` and `](url)` keep it. */ +const written = /(?<=<|\]\\?\(\s*)https?:\/\/[^\s<>"`]+/.source; const links = new RegExp(`(${written}|https?://${BARE_URL_REST})`, "gi"); export const messageParts = (content: string) => content.split(links); -export const linkPart = (part: string) => /^https?:\/\//i.test(part); +export const linkPart = (part: string) => /^https?:\/\//.test(part); const shortcodePattern = () => /(?=(:([a-zA-Z0-9_-]{1,64}):))/g; export function* emojiMatches( content: string, From 4a9a14fa533225ecf1672c068bc4a3c544aadf16 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 01:49:21 -0800 Subject: [PATCH 03/18] fix(emoji): find links with a case-sensitive scheme again The previous commit kept the emoji split case-insensitive, so an uppercase link could take a lowercase link after it into one part that linkPart then treats as text. In `HTTPS://example.com/?redirect=https://other.com/:party:` the sender newly tagged `:party:`, a send could wait for the emoji catalog, and the rendered link kept the final colon. The split is case-sensitive again, as it was before this PR, and BARE_URL_REST spells out A-Z in its host label check instead of relying on the i flag, so messageLinkParts still matches exactly what it did. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 2 ++ src/features/relay/emoji.ts | 2 +- src/features/relay/message-content.ts | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index c95bcd17ac..8580b249b4 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -101,6 +101,8 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "[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:", + "https://github。COM/:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([]); expect(referencedEmoji(content)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index 3b39fd46aa..fc89f5a3fc 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -82,7 +82,7 @@ export function emojiTags(event: { /** Share token boundaries with the renderer: never substitute inside HTTP(S) links. * Bare links end at the same CJK punctuation as rendered ones; `` and `](url)` keep it. */ const written = /(?<=<|\]\\?\(\s*)https?:\/\/[^\s<>"`]+/.source; -const links = new RegExp(`(${written}|https?://${BARE_URL_REST})`, "gi"); +const links = new RegExp(`(${written}|https?://${BARE_URL_REST})`, "g"); export const messageParts = (content: string) => content.split(links); export const linkPart = (part: string) => /^https?:\/\//.test(part); const shortcodePattern = () => /(?=(:([a-zA-Z0-9_-]{1,64}):))/g; diff --git a/src/features/relay/message-content.ts b/src/features/relay/message-content.ts index 18822d779b..f14d3d435e 100644 --- a/src/features/relay/message-content.ts +++ b/src/features/relay/message-content.ts @@ -23,10 +23,10 @@ export function safeMessageUrl(value: string): string | undefined { return url?.startsWith("https:") ? url : undefined; } -/** A bare URL after `scheme://`, as a case-insensitive pattern. CJK punctuation - * ends it, except 。.。 before a host label (URLs read them as dots). */ +/** 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-z\d-]+(?![a-z\d-]|:\/\/)))*(?:[:/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/ + /(?:[^\s<>"`:/?#,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]|[。.。](?=[a-zA-Z\d-]+(?![a-zA-Z\d-]|:\/\/)))*(?:[:/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/ .source; type MarkdownNode = { From 28a73610694088d711fcb83fab83cfa2f0415044 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 02:18:54 -0800 Subject: [PATCH 04/18] fix(emoji): keep a URL nested in another link whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An http(s) URL nested in a destination whose own scheme the split does not see, such as an uppercase or ftp link, took the bare-link boundary and ended at CJK punctuation. So `[a](HTTPS://example.com/?redirect=https://other.com/a,:party:)` and the same URL in `<...>` tagged a shortcode inside the link, and a send could wait for the emoji catalog. A URL right after `/`, `?`, `#`, `=` or `&` is nested in another one, so it now keeps the boundary it had before, as a URL after `<` or `](` does. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 2 ++ src/features/relay/emoji.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index 8580b249b4..d05525321c 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -102,6 +102,8 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "[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://github。COM/:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index fc89f5a3fc..c9ae4d7437 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -80,8 +80,9 @@ export function emojiTags(event: { return Object.freeze([...entries.values()]); } /** Share token boundaries with the renderer: never substitute inside HTTP(S) links. - * Bare links end at the same CJK punctuation as rendered ones; `` and `](url)` keep it. */ -const written = /(?<=<|\]\\?\(\s*)https?:\/\/[^\s<>"`]+/.source; + * Bare links end at the same CJK punctuation as rendered ones; ``, `](url)` and a + * URL nested after `/?#=&` keep it. */ +const written = /(?<=<|\]\\?\(\s*|[/?#=&])https?:\/\/[^\s<>"`]+/.source; const links = new RegExp(`(${written}|https?://${BARE_URL_REST})`, "g"); export const messageParts = (content: string) => content.split(links); export const linkPart = (part: string) => /^https?:\/\//.test(part); From 69064282fd7de1a5269ead186300d319e4c837bd Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 02:44:04 -0800 Subject: [PATCH 05/18] fix(emoji): keep every URL inside a link destination whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit kept a nested URL whole only when `/`, `?`, `#`, `=` or `&` came right before it, so `[a](HTTPS://x.com/(https://y.com/a,:party:))` still tagged a shortcode inside the link, and prose such as `链接=https://example.com,:party:` lost the fix. The split now reads a `` or `](url)` destination from its opener: the text up to its first http(s) URL stays text, and that URL runs to whitespace as before. The scan stops at the next opener, so it stays linear. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 4 ++++ src/features/relay/emoji.ts | 12 +++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index d05525321c..823513f4b3 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -88,7 +88,9 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () for (const content of [ "看这个 https://example.com,:party:", "https://example.com/a。:party:", + "链接=https://example.com,:party:", "[a](HTTPS://example.com),:party:", + "[a](HTTPS://example.com),:party:https://b.com", ]) { expect(contentEmojiTags(content, entries)).toEqual([ ["emoji", "party", url], @@ -104,6 +106,8 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "HTTPS://example.com/?redirect=https://other.com/:party:", "[a](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:)", "https://github。COM/:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index c9ae4d7437..6e84a608d2 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -80,11 +80,13 @@ export function emojiTags(event: { return Object.freeze([...entries.values()]); } /** Share token boundaries with the renderer: never substitute inside HTTP(S) links. - * Bare links end at the same CJK punctuation as rendered ones; ``, `](url)` and a - * URL nested after `/?#=&` keep it. */ -const written = /(?<=<|\]\\?\(\s*|[/?#=&])https?:\/\/[^\s<>"`]+/.source; -const links = new RegExp(`(${written}|https?://${BARE_URL_REST})`, "g"); -export const messageParts = (content: string) => content.split(links); + * Bare links end at the same CJK punctuation as rendered ones. In a `` or + * `](url)` destination, its first URL keeps it and the text before stays text. */ +const written = + /((?:<|\]\\?\(\s*)(?:(?!\]\\?\()[^\s<>"`])*?)(https?:\/\/[^\s<>"`]+)/.source; +const links = new RegExp(`${written}|(https?://${BARE_URL_REST})`, "g"); +export const messageParts = (content: string) => + content.split(links).filter((part) => part !== undefined); export const linkPart = (part: string) => /^https?:\/\//.test(part); const shortcodePattern = () => /(?=(:([a-zA-Z0-9_-]{1,64}):))/g; export function* emojiMatches( From b319962ae83dc882694586b6949f2938123d41ed Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 03:09:14 -0800 Subject: [PATCH 06/18] fix(emoji): split every URL in a link destination as before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit kept only a destination's first URL whole. A quote ends that URL, so in `[a](https://x.com/?url="https://y.com/a,:party:")` the next URL still ended at CJK punctuation and tagged a shortcode inside the link. The split now takes each `` autolink, `](url)` or `]()` destination as one piece, then splits every piece with the pattern used before this PR, so inside a destination every URL ends only at whitespace or `<>"`, as on main, while a bare link outside one ends at CJK punctuation. An autolink needs a scheme after `<`, so a stray `<` in text such as `价格<100看这个https://example.com,:party:` no longer hides the fix. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 3 +++ src/features/relay/emoji.ts | 13 +++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index 823513f4b3..f868cfd6a6 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -89,6 +89,7 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "看这个 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", ]) { @@ -108,6 +109,8 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "", "[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://github。COM/:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index 6e84a608d2..b9aa2c0fe6 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -80,13 +80,14 @@ export function emojiTags(event: { return Object.freeze([...entries.values()]); } /** Share token boundaries with the renderer: never substitute inside HTTP(S) links. - * Bare links end at the same CJK punctuation as rendered ones. In a `` or - * `](url)` destination, its first URL keeps it and the text before stays text. */ -const written = - /((?:<|\]\\?\(\s*)(?:(?!\]\\?\()[^\s<>"`])*?)(https?:\/\/[^\s<>"`]+)/.source; -const links = new RegExp(`${written}|(https?://${BARE_URL_REST})`, "g"); + * Bare links end at the same CJK punctuation as rendered ones. Within a `` or + * `](url)` destination, a URL ends only at whitespace or `<>"\``. */ +const destinations = + /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^\s<>]*|\]\\?\(\s*(?:<[^<>\r\n]*|\S*)/.source; +const links = new RegExp(`(${destinations}|https?://${BARE_URL_REST})`, "g"); +const runs = /(https?:\/\/[^\s<>"`]+)/; export const messageParts = (content: string) => - content.split(links).filter((part) => part !== undefined); + content.split(links).flatMap((part) => part.split(runs)); export const linkPart = (part: string) => /^https?:\/\//.test(part); const shortcodePattern = () => /(?=(:([a-zA-Z0-9_-]{1,64}):))/g; export function* emojiMatches( From 80bd8c0a409662d210391af00c45bf0aa38a4182 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 03:23:17 -0800 Subject: [PATCH 07/18] fix(emoji): leave a scheme with nothing after it as text The bare-link pattern also matched an empty `http://` that the pattern before this PR left as text, so `see :http://` no longer referenced `:http:`. A bare link now needs one URL character after `//`, as before. With that, a message with no fullwidth or CJK punctuation splits exactly as it did on main (checked over 200,000 random messages). Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 1 + src/features/relay/emoji.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index f868cfd6a6..5fdec61e69 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -116,6 +116,7 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () expect(contentEmojiTags(content, entries)).toEqual([]); expect(referencedEmoji(content)).toEqual([]); } + expect(referencedEmoji("see :http://")).toEqual(["http"]); }); it("unions latest complete member sets; empty replacements and ties survive stale reads", async () => { const h = session(); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index b9aa2c0fe6..9ce39b9038 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -84,7 +84,8 @@ export function emojiTags(event: { * `](url)` destination, a URL ends only at whitespace or `<>"\``. */ const destinations = /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^\s<>]*|\]\\?\(\s*(?:<[^<>\r\n]*|\S*)/.source; -const links = new RegExp(`(${destinations}|https?://${BARE_URL_REST})`, "g"); +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(links).flatMap((part) => part.split(runs)); From ecc8d5d56b71466d8997ebf25524da45b35f0727 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 03:47:53 -0800 Subject: [PATCH 08/18] fix(emoji): keep escaped and Unicode-spaced URLs inside a destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An angle destination may contain an escaped `\>` or `\<`, and an autolink or a destination may contain a Unicode space such as U+3000, but the destination pieces ended there, so in `[a](https://y.com/a,:party:>)` the next URL ended at CJK punctuation and tagged a shortcode inside the link. The `]()` piece now skips escaped characters, and the autolink and `](url)` pieces end only at ASCII whitespace, as CommonMark does. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 4 ++++ src/features/relay/emoji.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index 5fdec61e69..457ea505cc 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -111,6 +111,10 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "[a](HTTPS://x.com/?urls=HTTPS://one.com,https://two.com/a,:party:)", '[a](https://x.com/?url="https://y.com/a,:party:")', "[a]()", + String.raw`[a](https://y.com/a,:party:>)`, + String.raw`[a]()`, + "[a](https://x.com/a\u3000https://y.com/a,:party:)", + "", "https://github。COM/:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index 9ce39b9038..d7b4ed8e0c 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -83,7 +83,8 @@ export function emojiTags(event: { * Bare links end at the same CJK punctuation as rendered ones. Within a `` or * `](url)` destination, a URL ends only at whitespace or `<>"\``. */ const destinations = - /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^\s<>]*|\]\\?\(\s*(?:<[^<>\r\n]*|\S*)/.source; + /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^ \t\r\n<>]*|\]\\?\(\s*(?:<(?:\\.|[^<>\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<>"`]+)/; From 301ccf27d92492fb7add965cc69f6ec9eb236d49 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 04:15:00 -0800 Subject: [PATCH 09/18] fix(emoji): use CommonMark whitespace for destination pieces The previous commit read an escape in `]()` with `.`, which skips U+2028 and U+2029, so `\` followed by one of them ended the piece and the next URL took the bare-link boundary. The whitespace after `](` was `\s`, which also takes U+2028 and U+3000, so `[a](\u2028...)` started an angle piece where CommonMark reads a plain destination. An escape now pairs `\` with any character but CR or LF, and only space, tab, CR and LF count as whitespace after `](`. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 3 +++ src/features/relay/emoji.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index 457ea505cc..bb858273ff 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -115,6 +115,9 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () String.raw`[a]()`, "[a](https://x.com/a\u3000https://y.com/a,:party:)", "", + "[a]()", + "[a]()", + "[a](\u2028https://y.com/a,:party:)", "https://github。COM/:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index d7b4ed8e0c..5a10ad0fc8 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -83,7 +83,7 @@ export function emojiTags(event: { * Bare links end at the same CJK punctuation as rendered ones. Within a `` or * `](url)` destination, a URL ends only at whitespace or `<>"\``. */ const destinations = - /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^ \t\r\n<>]*|\]\\?\(\s*(?:<(?:\\.|[^<>\r\n\\])*|[^ \t\r\n]*)/ + /<[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"); From e7815c97791d96e4391c0d797327b21954f3d63c Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 08:18:21 -0800 Subject: [PATCH 10/18] fix(emoji): keep a reference definition's URL whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link reference definition such as `[x]: https://example.com/a,:party:` is never drawn, but its URL took the bare-link boundary and ended at the fullwidth comma, so the shortcode after it got a tag, and a send could wait for the emoji catalog. The destination split now also takes a CommonMark definition as one piece: a line that starts with up to three spaces and `[label]:`, optional whitespace with at most one line ending, then a plain or `<...>` destination. Every URL in it keeps the pattern used before this PR, as in the other destinations. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 3 +++ src/features/relay/emoji.ts | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index bb858273ff..ffbb4addaa 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -90,6 +90,7 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "https://example.com/a。:party:", "链接=https://example.com,:party:", "价格<100看这个https://example.com,:party:", + "看 [x]: https://example.com,:party:", "[a](HTTPS://example.com),:party:", "[a](HTTPS://example.com),:party:https://b.com", ]) { @@ -118,6 +119,8 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "[a]()", "[a]()", "[a](\u2028https://y.com/a,:party:)", + "see [a][x]\n\n[x]: https://example.com/a,:party:", + " [x\\]y]:\n", "https://github。COM/:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index 5a10ad0fc8..f8d396bede 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -80,10 +80,10 @@ export function emojiTags(event: { return Object.freeze([...entries.values()]); } /** 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 `` or - * `](url)` destination, a URL ends only at whitespace or `<>"\``. */ + * Bare links end at the same CJK punctuation as rendered ones. Within a ``, + * `](url)` or `[label]: 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]*)/ + /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^ \t\r\n<>]*|(?:\]\\?\([ \t\r\n]*|(?\r\n\\])*|[^ \t\r\n]*)/ .source; const scheme = /https?:\/\/(?=[^\s<>"`])/.source; const links = new RegExp(`(${destinations}|${scheme}${BARE_URL_REST})`, "g"); From 6eac88237f5a1e22df5df34e5c949d926e15df94 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 08:43:52 -0800 Subject: [PATCH 11/18] fix(emoji): keep a quoted or listed reference definition's URL whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit required a reference definition to start a line with up to three spaces, so one inside a blockquote or a list, as in `> [x]: https://example.com/a,:party:` or a list item's continuation, still took the bare-link boundary and tagged a shortcode inside the link. The definition's line may now start with any blockquote and list markers and indentation, and its destination may follow a line ending and a `>`. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 3 +++ src/features/relay/emoji.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index ffbb4addaa..a76b7a2f5b 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -121,6 +121,9 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "[a](\u2028https://y.com/a,:party:)", "see [a][x]\n\n[x]: https://example.com/a,:party:", " [x\\]y]:\n", + "> [a][x]\n>\n> [x]:\n> https://example.com/a,:party:", + "- [x]: https://example.com/a,:party:", + "- foo\n\n [x]: https://example.com/a,:party:", "https://github。COM/:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index f8d396bede..eca0d1dc12 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -83,7 +83,7 @@ export function emojiTags(event: { * Bare links end at the same CJK punctuation as rendered ones. Within a ``, * `](url)` or `[label]: 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\\])*|[^ \t\r\n]*)/ + /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^ \t\r\n<>]*|(?:\]\\?\([ \t\r\n]*|(?*+\d.)-]*\[(?:\\[\s\S]|[^\\[\]])+\]:[ \t]*(?:(?:\r\n?|\n)[ \t>]*)?)(?:<(?:\\[^\r\n]|[^<>\r\n\\])*|[^ \t\r\n]*)/ .source; const scheme = /https?:\/\/(?=[^\s<>"`])/.source; const links = new RegExp(`(${destinations}|${scheme}${BARE_URL_REST})`, "g"); From 0d205e6d63cbc747e13afc0dead31472aee4c082 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 08:59:18 -0800 Subject: [PATCH 12/18] fix(emoji): find a reference definition after leading whitespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending stores the trimmed text but reads emoji from the text as typed, so a message that starts with a BOM, U+3000 or another space before a reference definition, as in `\uFEFF[x]: https://example.com/a,:party:`, missed the definition check and tagged a shortcode the stored message never draws. The first definition may now follow any leading whitespace that trimming removes. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 2 ++ src/features/relay/emoji.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index a76b7a2f5b..c9cba1d37f 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -124,6 +124,8 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "> [a][x]\n>\n> [x]:\n> https://example.com/a,:party:", "- [x]: https://example.com/a,:party:", "- foo\n\n [x]: https://example.com/a,:party:", + "\uFEFF[x]: https://example.com/a,:party:\n\n[a][x]", + "\u3000[x]: https://example.com/a,:party:", "https://github。COM/:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index eca0d1dc12..f34e3f655f 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -81,9 +81,10 @@ export function emojiTags(event: { } /** 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 `[label]: url` destination, a URL ends only at whitespace or `<>"\``. */ + * `](url)` or `[label]: url` destination, a URL ends only at whitespace or `<>"\``. + * A definition may follow leading whitespace, which sending trims. */ const destinations = - /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^ \t\r\n<>]*|(?:\]\\?\([ \t\r\n]*|(?*+\d.)-]*\[(?:\\[\s\S]|[^\\[\]])+\]:[ \t]*(?:(?:\r\n?|\n)[ \t>]*)?)(?:<(?:\\[^\r\n]|[^<>\r\n\\])*|[^ \t\r\n]*)/ + /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^ \t\r\n<>]*|(?:\]\\?\([ \t\r\n]*|(?:^\s*(?=\S)|(?*+\d.)-]*\[(?:\\[\s\S]|[^\\[\]])+\]:[ \t]*(?:(?:\r\n?|\n)[ \t>]*)?)(?:<(?:\\[^\r\n]|[^<>\r\n\\])*|[^ \t\r\n]*)/ .source; const scheme = /https?:\/\/(?=[^\s<>"`])/.source; const links = new RegExp(`(${destinations}|${scheme}${BARE_URL_REST})`, "g"); From 62080fe67619720b7e814bf65459dfa9a20de102 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 09:14:22 -0800 Subject: [PATCH 13/18] fix(emoji): keep an angle destination inside another destination whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain destination piece ran to the next whitespace, so when it held another link's `](<` opener, as in `[x]: [a]()`, it stopped at the space inside that angle destination and the rest took the bare-link boundary, tagging a shortcode inside the link. A plain destination now ends before `](<` or `]\(<`, so the angle destination gets its own piece. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/relay/emoji.test.ts | 2 ++ src/features/relay/emoji.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index c9cba1d37f..38b5ed49be 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -126,6 +126,8 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "- foo\n\n [x]: https://example.com/a,:party:", "\uFEFF[x]: https://example.com/a,:party:\n\n[a][x]", "\u3000[x]: https://example.com/a,:party:", + "\uFEFF[x]: [a]()", + "[a](x]()", "https://github。COM/:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index f34e3f655f..c788964522 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -84,7 +84,7 @@ export function emojiTags(event: { * `](url)` or `[label]: url` destination, a URL ends only at whitespace or `<>"\``. * A definition may follow leading whitespace, which sending trims. */ const destinations = - /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^ \t\r\n<>]*|(?:\]\\?\([ \t\r\n]*|(?:^\s*(?=\S)|(?*+\d.)-]*\[(?:\\[\s\S]|[^\\[\]])+\]:[ \t]*(?:(?:\r\n?|\n)[ \t>]*)?)(?:<(?:\\[^\r\n]|[^<>\r\n\\])*|[^ \t\r\n]*)/ + /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^ \t\r\n<>]*|(?:\]\\?\([ \t\r\n]*|(?:^\s*(?=\S)|(?*+\d.)-]*\[(?:\\[\s\S]|[^\\[\]])+\]:[ \t]*(?:(?:\r\n?|\n)[ \t>]*)?)(?:<(?:\\[^\r\n]|[^<>\r\n\\])*|(?:(?!\]\\?\([ \t\r\n]*<)[^ \t\r\n])*)/ .source; const scheme = /https?:\/\/(?=[^\s<>"`])/.source; const links = new RegExp(`(${destinations}|${scheme}${BARE_URL_REST})`, "g"); From 86eb30fbfdadda0967cc17ad3fd3dc8275680b1c Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 09:23:38 -0800 Subject: [PATCH 14/18] fix(emoji): find a link definition only after a line ending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer splits a message at mentions and runs the same link split on each piece, so treating the start of any split text as a line start let `[x]:` right after a mention, as in `@Mic [x]: https://example.com,:party:`, hide a shortcode the renderer still draws. A definition now needs a preceding line ending. Senders split the trimmed text the app stores behind one line ending, so a definition on the first line, including one after a BOM or other leading whitespace, is still found. A message that starts with an empty `http://` or `https://` now also tags the shortcodes after it, which the renderer already draws. The send side used to skip them as part of a link. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng Co-authored-by: Cursor --- src/features/messages/MessageMarkdown.test.tsx | 7 +++++++ src/features/relay/emoji.ts | 10 ++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx index c259346b39..f0368979cf 100644 --- a/src/features/messages/MessageMarkdown.test.tsx +++ b/src/features/messages/MessageMarkdown.test.tsx @@ -933,6 +933,13 @@ describe("Markdown inline extensions", () => { ]); 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", () => { diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index c788964522..1595bf2c42 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -82,15 +82,17 @@ export function emojiTags(event: { /** 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 `[label]: url` destination, a URL ends only at whitespace or `<>"\``. - * A definition may follow leading whitespace, which sending trims. */ + * A definition follows a line ending, never a renderer mention segment's start. */ const destinations = - /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^ \t\r\n<>]*|(?:\]\\?\([ \t\r\n]*|(?:^\s*(?=\S)|(?*+\d.)-]*\[(?:\\[\s\S]|[^\\[\]])+\]:[ \t]*(?:(?:\r\n?|\n)[ \t>]*)?)(?:<(?:\\[^\r\n]|[^<>\r\n\\])*|(?:(?!\]\\?\([ \t\r\n]*<)[^ \t\r\n])*)/ + /<[a-zA-Z][a-zA-Z\d+.-]{1,31}:[^ \t\r\n<>]*|(?:\]\\?\([ \t\r\n]*|(?<=[\r\n])[ \t>*+\d.)-]*\[(?:\\[\s\S]|[^\\[\]])+\]:[ \t]*(?:(?:\r\n?|\n)[ \t>]*)?)(?:<(?:\\[^\r\n]|[^<>\r\n\\])*|(?:(?!\]\\?\([ \t\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(links).flatMap((part) => part.split(runs)); +/** Senders store trimmed text, whose first line may hold a definition. */ +const sentParts = (content: string) => messageParts(`\n${content.trim()}`); export const linkPart = (part: string) => /^https?:\/\//.test(part); const shortcodePattern = () => /(?=(:([a-zA-Z0-9_-]{1,64}):))/g; export function* emojiMatches( @@ -137,7 +139,7 @@ export function contentEmojiTags( entries: readonly CustomEmoji[], ): string[][] { const used = new Map(); - 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); @@ -147,7 +149,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) => From 5c51fa2e6a6b8187a20c306a17dbd9c78c817239 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Fri, 6 Nov 2026 10:27:03 -0800 Subject: [PATCH 15/18] fix(emoji): drop the reference-definition guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard kept a `[label]: url` destination whole for custom emoji, but a pattern cannot tell a definition from paragraph text without Markdown's block structure. Each round found another shape it missed, and it then hid shortcodes in text the renderer draws, as in `Check this\n[x]: https://example.com,:party:`. Emoji splitting is back to autolinks, inline destinations and bare links. A shortcode right after CJK punctuation inside a definition's URL is tagged again; the renderer never draws it there, because definitions are literal. This reverts 86eb30fbf, 62080fe67, 0d205e6d6, 6eac88237 and e7815c977, and keeps the renderer check that an emoji after a mention and a link that ends at CJK punctuation is drawn. Co-authored-by: Claude Opus 5.5 Signed-off-by: Cheng Peng --- src/features/relay/emoji.test.ts | 10 ---------- src/features/relay/emoji.ts | 13 +++++-------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index 38b5ed49be..bb858273ff 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -90,7 +90,6 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "https://example.com/a。:party:", "链接=https://example.com,:party:", "价格<100看这个https://example.com,:party:", - "看 [x]: https://example.com,:party:", "[a](HTTPS://example.com),:party:", "[a](HTTPS://example.com),:party:https://b.com", ]) { @@ -119,15 +118,6 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "[a]()", "[a]()", "[a](\u2028https://y.com/a,:party:)", - "see [a][x]\n\n[x]: https://example.com/a,:party:", - " [x\\]y]:\n", - "> [a][x]\n>\n> [x]:\n> https://example.com/a,:party:", - "- [x]: https://example.com/a,:party:", - "- foo\n\n [x]: https://example.com/a,:party:", - "\uFEFF[x]: https://example.com/a,:party:\n\n[a][x]", - "\u3000[x]: https://example.com/a,:party:", - "\uFEFF[x]: [a]()", - "[a](x]()", "https://github。COM/:party:", ]) { expect(contentEmojiTags(content, entries)).toEqual([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index 1595bf2c42..5a10ad0fc8 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -80,19 +80,16 @@ export function emojiTags(event: { return Object.freeze([...entries.values()]); } /** 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 `[label]: url` destination, a URL ends only at whitespace or `<>"\``. - * A definition follows a line ending, never a renderer mention segment's start. */ + * Bare links end at the same CJK punctuation as rendered ones. Within a `` 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])[ \t>*+\d.)-]*\[(?:\\[\s\S]|[^\\[\]])+\]:[ \t]*(?:(?:\r\n?|\n)[ \t>]*)?)(?:<(?:\\[^\r\n]|[^<>\r\n\\])*|(?:(?!\]\\?\([ \t\r\n]*<)[^ \t\r\n])*)/ + /<[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(links).flatMap((part) => part.split(runs)); -/** Senders store trimmed text, whose first line may hold a definition. */ -const sentParts = (content: string) => messageParts(`\n${content.trim()}`); export const linkPart = (part: string) => /^https?:\/\//.test(part); const shortcodePattern = () => /(?=(:([a-zA-Z0-9_-]{1,64}):))/g; export function* emojiMatches( @@ -139,7 +136,7 @@ export function contentEmojiTags( entries: readonly CustomEmoji[], ): string[][] { const used = new Map(); - for (const part of sentParts(content)) { + for (const part of messageParts(content)) { if (linkPart(part)) continue; for (const { emoji } of emojiMatches(part, entries)) used.set(emoji.shortcode, emoji.url); @@ -149,7 +146,7 @@ export function contentEmojiTags( export function referencedEmoji(content: string): string[] { return [ ...new Set( - sentParts(content).flatMap((part) => + messageParts(content).flatMap((part) => linkPart(part) ? [] : [...part.matchAll(shortcodePattern())].map((m) => From 31c644111352fb030dcf59e1dcd912c97dc50e55 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Mon, 9 Nov 2026 09:46:15 -0800 Subject: [PATCH 16/18] fix(emoji): leave reference definitions out of emoji tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[ref]: https://example.com/a,:party:` is a reference definition, which renders no text, but the sender tagged `party` there. Without a ready catalog that blocked the send. Senders now find definitions with the same Markdown parser the renderer uses for literal ranges and skip shortcodes inside them. A paragraph line that only looks like a definition, such as `Check this\n[x]: https://example.com,:party:`, is still tagged. The parse runs only when the message has a shortcode and `]:`. Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor Signed-off-by: Cheng Peng --- src/features/relay/emoji.test.ts | 30 +++++++++++++++++++++++++++ src/features/relay/emoji.ts | 17 ++++++++++++--- src/features/relay/message-content.ts | 23 ++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index bb858273ff..3a54860d76 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -119,11 +119,41 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "[a]()", "[a](\u2028https://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"]); + // A paragraph line that only looks like a definition stays prose. + const prose = "Check this\n[x]: https://example.com,:party:"; + expect(contentEmojiTags(prose, entries)).toEqual([["emoji", "party", url]]); + 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(); + const content = "[ref]: https://example.com/a,:party:"; + const sent = () => { + 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"); + }; + 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(); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index 5a10ad0fc8..7524cf47df 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -1,5 +1,5 @@ import type { EventData } from "./events.ts"; -import { BARE_URL_REST } from "./message-content.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"; @@ -91,6 +91,17 @@ const runs = /(https?:\/\/[^\s<>"`]+)/; export const messageParts = (content: string) => 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); + let text = ""; + let offset = 0; + for (const { start, end } of definitionRanges(content)) { + text += `${content.slice(offset, start)}\n`; + offset = end; + } + return messageParts(text + content.slice(offset)); +} const shortcodePattern = () => /(?=(:([a-zA-Z0-9_-]{1,64}):))/g; export function* emojiMatches( content: string, @@ -136,7 +147,7 @@ export function contentEmojiTags( entries: readonly CustomEmoji[], ): string[][] { const used = new Map(); - 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); @@ -146,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) => diff --git a/src/features/relay/message-content.ts b/src/features/relay/message-content.ts index f14d3d435e..d942eac9f6 100644 --- a/src/features/relay/message-content.ts +++ b/src/features/relay/message-content.ts @@ -118,6 +118,29 @@ 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, tooDeep } = scanMarkdown(content); + if (tooDeep) return []; + 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, From 9fb2009fd6a81da279581ef1d8e24286f8feb43e Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Mon, 9 Nov 2026 10:15:58 -0800 Subject: [PATCH 17/18] fix(emoji): tag the stored text and split around definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in finding reference definitions for emoji tags: - Joining the text around a definition with a line ending let a stray `](` before it reach a later link, as in `](\n\n[x]: https://example.com\n\nhttps://example.com,:party:`, so a shortcode the renderer draws lost its tag. Each piece is now split on its own. - The app stores trimmed text but tagged the untrimmed draft. A leading U+3000 or four spaces made `[ref]: https://example.com/a,:party:` a paragraph or code there, so the send still waited for the catalog. Sends, replies and edits now tag the text they store. Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor Signed-off-by: Cheng Peng --- src/features/relay/emoji.test.ts | 29 ++++++++++++++++++----------- src/features/relay/emoji.ts | 6 +++--- src/features/relay/messages.ts | 6 +++--- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index 3a54860d76..780c163bb6 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -92,6 +92,8 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () "价格<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], @@ -127,23 +129,28 @@ it("tags a shortcode after a link and CJK punctuation, but not inside links", () expect(referencedEmoji(content)).toEqual([]); } expect(referencedEmoji("see :http://")).toEqual(["http"]); - // A paragraph line that only looks like a definition stays prose. - const prose = "Check this\n[x]: https://example.com,:party:"; - expect(contentEmojiTags(prose, entries)).toEqual([["emoji", "party", url]]); 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(); - const content = "[ref]: https://example.com/a,:party:"; - const sent = () => { - 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"); - }; + // 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:", + ]; + 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([]); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index 7524cf47df..3c93480f9c 100644 --- a/src/features/relay/emoji.ts +++ b/src/features/relay/emoji.ts @@ -94,13 +94,13 @@ 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); - let text = ""; + const parts: string[] = []; let offset = 0; for (const { start, end } of definitionRanges(content)) { - text += `${content.slice(offset, start)}\n`; + parts.push(...messageParts(content.slice(offset, start))); offset = end; } - return messageParts(text + content.slice(offset)); + return [...parts, ...messageParts(content.slice(offset))]; } const shortcodePattern = () => /(?=(:([a-zA-Z0-9_-]{1,64}):))/g; export function* emojiMatches( diff --git a/src/features/relay/messages.ts b/src/features/relay/messages.ts index 40ac746457..992db8894b 100644 --- a/src/features/relay/messages.ts +++ b/src/features/relay/messages.ts @@ -201,7 +201,7 @@ export function createMessages( ["h", channelId], ["e", targetId], ...attachmentSource.tags.filter((tag) => tag[0] === "imeta"), - ...emojiTags(content), + ...emojiTags(content.trim()), ], }); const id = sendEdit(messageId); @@ -316,7 +316,7 @@ export function createMessages( ...mentionTags(channelId, withDmAgents(channelId, mentions)), ...referenceTags(references), ...pickTags(channelId, mentions, picks), - ...emojiTags(content), + ...emojiTags(content.trim()), ...message.tags, ], }, @@ -432,7 +432,7 @@ export function createMessages( ...mentionTags(channelId, withDmAgents(channelId, mentions)), ...referenceTags(references), ...pickTags(channelId, mentions, picks), - ...emojiTags(content), + ...emojiTags(content.trim()), ...message.tags, ], }); From c3151a36d4a32bfa712bf56fc180cdd52a4841c1 Mon Sep 17 00:00:00 2001 From: Cheng Peng Date: Mon, 9 Nov 2026 12:07:27 -0800 Subject: [PATCH 18/18] fix(emoji): find reference definitions in deeply nested Markdown `definitionRanges` returned nothing when Markdown nested more than 100 levels, so a definition there was still tagged and its send waited for the emoji catalog. The parser still returns the whole tree with positions; only the renderer's own walk stops at that depth, so the ranges now come from the full tree. Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor Signed-off-by: Cheng Peng --- src/features/relay/emoji.test.ts | 1 + src/features/relay/message-content.ts | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index 780c163bb6..9751079975 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -140,6 +140,7 @@ it("sends a reference definition's shortcode without the catalog", async () => { "[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) => { diff --git a/src/features/relay/message-content.ts b/src/features/relay/message-content.ts index d942eac9f6..a035acd2f4 100644 --- a/src/features/relay/message-content.ts +++ b/src/features/relay/message-content.ts @@ -124,8 +124,7 @@ export function definitionRanges( ): readonly { start: number; end: number }[] { if (!content.includes("]:") || content.length > MAX_MARKDOWN_LENGTH) return []; - const { tree, tooDeep } = scanMarkdown(content); - if (tooDeep) return []; + const { tree } = scanMarkdown(content); const ranges: { start: number; end: number }[] = []; const pending = [tree]; for (let node = pending.pop(); node; node = pending.pop()) {