Skip to content

Commit 2b25323

Browse files
authored
fix(canvas): navigate share links in-app instead of via the browser
Clicking a canvas/channel share link in a channel thread bounced out to the browser (PostHog Cloud interstitial) which then deep-linked back into the app. The thread feed renders server-emitted announcement messages whose content embeds a portable https share link; those links were rendered as plain external anchors with no in-app interception. Add a client-side parser that recognizes our own share-link URLs (/code/canvas/... and /code/channel/...) and navigates via the in-app router, short-circuiting the browser round-trip. Wired into MentionText (thread + channel surfaces) and MarkdownRenderer (agent chat). External links are untouched. Generated-By: PostHog Code Task-Id: a3001315-524a-4902-b23c-5c6f5b010827
1 parent d0002a5 commit 2b25323

5 files changed

Lines changed: 259 additions & 2 deletions

File tree

packages/ui/src/features/canvas/components/MentionText.test.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
import { render, screen } from "@testing-library/react";
2-
import { describe, expect, it } from "vitest";
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
33
import { MentionText } from "./MentionText";
44

5+
const navigateToChannelDashboard = vi.fn();
6+
7+
vi.mock("@posthog/ui/router/navigationBridge", () => ({
8+
navigateToChannel: vi.fn(),
9+
navigateToChannelDashboard: (...args: unknown[]) =>
10+
navigateToChannelDashboard(...args),
11+
navigateToChannelTask: vi.fn(),
12+
}));
13+
14+
beforeEach(() => {
15+
vi.clearAllMocks();
16+
});
17+
518
describe("MentionText", () => {
619
it("uses the shared mention styles and emphasizes the current user", () => {
720
render(
@@ -42,6 +55,28 @@ describe("MentionText", () => {
4255
expect(screen.queryByText("@agent")).not.toBeInTheDocument();
4356
});
4457

58+
it("navigates in-app instead of the browser for a canvas share link", () => {
59+
render(
60+
<MentionText content="[Signups](https://us.posthog.com/code/canvas/chan1/dash1) has been created" />,
61+
);
62+
63+
const link = screen.getByRole("link", { name: "Signups" });
64+
const defaultAllowed = fireEvent.click(link);
65+
66+
expect(defaultAllowed).toBe(false); // preventDefault was called
67+
expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan1", "dash1");
68+
});
69+
70+
it("leaves an external link opening in the browser", () => {
71+
render(<MentionText content="[Docs](https://example.com/guide) is here" />);
72+
73+
const link = screen.getByRole("link", { name: "Docs" });
74+
const defaultAllowed = fireEvent.click(link);
75+
76+
expect(defaultAllowed).toBe(true); // default not prevented
77+
expect(navigateToChannelDashboard).not.toHaveBeenCalled();
78+
});
79+
4580
it("inherits the surrounding message text size", () => {
4681
render(<MentionText content="A thread reply" />);
4782

packages/ui/src/features/canvas/components/MentionText.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { splitMentionSegments } from "@posthog/shared";
22
import { splitLinkSegments } from "@posthog/ui/features/canvas/utils/linkify";
3+
import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks";
34
import { Fragment, useMemo } from "react";
45
import "./mention-chip.css";
56

@@ -105,6 +106,7 @@ export function MentionText({
105106
<a
106107
key={key}
107108
href={segment.href}
109+
onClick={(event) => handleShareLinkClick(segment.href, event)}
108110
target="_blank"
109111
rel="noopener noreferrer"
110112
className="text-[var(--accent-11)] underline underline-offset-2 hover:text-[var(--accent-12)]"

packages/ui/src/features/editor/components/MarkdownRenderer.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CodeBlock } from "@posthog/ui/primitives/CodeBlock";
55
import { Divider } from "@posthog/ui/primitives/Divider";
66
import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode";
77
import { List, ListItem } from "@posthog/ui/primitives/List";
8+
import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks";
89
import { Blockquote, Checkbox, Code, Kbd, Text } from "@radix-ui/themes";
910
import { memo, useMemo } from "react";
1011
import type { Components } from "react-markdown";
@@ -91,6 +92,7 @@ export const baseComponents: Components = {
9192
<a
9293
href={href}
9394
onClick={(event) => {
95+
if (handleShareLinkClick(href, event)) return;
9496
if (!isDeeplink || !href) return;
9597
event.preventDefault();
9698
openExternalUrl(href);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {
2+
handleShareLinkClick,
3+
parseShareLink,
4+
} from "@posthog/ui/utils/shareLinks";
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
const navigateToChannel = vi.fn();
8+
const navigateToChannelDashboard = vi.fn();
9+
const navigateToChannelTask = vi.fn();
10+
11+
vi.mock("@posthog/ui/router/navigationBridge", () => ({
12+
navigateToChannel: (...args: unknown[]) => navigateToChannel(...args),
13+
navigateToChannelDashboard: (...args: unknown[]) =>
14+
navigateToChannelDashboard(...args),
15+
navigateToChannelTask: (...args: unknown[]) => navigateToChannelTask(...args),
16+
}));
17+
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
});
21+
22+
describe("parseShareLink", () => {
23+
it.each([
24+
[
25+
"canvas link",
26+
"https://us.posthog.com/code/canvas/chan1/dash1",
27+
{ kind: "canvas", channelId: "chan1", dashboardId: "dash1" },
28+
],
29+
[
30+
"canvas link with encoded ids",
31+
"https://us.posthog.com/code/canvas/chan%2F1/dash%202",
32+
{ kind: "canvas", channelId: "chan/1", dashboardId: "dash 2" },
33+
],
34+
[
35+
"channel link on the eu host",
36+
"https://eu.posthog.com/code/channel/chan1",
37+
{ kind: "channel", channelId: "chan1" },
38+
],
39+
[
40+
"channel thread link",
41+
"https://us.posthog.com/code/channel/chan1/tasks/task1",
42+
{ kind: "channel", channelId: "chan1", taskId: "task1" },
43+
],
44+
])("parses a %s", (_label, href, expected) => {
45+
expect(parseShareLink(href)).toEqual(expected);
46+
});
47+
48+
it.each([
49+
["a non-PostHog host", "https://evil.com/code/canvas/chan1/dash1"],
50+
[
51+
"an unrelated PostHog path",
52+
"https://us.posthog.com/project/2/dashboard/1",
53+
],
54+
[
55+
"a canvas link missing the dashboard id",
56+
"https://us.posthog.com/code/canvas/chan1",
57+
],
58+
[
59+
"a channel thread link with a malformed tail",
60+
"https://us.posthog.com/code/channel/chan1/foo/task1",
61+
],
62+
["a malformed url", "not a url"],
63+
])("returns null for %s", (_label, href) => {
64+
expect(parseShareLink(href)).toBeNull();
65+
});
66+
});
67+
68+
describe("handleShareLinkClick", () => {
69+
it("navigates in-app and cancels the default open for a share link", () => {
70+
const event = { preventDefault: vi.fn() };
71+
72+
const handled = handleShareLinkClick(
73+
"https://us.posthog.com/code/canvas/chan1/dash1",
74+
event,
75+
);
76+
77+
expect(handled).toBe(true);
78+
expect(event.preventDefault).toHaveBeenCalledOnce();
79+
expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan1", "dash1");
80+
});
81+
82+
it("routes a channel thread link to the task navigator", () => {
83+
const event = { preventDefault: vi.fn() };
84+
85+
handleShareLinkClick(
86+
"https://us.posthog.com/code/channel/chan1/tasks/task1",
87+
event,
88+
);
89+
90+
expect(navigateToChannelTask).toHaveBeenCalledWith("chan1", "task1");
91+
});
92+
93+
it("leaves an external link alone", () => {
94+
const event = { preventDefault: vi.fn() };
95+
96+
const handled = handleShareLinkClick("https://example.com/docs", event);
97+
98+
expect(handled).toBe(false);
99+
expect(event.preventDefault).not.toHaveBeenCalled();
100+
expect(navigateToChannel).not.toHaveBeenCalled();
101+
expect(navigateToChannelDashboard).not.toHaveBeenCalled();
102+
expect(navigateToChannelTask).not.toHaveBeenCalled();
103+
});
104+
105+
it("returns false for a missing href", () => {
106+
const event = { preventDefault: vi.fn() };
107+
108+
expect(handleShareLinkClick(undefined, event)).toBe(false);
109+
expect(event.preventDefault).not.toHaveBeenCalled();
110+
});
111+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared";
2+
import {
3+
navigateToChannel,
4+
navigateToChannelDashboard,
5+
navigateToChannelTask,
6+
} from "@posthog/ui/router/navigationBridge";
7+
8+
// The in-app destination a PostHog Code share link points at. The inverse of the
9+
// `canvasShareUrl` / `channelShareUrl` builders in `posthogLinks.ts`.
10+
export type ShareLinkTarget =
11+
| { kind: "canvas"; channelId: string; dashboardId: string }
12+
| { kind: "channel"; channelId: string; taskId?: string };
13+
14+
const REGIONS: CloudRegion[] = ["us", "eu", "dev"];
15+
16+
// Hosts we recognise as PostHog share-link origins. We match every region (not
17+
// just the signed-in one) so a link works in-app regardless of which instance
18+
// it was minted on — the inbound deep-link handlers already navigate by id
19+
// against the current session, so bouncing through the browser buys nothing.
20+
const POSTHOG_HOSTS = new Set(
21+
REGIONS.map((region) => {
22+
try {
23+
return new URL(getCloudUrlFromRegion(region)).host;
24+
} catch {
25+
return "";
26+
}
27+
}).filter(Boolean),
28+
);
29+
30+
/**
31+
* Parse a PostHog Code share link into its in-app navigation target, or `null`
32+
* if it isn't one. Recognises `/code/canvas/<channelId>/<dashboardId>` and
33+
* `/code/channel/<channelId>[/tasks/<taskId>]` on a known PostHog host. The
34+
* host check keeps us from hijacking unrelated links that happen to share the
35+
* path shape.
36+
*/
37+
export function parseShareLink(href: string): ShareLinkTarget | null {
38+
let url: URL;
39+
try {
40+
url = new URL(href);
41+
} catch {
42+
return null;
43+
}
44+
if (!POSTHOG_HOSTS.has(url.host)) return null;
45+
46+
// Split the still-encoded pathname first, then decode each segment, so an id
47+
// containing an encoded slash (`%2F`) stays a single segment.
48+
const segments = url.pathname
49+
.split("/")
50+
.filter(Boolean)
51+
.map((segment) => {
52+
try {
53+
return decodeURIComponent(segment);
54+
} catch {
55+
return segment;
56+
}
57+
});
58+
59+
if (segments[0] !== "code") return null;
60+
61+
if (segments[1] === "canvas" && segments.length === 4) {
62+
return { kind: "canvas", channelId: segments[2], dashboardId: segments[3] };
63+
}
64+
65+
if (segments[1] === "channel") {
66+
if (segments.length === 3) {
67+
return { kind: "channel", channelId: segments[2] };
68+
}
69+
if (segments.length === 5 && segments[3] === "tasks") {
70+
return { kind: "channel", channelId: segments[2], taskId: segments[4] };
71+
}
72+
}
73+
74+
return null;
75+
}
76+
77+
export function navigateToShareTarget(target: ShareLinkTarget): void {
78+
switch (target.kind) {
79+
case "canvas":
80+
navigateToChannelDashboard(target.channelId, target.dashboardId);
81+
break;
82+
case "channel":
83+
if (target.taskId) {
84+
navigateToChannelTask(target.channelId, target.taskId);
85+
} else {
86+
navigateToChannel(target.channelId);
87+
}
88+
break;
89+
}
90+
}
91+
92+
/**
93+
* If `href` is a PostHog Code share link, navigate to it in-app and return true
94+
* (cancelling the click's default open-in-browser). Otherwise return false so
95+
* the caller lets the link open externally as usual.
96+
*/
97+
export function handleShareLinkClick(
98+
href: string | undefined,
99+
event: { preventDefault: () => void },
100+
): boolean {
101+
if (!href) return false;
102+
const target = parseShareLink(href);
103+
if (!target) return false;
104+
event.preventDefault();
105+
navigateToShareTarget(target);
106+
return true;
107+
}

0 commit comments

Comments
 (0)