Skip to content
Merged
3 changes: 3 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,9 @@ that agent in Buzz; they do not notify the external app through this connection.
and Linux) an HTTP(S) link in a message to open it in your default browser
instead of a side panel. You can also right-click the link and choose
**Open in browser**.
- **Link boundaries**: a bare http(s) or buzz link ends at whitespace and at
fullwidth, CJK or curly-quote punctuation such as `,`, `。`, `」` or `’`. In a
host name, `。` works as a dot. Wrap it as `<url>` to keep them in it.
- **Drafts & sent** lists the messages you started but did not send, newest
change first, with the number of drafts shown beside it in the sidebar, and
your latest 100 sent messages in this community, grouped by day. A sent
Expand Down
33 changes: 33 additions & 0 deletions src/features/messages/MessageMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,39 @@ describe("Markdown inline extensions", () => {
},
);

it.each([
[
"https://github.com/block/buzz-app。",
"https://github.com/block/buzz-app",
],
["看这个 https://example.com/spec,大家看一下", "https://example.com/spec"],
["(https://example.com)", "https://example.com/"],
["他说“https://example.com/a”。", "https://example.com/a"],
[
"https://github。com/block/buzz-app。",
"https://github.com/block/buzz-app",
],
["http://localhost:3000。OK", "http://localhost:3000/"],
[
"https://zh.wikipedia.org/wiki/中文 ok",
"https://zh.wikipedia.org/wiki/%E4%B8%AD%E6%96%87",
],
["<https://example.com/a。>", "https://example.com/a%E3%80%82"],
[
"https://a.com,https://zh.wikipedia.org/wiki/中文",
"https://a.com/",
"https://zh.wikipedia.org/wiki/%E4%B8%AD%E6%96%87",
],
])("ends a bare link at fullwidth punctuation: %s", (content, ...hrefs) => {
const html = render(content);
expect([...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1])).toEqual(
hrefs,
);
expect(html.replace(/<[^>]*>/g, "")).toBe(
content.replace(/^<(.*)>$/, "$1"),
);
});

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
28 changes: 28 additions & 0 deletions src/features/messages/MessageMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,33 @@ function rehypeEditedSlot() {
};
}

/** GFM ends a bare URL only at whitespace. End it where bare message links end;
* `<url>` and `[url](url)` keep their destination. */
function remarkBareLinkEnd(content: string) {
return function visit(parent: MarkdownNode) {
if (!parent.children) return;
parent.children = parent.children.flatMap((child) => {
const text = child.url;
const { start, end } = child.position ?? {};
if (
child.type !== "link" ||
!text ||
child.children?.[0]?.value !== text ||
(start && content.slice(start.offset, end?.offset) !== text)
) {
visit(child);
return [child];
}
const parts = messageLinkParts(text).filter((part) => part.text);
if (parts.length < 2) return [child];
return parts.map(({ text: value, url }) => {
const node = { type: "text", value };
return url ? { type: "link", url, children: [node] } : node;
});
});
};
}

/** Offer only Markdown prose to profile controls and inline plugins. */
function remarkInlineContent(protectedContent: ProtectedContent) {
const restore = (value: string) =>
Expand Down Expand Up @@ -858,6 +885,7 @@ const MarkdownBody = memo(function MarkdownBody({
Markdown({
remarkPlugins: [
remarkGfm,
[remarkBareLinkEnd, protectedContent.content],
remarkBreaks,
[remarkSpoilers, `<${protectedContent.prefix}spoiler\uE002>`],
[remarkInlineContent, protectedContent],
Expand Down
43 changes: 43 additions & 0 deletions src/features/messages/message-link-parts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,49 @@ it("keeps balanced bare URL delimiters in the decorated range and trims only unm
expect(parts.map((part) => part.text).join("")).toBe(content);
});

it.each([
["https://github.com/block/buzz-app。", "https://github.com/block/buzz-app"],
["看这个 https://example.com/spec,大家看一下", "https://example.com/spec"],
["(https://example.com)", "https://example.com"],
["https://example.com/a?b=c。", "https://example.com/a?b=c"],
["https://example.com/path.", "https://example.com/path"],
["https://example.com/path[注]", "https://example.com/path"],
["他说“https://example.com”。", "https://example.com"],
["「https://example.com」", "https://example.com"],
["https://example.com。大家", "https://example.com"],
["https://example.com/a。b", "https://example.com/a"],
["https://github。com/block/buzz-app", "https://github。com/block/buzz-app"],
["https://github.com:8080/x。", "https://github.com:8080/x"],
["http://localhost:3000。OK", "http://localhost:3000"],
["https://[::1]。OK", "https://[::1]"],
["https:///example.com/x。", "https:///example.com/x"],
[
"https://github。com:/block/buzz-app",
"https://github。com:/block/buzz-app",
],
["https://github。com/x。", "https://github。com/x"],
[
"见 https://zh.wikipedia.org/wiki/中文",
"https://zh.wikipedia.org/wiki/中文",
],
])(
"ends a bare URL at fullwidth punctuation but keeps CJK letters: %s",
(content, url) => {
const parts = messageLinkParts(content);
expect(parts.filter((part) => part.url)).toEqual([{ text: url, url }]);
expect(parts.map((part) => part.text).join("")).toBe(content);
},
);

it("ends a bare URL at fullwidth punctuation before another scheme", () => {
expect(
messageLinkParts("https://a.com。https://b.com").filter((part) => part.url),
).toEqual([
{ text: "https://a.com", url: "https://a.com" },
{ text: "https://b.com", url: "https://b.com" },
]);
});

it.each([
"[this shadcdn/ui clone](https://github.com/duobaseio/forui)",
"[this shadcdn/ui clone](<https://github.com/duobaseio/forui>)",
Expand Down
3 changes: 2 additions & 1 deletion src/features/messages/message-link-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,9 @@ 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<>"`]+/gi,
/(?<!\\)\[((?:\\[^\r\n]|[^[\]\\\r\n])+)\]\\?\(|<(?:https?|buzz):\/\/[^\s<>"`]+>|(?:https?|buzz):\/\/(?:[^\s<>"`:/?#,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]|[。.。](?=[a-z\d-]+(?![a-z\d-]|:\/\/)))*(?:[:/?#][^\s<>"`,。、.!?;:"'()[]{}「」『』《》【】〈〉〔〕〖〗〝〞〟。「」、“”‘’…~〜]*)?/gi,
)) {
if (match.index < offset) continue;
if (match[1] !== undefined) {
Expand Down
Loading