diff --git a/packages/session-ui/component-tests/markdown.spec.ts b/packages/session-ui/component-tests/markdown.spec.ts index dab557031a7..5d35bebce2b 100644 --- a/packages/session-ui/component-tests/markdown.spec.ts +++ b/packages/session-ui/component-tests/markdown.spec.ts @@ -163,6 +163,89 @@ story("mounts cached completed Markdown with sanitized HTML and decorations", as await expect(markdown).toHaveAttribute("data-markdown-ready", "") }) +story("shows a stable GitHub mark without changing link text or other sites", async ({ page }) => { + await page.evaluate(async (fixture) => { + const { mountMarkdown } = await import(fixture) + await mountMarkdown({ + text: [ + "[#540](https://github.com/anomalyco/opencode/pull/540)", + "[GitHub](https://github.com)", + "[other site](https://example.com/docs)", + "[lookalike](https://github.com.evil.example/pull/540)", + ].join(" · "), + cached: true, + }) + }, fixture) + + const markdown = page.getByTestId("markdown-fixture").locator('[data-component="markdown"]') + await expect(markdown).toHaveAttribute("data-markdown-ready", "") + const github = markdown.getByRole("link", { name: "#540" }) + await expect(github).toHaveAttribute("href", "https://github.com/anomalyco/opencode/pull/540") + await expect(github).toHaveText("#540") + expect(await github.evaluate((link) => getComputedStyle(link, "::before").width)).toBe("14px") + expect(await github.evaluate((link) => getComputedStyle(link, "::before").maskImage)).toContain("data:image/svg+xml") + expect( + await markdown.getByRole("link", { name: "GitHub" }).evaluate((link) => getComputedStyle(link, "::before").content), + ).toBe('""') + for (const name of ["other site", "lookalike"]) { + expect( + await markdown.getByRole("link", { name }).evaluate((link) => getComputedStyle(link, "::before").content), + ).toBe("none") + } +}) + +story("keeps favicon space stable across loading and failure without fetching private hosts", async ({ page }) => { + const requested: string[] = [] + let release: () => void = () => undefined + const loading = new Promise((resolve) => (release = resolve)) + await page.route("https://www.google.com/s2/favicons?**", async (route) => { + requested.push(route.request().url()) + if (route.request().url().includes("developer.mozilla.org")) await loading + if (route.request().url().includes("broken.example.org")) return route.abort() + await route.fulfill({ status: 200, contentType: "image/png", body: png }) + }) + + await page.evaluate(async (fixture) => { + const { mountMarkdown } = await import(fixture) + await mountMarkdown({ + text: [ + "[docs](https://developer.mozilla.org/docs)", + "[missing](https://broken.example.org/docs)", + "[private](http://localhost:8080/docs)", + "[GitHub](https://github.com/anomalyco/opencode)", + ].join(" · "), + cached: true, + }) + }, fixture) + + const markdown = page.getByTestId("markdown-fixture").locator('[data-component="markdown"]') + await expect(markdown).toHaveAttribute("data-markdown-ready", "") + const docs = markdown.getByRole("link", { name: "docs" }) + const image = docs.locator(".markdown-link-favicon img") + await expect.poll(() => requested.some((url) => url.includes("developer.mozilla.org"))).toBe(true) + await expect(image).toHaveCSS("opacity", "0") + await expect(docs.locator(".markdown-link-favicon")).toHaveCSS("width", "14px") + const width = await docs.evaluate((link) => link.getBoundingClientRect().width) + release() + await expect(image).toHaveAttribute("data-loaded", "") + await expect(image).toHaveCSS("opacity", "1") + await expect(image).not.toHaveAttribute("role", "button") + expect(await image.evaluate((favicon) => favicon.onclick)).toBeNull() + expect(await docs.evaluate((link) => link.getBoundingClientRect().width)).toBe(width) + expect( + await image.evaluate((favicon) => favicon.getBoundingClientRect().top - favicon.parentElement!.getBoundingClientRect().top), + ).toBe(0) + + await expect(markdown.getByRole("link", { name: "missing" }).locator(".markdown-link-favicon img")).not.toHaveAttribute( + "data-loaded", + "", + ) + await expect(markdown.getByRole("link", { name: "private" }).locator(".markdown-link-favicon img")).toHaveCount(0) + await expect(markdown.getByRole("link", { name: "GitHub" }).locator(".markdown-link-favicon")).toHaveCount(0) + expect(requested).toHaveLength(2) + expect(requested.every((url) => !url.includes("localhost") && !url.includes("github.com"))).toBe(true) +}) + async function resolvedColor(page: Page, token: string) { return page.evaluate((token) => { const probe = document.createElement("span") diff --git a/packages/session-ui/src/components/image-preview.tsx b/packages/session-ui/src/components/image-preview.tsx index 17590048ec3..f3a9550795b 100644 --- a/packages/session-ui/src/components/image-preview.tsx +++ b/packages/session-ui/src/components/image-preview.tsx @@ -16,7 +16,7 @@ export function createImagePreview() { } return (root: HTMLElement) => { - root.querySelectorAll("img").forEach((image) => { + root.querySelectorAll("img:not([data-markdown-favicon])").forEach((image) => { image.setAttribute("role", "button") image.setAttribute("aria-haspopup", "dialog") image.setAttribute("aria-label", image.alt || i18n.t("ui.imagePreview.alt")) diff --git a/packages/session-ui/src/components/markdown-github.svg b/packages/session-ui/src/components/markdown-github.svg new file mode 100644 index 00000000000..e5181690f77 --- /dev/null +++ b/packages/session-ui/src/components/markdown-github.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/session-ui/src/components/markdown-globe.svg b/packages/session-ui/src/components/markdown-globe.svg new file mode 100644 index 00000000000..8773823cf20 --- /dev/null +++ b/packages/session-ui/src/components/markdown-globe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css index 266003c1cf9..a8b3250c149 100644 --- a/packages/session-ui/src/components/markdown.css +++ b/packages/session-ui/src/components/markdown.css @@ -101,6 +101,55 @@ text-underline-offset: 2px; } + a.external-link:is( + [href="https://github.com" i], + [href^="https://github.com/" i], + [href="http://github.com" i], + [href^="http://github.com/" i] + ):not(:has(img))::before { + content: ""; + display: inline-block; + width: 14px; + height: 14px; + margin-inline-end: 4px; + vertical-align: -0.125em; + background: currentColor; + mask: url("./markdown-github.svg") center / contain no-repeat; + } + + .markdown-link-favicon { + position: relative; + display: inline-block; + width: 14px; + height: 14px; + margin-inline-end: 4px; + vertical-align: -0.125em; + } + + .markdown-link-favicon::before { + content: ""; + position: absolute; + inset: 0; + background: currentColor; + mask: url("./markdown-globe.svg") center / contain no-repeat; + } + + .markdown-link-favicon img { + position: absolute; + inset: 0; + display: block; + width: 14px; + height: 14px; + margin: 0; + border-radius: 2px; + cursor: inherit; + opacity: 0; + } + + .markdown-link-favicon img[data-loaded] { + opacity: 1; + } + a[data-local-link] { cursor: pointer; } diff --git a/packages/session-ui/src/components/markdown.stories.tsx b/packages/session-ui/src/components/markdown.stories.tsx index 9ec7810765e..4b6ab695a10 100644 --- a/packages/session-ui/src/components/markdown.stories.tsx +++ b/packages/session-ui/src/components/markdown.stories.tsx @@ -85,3 +85,42 @@ export const StreamingInlineCode = { ), } + +const externalLinkChunks = [ + "1. ", + "[#540](https://github.com/anomalyco/opencode/pull/540)", + ": Batch access-policy reads.\n2. ", + "[Stack Overflow](https://stackoverflow.com/questions/123)", + ": Favicon from a public site.\n3. ", + "[MDN docs](https://developer.mozilla.org/en-US/docs/Web)", + ": Globe while loading.\n4. ", + "[Unavailable favicon](https://absent-site.example.org/docs)", + ": Globe fallback.\n5. ", + "[Local host](http://localhost:8080/docs)", + ": No third-party request.", +] + +function StreamingExternalLinks() { + const [count, setCount] = createSignal(1) + const timer = setInterval(() => { + setCount((value) => { + if (value === externalLinkChunks.length) { + clearInterval(timer) + return value + } + return value + 1 + }) + }, 380) + onCleanup(() => clearInterval(timer)) + return ( + + ) +} + +export const ExternalLinksStreaming = { + render: () => ( +
+ +
+ ), +} diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index a8e78519005..a1ab7fbe1ea 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -289,6 +289,50 @@ function markCodeLinks(root: HTMLDivElement) { } } +const publicFaviconHost = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/ +const privateFaviconSuffix = /\.(?:alt|example|internal|invalid|local|localhost|onion|test|ts\.net)$/ + +function markExternalLinkFavicons(root: HTMLDivElement) { + root.querySelectorAll("a.external-link[href]").forEach((link) => { + if (!link.textContent?.trim() || link.querySelector("img")) return + if (!URL.canParse(link.href)) return + const url = new URL(link.href) + if (url.protocol !== "http:" && url.protocol !== "https:") return + if (url.hostname.toLowerCase() === "github.com") return + + const favicon = document.createElement("span") + favicon.className = "markdown-link-favicon" + favicon.setAttribute("aria-hidden", "true") + const host = url.hostname.toLowerCase() + if ( + publicFaviconHost.test(host) && + !privateFaviconSuffix.test(host) && + host !== "home.arpa" && + !host.endsWith(".home.arpa") + ) { + const image = document.createElement("img") + image.src = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.host)}&sz=32` + image.alt = "" + image.setAttribute("data-markdown-favicon", "") + image.width = 14 + image.height = 14 + image.decoding = "async" + favicon.appendChild(image) + } + link.insertBefore(favicon, link.firstChild) + }) +} + +function setupExternalLinkFavicons(root: HTMLDivElement) { + const loaded = (event: Event) => { + const image = event.target + if (!(image instanceof HTMLImageElement) || !image.hasAttribute("data-markdown-favicon")) return + if (image.naturalWidth > 0) image.dataset.loaded = "" + } + root.addEventListener("load", loaded, true) + return () => root.removeEventListener("load", loaded, true) +} + function markInlineCode(root: HTMLDivElement) { const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code")) for (const code of codeNodes) { @@ -554,6 +598,7 @@ export function Markdown( let copyCleanup: (() => void) | undefined let linkCleanup: (() => void) | undefined + let faviconCleanup: (() => void) | undefined let readImage: ReadMarkdownImage | undefined let images: ReturnType | undefined @@ -599,6 +644,9 @@ export function Markdown( } images?.update(container) previewImages(container) + container.querySelectorAll("img[data-markdown-favicon]").forEach((image) => { + if (image.complete && image.naturalWidth > 0) image.dataset.loaded = "" + }) container .querySelectorAll('[data-slot="markdown-copy-button"]') .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true")) @@ -608,6 +656,7 @@ export function Markdown( copied: i18n.t("ui.message.copied"), })) if (!linkCleanup) linkCleanup = setupLocalLinks(container, () => markdown?.openLocalFile) + if (!faviconCleanup) faviconCleanup = setupExternalLinkFavicons(container) container.toggleAttribute("data-local-links", !!markdown?.openLocalFile) if (result?.ready && result.text === local.text) container.dataset.markdownReady = "" }) @@ -617,6 +666,7 @@ export function Markdown( images?.dispose() if (copyCleanup) copyCleanup() if (linkCleanup) linkCleanup() + if (faviconCleanup) faviconCleanup() const container = root() if (container) disposeRenderedMarkdown(container) if (streamed) disposeMarkdownProjection(owner) @@ -697,6 +747,7 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl source.innerHTML = block.html markInlineCode(source) markCodeLinks(source) + markExternalLinkFavicons(source) if (rendered) { rendered.renderer.update(source.innerHTML, block.mode === "live", rendered.raw !== block.raw)