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;
+ }
+};