diff --git a/__tests__/components/drops/view/part/dropPartMarkdown/linkHandlersRegistry.test.tsx b/__tests__/components/drops/view/part/dropPartMarkdown/linkHandlersRegistry.test.tsx index b7505fd40a..1ed8abd6dc 100644 --- a/__tests__/components/drops/view/part/dropPartMarkdown/linkHandlersRegistry.test.tsx +++ b/__tests__/components/drops/view/part/dropPartMarkdown/linkHandlersRegistry.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { render, screen } from "@testing-library/react"; import { createLinkRenderer } from "@/components/drops/view/part/dropPartMarkdown/linkHandlers"; +import { ensureStableSeizeLink } from "@/helpers/SeizeLinkParser"; import { publicEnv } from "@/config/env"; jest.mock("@/components/drops/view/part/dropPartMarkdown/youtubePreview", () => ({ @@ -14,7 +15,9 @@ jest.mock("@/components/drops/view/part/dropPartMarkdown/youtubePreview", () => jest.mock("@/components/drops/view/part/DropPartMarkdownImage", () => ({ __esModule: true, - default: ({ src }: { src: string }) => , + default: ({ src }: { src: string }) => ( + + ), })); jest.mock("@/components/drops/view/part/dropPartMarkdown/renderers", () => ({ @@ -100,13 +103,41 @@ jest.mock("@/components/waves/list/WaveItemChat", () => ({ jest.mock("@/components/waves/drops/DropItemChat", () => ({ __esModule: true, - default: ({ dropId }: { dropId: string }) =>
, + default: ({ dropId, href }: { dropId: string; href: string }) => ( +
+
+
+ ), })); +jest.mock("@/helpers/SeizeLinkParser", () => { + const actual = jest.requireActual("@/helpers/SeizeLinkParser"); + let currentHrefOverride: string | undefined; + + const ensureStableSeizeLink = (href: string) => + actual.ensureStableSeizeLink(href, currentHrefOverride); + + (ensureStableSeizeLink as any).__setCurrentHref = (href?: string) => { + currentHrefOverride = href; + }; + + return { + ...actual, + ensureStableSeizeLink, + }; +}); + const onQuoteClick = jest.fn(); const baseRenderer = () => createLinkRenderer({ onQuoteClick }); +const setEnsureCurrentHref = (href?: string) => { + const setter = (ensureStableSeizeLink as any).__setCurrentHref; + if (typeof setter === "function") { + setter(href); + } +}; + describe("createLinkRenderer", () => { const FALLBACK_BASE_ENDPOINT = "https://6529.io"; const originalBaseEndpointEnv = publicEnv.BASE_ENDPOINT; @@ -116,6 +147,7 @@ describe("createLinkRenderer", () => { jest.clearAllMocks(); publicEnv.BASE_ENDPOINT = FALLBACK_BASE_ENDPOINT; process.env.BASE_ENDPOINT = FALLBACK_BASE_ENDPOINT; + setEnsureCurrentHref(); }); afterEach(() => { @@ -125,6 +157,7 @@ describe("createLinkRenderer", () => { } else { process.env.BASE_ENDPOINT = originalProcessBaseEndpoint; } + setEnsureCurrentHref(); }); it("renders DropPartMarkdownImage for img elements", () => { @@ -182,6 +215,38 @@ describe("createLinkRenderer", () => { expect(screen.getByTestId("drop-card")).toHaveAttribute("data-drop", "def"); }); + it("normalizes root drop links using current location context", () => { + setEnsureCurrentHref("https://6529.io/messages?wave=current-wave"); + + const { renderAnchor } = baseRenderer(); + const element = renderAnchor({ href: "https://6529.io/?drop=drop-123" } as any); + render(<>{element}); + + expect(screen.getByTestId("drop-card")).toHaveAttribute("data-drop", "drop-123"); + expect(screen.getByTestId("chat-buttons")).toHaveAttribute( + "data-href", + "https://6529.io/messages?wave=current-wave&drop=drop-123" + ); + setEnsureCurrentHref(); + }); + + it("normalizes drop links shared from other paths", () => { + setEnsureCurrentHref("https://6529.io/messages?wave=current-wave"); + + const { renderAnchor } = baseRenderer(); + const element = renderAnchor({ + href: "https://6529.io/waves?wave=other-wave&drop=drop-456", + } as any); + render(<>{element}); + + expect(screen.getByTestId("drop-card")).toHaveAttribute("data-drop", "drop-456"); + expect(screen.getByTestId("chat-buttons")).toHaveAttribute( + "data-href", + "https://6529.io/messages?wave=current-wave&drop=drop-456" + ); + setEnsureCurrentHref(); + }); + it.each([ ["standard status link", "https://twitter.com/user/status/987654321"], ["mobile twitter link", "https://mobile.twitter.com/user/status/987654321"], diff --git a/__tests__/helpers/SeizeLinkParser.test.ts b/__tests__/helpers/SeizeLinkParser.test.ts index 6ed545bf1e..4af5965568 100644 --- a/__tests__/helpers/SeizeLinkParser.test.ts +++ b/__tests__/helpers/SeizeLinkParser.test.ts @@ -1,6 +1,7 @@ describe("SeizeLinkParser with mocked BASE_ENDPOINT", () => { let parseSeizeQuoteLink: any; let parseSeizeQueryLink: any; + let ensureStableSeizeLink: any; beforeAll(() => { jest.resetModules(); @@ -13,6 +14,7 @@ describe("SeizeLinkParser with mocked BASE_ENDPOINT", () => { ({ parseSeizeQuoteLink, parseSeizeQueryLink, + ensureStableSeizeLink, } = require("@/helpers/SeizeLinkParser")); }); @@ -79,4 +81,73 @@ describe("SeizeLinkParser with mocked BASE_ENDPOINT", () => { expect(res).toBeNull(); }); }); + + describe("ensureStableSeizeLink", () => { + it("returns original href for non-base URLs", () => { + const incoming = "https://othersite.com/?drop=drop-id"; + const current = "https://site.com/messages?wave=abc"; + expect(ensureStableSeizeLink(incoming, current)).toBe(incoming); + }); + + it("returns original href when drop param missing", () => { + const incoming = "https://site.com/"; + const current = "https://site.com/messages?wave=abc"; + expect(ensureStableSeizeLink(incoming, current)).toBe(incoming); + }); + + it("rewrites root drop link to current path with drop param", () => { + const incoming = "https://site.com/?drop=drop-id"; + const current = "https://site.com/messages?wave=abc"; + expect(ensureStableSeizeLink(incoming, current)).toBe( + "https://site.com/messages?wave=abc&drop=drop-id" + ); + }); + + it("handles relative drop links", () => { + const incoming = "?drop=drop-id"; + const current = "https://site.com/messages"; + expect(ensureStableSeizeLink(incoming, current)).toBe( + "https://site.com/messages?drop=drop-id" + ); + }); + + it("preserves existing query params and replaces drop", () => { + const incoming = "https://site.com/?drop=new-drop"; + const current = "https://site.com/messages?wave=abc&drop=old-drop"; + expect(ensureStableSeizeLink(incoming, current)).toBe( + "https://site.com/messages?wave=abc&drop=new-drop" + ); + }); + + it("rebases drop links from other paths onto current location", () => { + const incoming = "https://site.com/waves?wave=abc&drop=def"; + const current = "https://site.com/messages?wave=xyz"; + expect(ensureStableSeizeLink(incoming, current)).toBe( + "https://site.com/messages?wave=xyz&drop=def" + ); + }); + + it("refreshes cached origin when BASE_ENDPOINT changes", () => { + const { publicEnv } = require("@/config/env"); + + expect( + ensureStableSeizeLink( + "https://site.com/?drop=drop-1", + "https://site.com/messages" + ) + ).toBe("https://site.com/messages?drop=drop-1"); + + publicEnv.BASE_ENDPOINT = "https://other.com"; + try { + expect( + ensureStableSeizeLink( + "https://other.com/?drop=drop-2", + "https://other.com/messages" + ) + ).toBe("https://other.com/messages?drop=drop-2"); + } finally { + publicEnv.BASE_ENDPOINT = "https://site.com"; + } + }); + }); }); diff --git a/components/drops/view/part/dropPartMarkdown/handlers/seize.tsx b/components/drops/view/part/dropPartMarkdown/handlers/seize.tsx index 3fff42ffe0..0ef205bb3a 100644 --- a/components/drops/view/part/dropPartMarkdown/handlers/seize.tsx +++ b/components/drops/view/part/dropPartMarkdown/handlers/seize.tsx @@ -4,6 +4,7 @@ import { ApiDrop } from "@/generated/models/ApiDrop"; import { parseSeizeQuoteLink, parseSeizeQueryLink, + getSeizeBaseOrigin, type SeizeQuoteLinkInfo, } from "@/helpers/SeizeLinkParser"; @@ -91,12 +92,21 @@ const createSeizeWaveHandler = (): LinkHandler => ); const getDropId = (href: string): string | null => { - const result = parseSeizeQueryLink(href, "/waves", ["wave", "drop"], true); - if (!result || typeof result.drop !== "string") { + const baseOrigin = getSeizeBaseOrigin(); + if (!baseOrigin) { return null; } - return result.drop; + try { + const url = new URL(href, baseOrigin); + if (url.origin !== baseOrigin) { + return null; + } + const dropId = url.searchParams.get("drop"); + return dropId ?? null; + } catch { + return null; + } }; const createSeizeDropHandler = (): LinkHandler => diff --git a/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx b/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx index 1071d62e4d..b06d04f187 100644 --- a/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx +++ b/components/drops/view/part/dropPartMarkdown/linkHandlers.tsx @@ -8,6 +8,7 @@ import { ErrorBoundary } from "react-error-boundary"; import { ExtraProps } from "react-markdown"; import { ApiDrop } from "@/generated/models/ApiDrop"; +import { ensureStableSeizeLink } from "@/helpers/SeizeLinkParser"; import LinkPreviewCard from "@/components/waves/LinkPreviewCard"; import DropPartMarkdownImage from "../DropPartMarkdownImage"; @@ -68,21 +69,28 @@ export const createLinkRenderer = ({ const renderAnchor: LinkRenderer["renderAnchor"] = (props) => { const { href } = props; - if (!href || !isValidLink(href)) { + if (!href) { return null; } - const parsedUrl = parseUrl(href); - const renderFallbackAnchor = () => renderExternalOrInternalLink(href, props); - const matchSeize = findMatch(seizeHandlers, href); + const stableHref = ensureStableSeizeLink(href); + if (!isValidLink(stableHref)) { + return null; + } + + const parsedUrl = parseUrl(stableHref); + const anchorProps = { ...props, href: stableHref }; + const renderFallbackAnchor = () => + renderExternalOrInternalLink(stableHref, anchorProps); + const matchSeize = findMatch(seizeHandlers, stableHref); const renderOpenGraph = () => { - if (!shouldUseOpenGraphPreview(href, parsedUrl)) { + if (!shouldUseOpenGraphPreview(stableHref, parsedUrl)) { return renderFallbackAnchor(); } return ( renderFallbackAnchor()} /> ); @@ -117,7 +125,7 @@ export const createLinkRenderer = ({ const renderFromHandler = (handler: LinkHandler): ReactElement | null => { try { - const rendered = handler.render(href); + const rendered = handler.render(stableHref); if (rendered === null || rendered === undefined) { throw new Error("Link handler returned no content"); } @@ -138,7 +146,7 @@ export const createLinkRenderer = ({ } } - const matchExternal = findMatch(handlers, href); + const matchExternal = findMatch(handlers, stableHref); if (matchExternal) { const rendered = renderFromHandler(matchExternal); @@ -156,22 +164,27 @@ export const createLinkRenderer = ({ }; const isSmartLink = (href: string): boolean => { - if (!href || !isValidLink(href)) { + if (!href) { + return false; + } + + const stableHref = ensureStableSeizeLink(href); + if (!isValidLink(stableHref)) { return false; } - const parsedUrl = parseUrl(href); - const seizeMatch = findMatch(seizeHandlers, href); + const parsedUrl = parseUrl(stableHref); + const seizeMatch = findMatch(seizeHandlers, stableHref); if (seizeMatch) { return seizeMatch.display === "block"; } - const match = findMatch(handlers, href); + const match = findMatch(handlers, stableHref); if (match) { return match.display === "block"; } - return shouldUseOpenGraphPreview(href, parsedUrl); + return shouldUseOpenGraphPreview(stableHref, parsedUrl); }; return { diff --git a/helpers/SeizeLinkParser.ts b/helpers/SeizeLinkParser.ts index f29845b452..bec5b7d80d 100644 --- a/helpers/SeizeLinkParser.ts +++ b/helpers/SeizeLinkParser.ts @@ -1,5 +1,36 @@ import { publicEnv } from "@/config/env"; +let cachedBaseEndpoint: string | undefined; +let cachedBaseUrl: URL | null | undefined; + +const getBaseUrl = (): URL | null => { + const current = publicEnv.BASE_ENDPOINT; + + if (!current) { + cachedBaseEndpoint = undefined; + cachedBaseUrl = null; + return null; + } + + if (cachedBaseEndpoint === current && cachedBaseUrl !== undefined) { + return cachedBaseUrl; + } + + cachedBaseEndpoint = current; + try { + cachedBaseUrl = new URL(current); + } catch { + cachedBaseUrl = null; + } + + return cachedBaseUrl; +}; + +export const getSeizeBaseOrigin = (): string | null => { + const baseUrl = getBaseUrl(); + return baseUrl ? baseUrl.origin : null; +}; + export interface SeizeQuoteLinkInfo { waveId: string; serialNo?: string; @@ -24,10 +55,19 @@ export function parseSeizeQueryLink( exact: boolean = false ): Record | null { try { - const url = new URL(href); + const baseUrl = getBaseUrl(); + if (!baseUrl) { + return null; + } + + let url: URL; + try { + url = new URL(href); + } catch { + url = new URL(href, baseUrl.origin); + } - const allowedOrigins = new Set([new URL(publicEnv.BASE_ENDPOINT).origin]); - if (!allowedOrigins.has(url.origin)) return null; + if (url.origin !== baseUrl.origin) return null; if (url.pathname !== path) return null; if (exact) { @@ -56,3 +96,65 @@ export function parseSeizeQueryLink( return null; } } + +export const ensureStableSeizeLink = ( + href: string, + currentHref?: string +): string => { + try { + const baseUrl = getBaseUrl(); + if (!baseUrl) { + return href; + } + + let targetUrl: URL; + try { + targetUrl = new URL(href); + } catch { + targetUrl = new URL(href, baseUrl.origin); + } + + if (targetUrl.origin !== baseUrl.origin) { + return href; + } + + const dropId = targetUrl.searchParams.get("drop"); + if (!dropId) { + return href; + } + + let globalWindow: Window | undefined; + if (typeof globalThis === "object" && "window" in globalThis) { + globalWindow = (globalThis as typeof globalThis & { window?: Window }).window; + } + + const resolvedCurrentHref = currentHref ?? globalWindow?.location?.href; + if (resolvedCurrentHref === undefined || resolvedCurrentHref === "") { + return href; + } + + let currentUrl: URL; + try { + currentUrl = new URL(resolvedCurrentHref); + } catch { + return href; + } + + if (currentUrl.origin !== baseUrl.origin) { + return href; + } + + const params = new URLSearchParams(currentUrl.search); + params.set("drop", dropId); + const query = params.toString(); + const hash = currentUrl.hash ?? ""; + + const path = currentUrl.pathname || "/"; + + const querySuffix = query ? `?${query}` : ""; + + return `${baseUrl.origin}${path}${querySuffix}${hash}`; + } catch { + return href; + } +};