diff --git a/src/features/messages/MessageMarkdown.test.tsx b/src/features/messages/MessageMarkdown.test.tsx index 4df15ebd59..80d7e0085c 100644 --- a/src/features/messages/MessageMarkdown.test.tsx +++ b/src/features/messages/MessageMarkdown.test.tsx @@ -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:", { 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..9751079975 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,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:", + "", + "[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:)", + "", + "[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]()", + String.raw`[a](https://y.com/a,:party:>)`, + 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:", + "[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(); diff --git a/src/features/relay/emoji.ts b/src/features/relay/emoji.ts index 003d2a853d..3c93480f9c 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, definitionRanges } from "./message-content.ts"; export type CustomEmoji = Readonly<{ shortcode: string; url: string }>; export const EMOJI_SET = "buzz:custom-emoji"; @@ -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 `` 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, @@ -127,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); @@ -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) => diff --git a/src/features/relay/message-content.ts b/src/features/relay/message-content.ts index 35d27882e4..a035acd2f4 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://`. 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; @@ -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, diff --git a/src/features/relay/messages.ts b/src/features/relay/messages.ts index b98676aa34..60f9e3dfe6 100644 --- a/src/features/relay/messages.ts +++ b/src/features/relay/messages.ts @@ -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); @@ -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, ], }, @@ -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, ], });