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,
}) => {