diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx index 6c9588e4d3..e924736b4e 100644 --- a/packages/ui/src/features/canvas/components/MentionText.test.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.test.tsx @@ -1,7 +1,20 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { MentionText } from "./MentionText"; +const navigateToChannelDashboard = vi.fn(); + +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToChannel: vi.fn(), + navigateToChannelDashboard: (...args: unknown[]) => + navigateToChannelDashboard(...args), + navigateToChannelTask: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("MentionText", () => { it("uses the shared mention styles and emphasizes the current user", () => { render( @@ -42,6 +55,28 @@ describe("MentionText", () => { expect(screen.queryByText("@agent")).not.toBeInTheDocument(); }); + it("navigates in-app instead of the browser for a canvas share link", () => { + render( + , + ); + + const link = screen.getByRole("link", { name: "Signups" }); + const defaultAllowed = fireEvent.click(link); + + expect(defaultAllowed).toBe(false); + expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan1", "dash1"); + }); + + it("leaves an external link opening in the browser", () => { + render(); + + const link = screen.getByRole("link", { name: "Docs" }); + const defaultAllowed = fireEvent.click(link); + + expect(defaultAllowed).toBe(true); + expect(navigateToChannelDashboard).not.toHaveBeenCalled(); + }); + it("inherits the surrounding message text size", () => { render(); diff --git a/packages/ui/src/features/canvas/components/MentionText.tsx b/packages/ui/src/features/canvas/components/MentionText.tsx index 41214cf80e..8fbe4bea7f 100644 --- a/packages/ui/src/features/canvas/components/MentionText.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -1,5 +1,6 @@ import { splitMentionSegments } from "@posthog/shared"; import { splitLinkSegments } from "@posthog/ui/features/canvas/utils/linkify"; +import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks"; import { Fragment, useMemo } from "react"; import "./mention-chip.css"; @@ -105,6 +106,7 @@ export function MentionText({ handleShareLinkClick(segment.href, event)} target="_blank" rel="noopener noreferrer" className="text-[var(--accent-11)] underline underline-offset-2 hover:text-[var(--accent-12)]" diff --git a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx index e024b2d08c..32a43862a0 100644 --- a/packages/ui/src/features/editor/components/MarkdownRenderer.tsx +++ b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx @@ -5,6 +5,7 @@ import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; import { Divider } from "@posthog/ui/primitives/Divider"; import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode"; import { List, ListItem } from "@posthog/ui/primitives/List"; +import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks"; import { Blockquote, Checkbox, Code, Kbd, Text } from "@radix-ui/themes"; import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; @@ -91,6 +92,7 @@ export const baseComponents: Components = { { + if (handleShareLinkClick(href, event)) return; if (!isDeeplink || !href) return; event.preventDefault(); openExternalUrl(href); diff --git a/packages/ui/src/utils/posthogLinks.test.ts b/packages/ui/src/utils/posthogLinks.test.ts index cc3acef9d9..0d84e0a7f8 100644 --- a/packages/ui/src/utils/posthogLinks.test.ts +++ b/packages/ui/src/utils/posthogLinks.test.ts @@ -1,6 +1,7 @@ import { canvasShareUrl, errorTrackingIssueUrl, + parseShareLink, } from "@posthog/ui/utils/posthogLinks"; import { describe, expect, it, vi } from "vitest"; @@ -16,6 +17,52 @@ describe("canvasShareUrl", () => { }); }); +describe("parseShareLink", () => { + it.each([ + [ + "canvas link", + "https://us.posthog.com/code/canvas/chan1/dash1", + { kind: "canvas", channelId: "chan1", dashboardId: "dash1" }, + ], + [ + "canvas link with encoded ids", + "https://us.posthog.com/code/canvas/chan%2F1/dash%202", + { kind: "canvas", channelId: "chan/1", dashboardId: "dash 2" }, + ], + [ + "channel link on the eu host", + "https://eu.posthog.com/code/channel/chan1", + { kind: "channel", channelId: "chan1" }, + ], + [ + "channel thread link", + "https://us.posthog.com/code/channel/chan1/tasks/task1", + { kind: "channel", channelId: "chan1", taskId: "task1" }, + ], + ])("parses a %s", (_label, href, expected) => { + expect(parseShareLink(href)).toEqual(expected); + }); + + it.each([ + ["a non-PostHog host", "https://evil.com/code/canvas/chan1/dash1"], + [ + "an unrelated PostHog path", + "https://us.posthog.com/project/2/dashboard/1", + ], + [ + "a canvas link missing the dashboard id", + "https://us.posthog.com/code/canvas/chan1", + ], + [ + "a channel thread link with a malformed tail", + "https://us.posthog.com/code/channel/chan1/foo/task1", + ], + ["a malformed url", "not a url"], + ])("returns null for %s", (_label, href) => { + expect(parseShareLink(href)).toBeNull(); + }); +}); + describe("errorTrackingIssueUrl", () => { it("links to the issue when no fingerprint is provided", () => { expect( diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index b5df06abf1..4db4fb3aec 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -1,4 +1,8 @@ -import type { CloudRegion } from "@posthog/shared"; +import { + type CloudRegion, + getCloudUrlFromRegion, + REGION_LABELS, +} from "@posthog/shared"; import { useAuthStore } from "@posthog/ui/features/auth/store"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; @@ -117,6 +121,93 @@ export function channelShareUrl( ); } +export type ShareLinkTarget = + | { kind: "canvas"; channelId: string; dashboardId: string } + | { kind: "channel"; channelId: string; taskId?: string }; + +const POSTHOG_HOSTS = new Set( + (Object.keys(REGION_LABELS) as CloudRegion[]) + .map((region) => { + try { + return new URL(getCloudUrlFromRegion(region)).host; + } catch { + return ""; + } + }) + .filter(Boolean), +); + +interface ShareLinkRoute { + pattern: string[]; + build: (params: Record) => ShareLinkTarget; +} + +const SHARE_LINK_ROUTES: ShareLinkRoute[] = [ + { + pattern: ["code", "canvas", ":channelId", ":dashboardId"], + build: ({ channelId, dashboardId }) => ({ + kind: "canvas", + channelId, + dashboardId, + }), + }, + { + pattern: ["code", "channel", ":channelId"], + build: ({ channelId }) => ({ kind: "channel", channelId }), + }, + { + pattern: ["code", "channel", ":channelId", "tasks", ":taskId"], + build: ({ channelId, taskId }) => ({ kind: "channel", channelId, taskId }), + }, +]; + +function decodePathSegments(pathname: string): string[] { + return pathname + .split("/") + .filter(Boolean) + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }); +} + +function matchRoute( + segments: string[], + route: ShareLinkRoute, +): ShareLinkTarget | null { + if (segments.length !== route.pattern.length) return null; + const params: Record = {}; + for (const [index, token] of route.pattern.entries()) { + const segment = segments[index]; + if (token.startsWith(":")) { + params[token.slice(1)] = segment; + } else if (token !== segment) { + return null; + } + } + return route.build(params); +} + +export function parseShareLink(href: string): ShareLinkTarget | null { + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + if (!POSTHOG_HOSTS.has(url.host)) return null; + + const segments = decodePathSegments(url.pathname); + for (const route of SHARE_LINK_ROUTES) { + const target = matchRoute(segments, route); + if (target) return target; + } + return null; +} + export function errorTrackingIssueUrl( issueId: string, overrides?: ErrorTrackingIssueLinkOverrides, diff --git a/packages/ui/src/utils/shareLinks.test.ts b/packages/ui/src/utils/shareLinks.test.ts new file mode 100644 index 0000000000..7184867d6a --- /dev/null +++ b/packages/ui/src/utils/shareLinks.test.ts @@ -0,0 +1,83 @@ +import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const navigateToChannel = vi.fn(); +const navigateToChannelDashboard = vi.fn(); +const navigateToChannelTask = vi.fn(); + +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToChannel: (...args: unknown[]) => navigateToChannel(...args), + navigateToChannelDashboard: (...args: unknown[]) => + navigateToChannelDashboard(...args), + navigateToChannelTask: (...args: unknown[]) => navigateToChannelTask(...args), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("handleShareLinkClick", () => { + it("navigates in-app and cancels the default open for a share link", () => { + const event = { preventDefault: vi.fn() }; + + const handled = handleShareLinkClick( + "https://us.posthog.com/code/canvas/chan1/dash1", + event, + ); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan1", "dash1"); + }); + + it("routes a channel thread link to the task navigator", () => { + const event = { preventDefault: vi.fn() }; + + handleShareLinkClick( + "https://us.posthog.com/code/channel/chan1/tasks/task1", + event, + ); + + expect(navigateToChannelTask).toHaveBeenCalledWith("chan1", "task1"); + }); + + it.each([ + ["meta", { metaKey: true }], + ["ctrl", { ctrlKey: true }], + ["shift", { shiftKey: true }], + ["a middle button", { button: 1 }], + ])( + "leaves a %s-modified click to open in a new tab/window", + (_label, modifier) => { + const event = { preventDefault: vi.fn(), ...modifier }; + + const handled = handleShareLinkClick( + "https://us.posthog.com/code/canvas/chan1/dash1", + event, + ); + + expect(handled).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToChannelDashboard).not.toHaveBeenCalled(); + }, + ); + + it("leaves an external link alone", () => { + const event = { preventDefault: vi.fn() }; + + const handled = handleShareLinkClick("https://example.com/docs", event); + + expect(handled).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToChannel).not.toHaveBeenCalled(); + expect(navigateToChannelDashboard).not.toHaveBeenCalled(); + expect(navigateToChannelTask).not.toHaveBeenCalled(); + }); + + it("returns false for a missing href", () => { + const event = { preventDefault: vi.fn() }; + + expect(handleShareLinkClick(undefined, event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/utils/shareLinks.ts b/packages/ui/src/utils/shareLinks.ts new file mode 100644 index 0000000000..62f0b4b17c --- /dev/null +++ b/packages/ui/src/utils/shareLinks.ts @@ -0,0 +1,55 @@ +import { + navigateToChannel, + navigateToChannelDashboard, + navigateToChannelTask, +} from "@posthog/ui/router/navigationBridge"; +import { + parseShareLink, + type ShareLinkTarget, +} from "@posthog/ui/utils/posthogLinks"; + +export function navigateToShareTarget(target: ShareLinkTarget): void { + switch (target.kind) { + case "canvas": + navigateToChannelDashboard(target.channelId, target.dashboardId); + break; + case "channel": + if (target.taskId) { + navigateToChannelTask(target.channelId, target.taskId); + } else { + navigateToChannel(target.channelId); + } + break; + } +} + +interface ShareLinkClickEvent { + preventDefault: () => void; + metaKey?: boolean; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + button?: number; +} + +function isModifiedClick(event: ShareLinkClickEvent): boolean { + return Boolean( + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + (event.button != null && event.button !== 0), + ); +} + +export function handleShareLinkClick( + href: string | undefined, + event: ShareLinkClickEvent, +): boolean { + if (!href || isModifiedClick(event)) return false; + const target = parseShareLink(href); + if (!target) return false; + event.preventDefault(); + navigateToShareTarget(target); + return true; +}