From 0ee1f5c96d136823da55eca2b9f8c6ac990d9382 Mon Sep 17 00:00:00 2001 From: hmseeb <74695355+hmseeb@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:38:00 +0500 Subject: [PATCH] fix(web): open off-site links in a new tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown-rendered links (README, file previews, join-policy documents) navigated the current tab, pulling readers out of the app. Route all three through a shared Markdown component that adds target=_blank + rel=noopener noreferrer for cross-origin http(s) links only. Same-origin paths, in-page anchors, and custom schemes (buzz://) keep navigating in place — a new tab for a deep link just leaves an empty one behind. Signed-off-by: hmseeb <74695355+hmseeb@users.noreply.github.com> --- web/src/features/invite/ui/InvitePage.tsx | 7 +-- web/src/features/repos/ui/RepoBlobViewer.tsx | 5 +-- .../features/repos/ui/RepoReadmeSection.tsx | 5 +-- web/src/shared/ui/markdown.tsx | 44 +++++++++++++++++++ web/tests/e2e/smoke.spec.ts | 39 ++++++++++++++++ 5 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 web/src/shared/ui/markdown.tsx diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx index 6de8d03875..b5dba1e1eb 100644 --- a/web/src/features/invite/ui/InvitePage.tsx +++ b/web/src/features/invite/ui/InvitePage.tsx @@ -9,9 +9,8 @@ import { import { hasNip07Provider } from "@/shared/lib/nostr-signer"; import { relayWsUrl } from "@/shared/lib/relay-url"; import { Button } from "@/shared/ui/button"; +import { Markdown } from "@/shared/ui/markdown"; import * as React from "react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import { InviteJoinPolicyNotice } from "./InviteJoinPolicyNotice"; @@ -372,9 +371,7 @@ export function InvitePage({ code }: { code: string }) {
- - {document.markdown} - + {document.markdown}
diff --git a/web/src/features/repos/ui/RepoBlobViewer.tsx b/web/src/features/repos/ui/RepoBlobViewer.tsx index 2e67cee6f1..c53d73619e 100644 --- a/web/src/features/repos/ui/RepoBlobViewer.tsx +++ b/web/src/features/repos/ui/RepoBlobViewer.tsx @@ -10,11 +10,10 @@ import { ArrowLeft, Check, Copy, Download, FileText, Play } from "lucide-react"; import { useEffect, useState } from "react"; import { Link, useParams } from "@tanstack/react-router"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import { toast } from "sonner"; import { Button } from "@/shared/ui/button"; +import { Markdown } from "@/shared/ui/markdown"; import type { BlobView } from "../git-client"; import { useGitBlob, useGitHtmlDoc } from "../use-git-browse"; import { useRepoContext } from "../use-repo-context"; @@ -182,7 +181,7 @@ function ViewerBody({ case "markdown": return (
- {view.content} + {view.content}
); case "html": diff --git a/web/src/features/repos/ui/RepoReadmeSection.tsx b/web/src/features/repos/ui/RepoReadmeSection.tsx index c0e20ba23a..0c2b67a821 100644 --- a/web/src/features/repos/ui/RepoReadmeSection.tsx +++ b/web/src/features/repos/ui/RepoReadmeSection.tsx @@ -1,6 +1,5 @@ +import { Markdown } from "@/shared/ui/markdown"; import { BookOpen } from "lucide-react"; -import Markdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import type { ReadmeResult } from "../git-client"; export function RepoReadmeSection({ @@ -35,7 +34,7 @@ export function RepoReadmeSection({ {readme.filename}
- {readme.content} + {readme.content}
); diff --git a/web/src/shared/ui/markdown.tsx b/web/src/shared/ui/markdown.tsx new file mode 100644 index 0000000000..f8fd1268b5 --- /dev/null +++ b/web/src/shared/ui/markdown.tsx @@ -0,0 +1,44 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +/** + * True when following `href` leaves this site. Same-origin paths, in-page + * anchors, and custom schemes (`buzz://`, `mailto:`) stay in the current tab — + * a new tab for those would just leave an empty one behind. + */ +export function isOffSiteHref(href: string | undefined): boolean { + if (!href) return false; + try { + const url = new URL(href, window.location.href); + return ( + /^https?:$/.test(url.protocol) && url.origin !== window.location.origin + ); + } catch { + return false; + } +} + +/** Markdown (GitHub flavour) that opens off-site links in a new tab. */ +export function Markdown({ children }: { children: string }) { + return ( + + ) : ( + + ); + }, + }} + remarkPlugins={[remarkGfm]} + > + {children} + + ); +} diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 22e2628165..493575e89b 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -120,6 +120,45 @@ test("invite requires age and legal consent before opening Buzz", async ({ expect(consentBox?.width).toBe(acceptButtonBox?.width); }); +test("markdown opens off-site links in a new tab, in-app links in place", async ({ + page, +}) => { + await page.route("**/api/join-policy", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + policy: { + terms_markdown: [ + "Read the [full policy](https://example.com/terms),", + "our [help centre](/help), and [section two](#two).", + ].join(" "), + age_attestation_required: false, + version: "policy-v1", + }, + }), + }); + }); + await page.route("https://api.github.com/**", async (route) => { + await route.fulfill({ status: 500 }); + }); + + await page.goto("/invite/demo-code"); + await page.getByRole("button", { name: "Terms of Service" }).click(); + + const dialog = page.getByRole("dialog", { name: "Terms of Service" }); + const offSite = dialog.getByRole("link", { name: "full policy" }); + await expect(offSite).toHaveAttribute("target", "_blank"); + await expect(offSite).toHaveAttribute("rel", /noreferrer/); + + await expect( + dialog.getByRole("link", { name: "help centre" }), + ).not.toHaveAttribute("target", "_blank"); + await expect( + dialog.getByRole("link", { name: "section two" }), + ).not.toHaveAttribute("target", "_blank"); +}); + test("invite can enroll a NIP-07 identity for browser access", async ({ page, }) => {