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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/features/messages/MessageMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,15 @@ describe("Markdown profile mentions", () => {
},
);

it("renders a mention after a bare link that ends at CJK punctuation", () => {
const html = render("看这个 https://example.test,@Mic");
expect([...html.matchAll(/href="([^"]*)"/g)].map((m) => m[1])).toEqual([
"https://example.test/",
]);
expect(html).toContain(',<button type="button"');
expect(html).toContain('aria-label="View Mic profile"');
});

it.each<[string, RenderOptions]>([
["no opener", { canOpenLink: undefined }],
["a refusing opener", { canOpenLink: () => false }],
Expand Down
22 changes: 22 additions & 0 deletions src/features/messages/message-edit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert, describe, expect, it } from "vitest";
import { shareMessageRows } from "../relay/row-identity";
import { foldMessages } from "../relay/fold";
import { scanMarkdown } from "../relay/message-content";
import { profileTarget } from "../profiles/target";
import { messageEditText, validateMessageEdit } from "./message-edit";
import type { EventData } from "../relay/events";
Expand Down Expand Up @@ -75,6 +76,27 @@ describe("message edit round trips", () => {
const row = firstRow([{ ...original, tags: [["h", "room"]] }]);
expect(messageEditText(row, profiles)).toBe(original.content);
});
it("keeps a reference definition's URL when an edit changes only prose", () => {
const definition = "[ref]: https://example.test/a,@Sam";
const sent = {
...original,
content: `https://example.test,@Sam look at [ref].\n\n> ${definition}\n\n${definition}\n\n${"> ".repeat(101)}${definition}`,
tags: [
["h", "room"],
["p", person],
],
};
const draft = messageEditText(firstRow([sent]), profiles);
expect(draft).toBe(
sent.content.replace(
"@Sam look",
`[@Sam](${profileTarget(person)}) look`,
),
);
expect(
scanMarkdown(draft.replace("look", "see")).definitions.get("ref"),
).toBe("https://example.test/a,@Sam");
});
it.each([
"[ref]: <https://example.com/a @Sam>",
'[ref]: https://example.com "Ask @Sam"',
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
16 changes: 16 additions & 0 deletions src/features/messages/profile-mentions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,25 @@ it.each([
"\\@Mic",
"@Mic_foo",
"@Micé",
"https://example.test/文档?to=@Mic",
"https://example.test,https://example.test/b?to=@Mic",
"<https://example.test/a,@Mic>",
"[[a]](https://example.test/a,@Mic)",
"[a](\nhttps://example.test/a,@Mic)",
String.raw`[a]\(https://example.test/a,@Mic\)`,
])("leaves code/link/non-token context literal: %s", (content) => {
expect(parts(content)).toEqual([{ text: content }]);
});
it("binds a mention after a bare link that ends at CJK punctuation", () => {
for (const content of [
"https://example.test,@Mic",
"看这个 https://example.test/a。@Mic",
"(https://example.test,@Mic)",
])
expect(parts(content).filter((part) => part.target)).toEqual([
{ text: "@Mic", target: profileTarget(mic) },
]);
});
it("recognizes prose after a closed code span even when the span contained HTTPS", () => {
expect(
parts("`https://example.test @Mic` then @Mic").filter((p) => p.target),
Expand Down
20 changes: 16 additions & 4 deletions src/features/messages/profile-mentions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AgentLibrary } from "../agents/library";
import type { ChannelMessage, Profile } from "../relay/contracts";
import { BARE_URL_REST } from "../relay/message-content";
import { profileTarget } from "../profiles/target";

/** The names a person's signed prose mention binds by: their shown name and,
Expand Down Expand Up @@ -95,6 +96,15 @@ export function profileMentionParts(
return parts.length ? parts : [{ text }];
}

const urlScheme = /^(?:[a-z][a-z\d+.-]*:\/\/|mailto:|nostr:)/.source;
/** Bare URLs end where rendered ones do, and another can start right there. A
* URL after `<`, or after `](` or a pasted `]\(` and any whitespace, keeps
* CJK punctuation. */
const urlChar = new RegExp(`^(?:${BARE_URL_REST})$`, "i");
const wrapped = /(?<=<|\]\\?\(\s*)/y;
const wrappedUrl = new RegExp(urlScheme + /[^\s<>`]+/.source, "i");
const bareUrl = new RegExp(urlScheme + BARE_URL_REST, "i");

// Evaluate exclusions on the full body BEFORE splitting link/emoji rendering.
// Deliberately conservative for incomplete code/link syntax; not a Markdown parser.
function protectedTextEnd(text: string, start: number): number {
Expand All @@ -120,7 +130,8 @@ function protectedTextEnd(text: string, start: number): number {
return match ? match.index + match[0].length : text.length;
}
}
const boundary = start === 0 || /[\s<([]/.test(text[start - 1] ?? "");
const prev = text[start - 1] ?? "";
const boundary = start === 0 || /[\s<([]/.test(prev) || !urlChar.test(prev);
if (char !== "`" && char !== "~" && char !== "[" && char !== "!" && !boundary)
return start;
const span = /^(?:`+|~{3,})/.exec(rest)?.[0];
Expand All @@ -132,8 +143,9 @@ function protectedTextEnd(text: string, start: number): number {
const link = /^!?\[[^\]\n]*\](?:\([^\n]*?\)|\[[^\]\n]*\])/.exec(rest)?.[0];
if (link) return start + link.length;
}
const url = /^(?:[a-z][a-z\d+.-]*:\/\/|mailto:|nostr:)[^\s<>`]+/i.exec(
rest,
)?.[0];
const bare = bareUrl.exec(rest)?.[0];
// The lookbehind rescans whitespace, so test it only where a URL starts.
wrapped.lastIndex = start;
const url = bare && wrapped.test(text) ? wrappedUrl.exec(rest)?.[0] : bare;
return url ? start + url.length : start;
}
6 changes: 6 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
Loading