Skip to content

Commit 351fd79

Browse files
authored
fix(canvas): navigate share links in-app instead of via the browser (#3582)
1 parent 4f0acfb commit 351fd79

7 files changed

Lines changed: 318 additions & 3 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);
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);
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);

packages/ui/src/utils/posthogLinks.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
canvasShareUrl,
33
errorTrackingIssueUrl,
4+
parseShareLink,
45
} from "@posthog/ui/utils/posthogLinks";
56
import { describe, expect, it, vi } from "vitest";
67

@@ -16,6 +17,52 @@ describe("canvasShareUrl", () => {
1617
});
1718
});
1819

20+
describe("parseShareLink", () => {
21+
it.each([
22+
[
23+
"canvas link",
24+
"https://us.posthog.com/code/canvas/chan1/dash1",
25+
{ kind: "canvas", channelId: "chan1", dashboardId: "dash1" },
26+
],
27+
[
28+
"canvas link with encoded ids",
29+
"https://us.posthog.com/code/canvas/chan%2F1/dash%202",
30+
{ kind: "canvas", channelId: "chan/1", dashboardId: "dash 2" },
31+
],
32+
[
33+
"channel link on the eu host",
34+
"https://eu.posthog.com/code/channel/chan1",
35+
{ kind: "channel", channelId: "chan1" },
36+
],
37+
[
38+
"channel thread link",
39+
"https://us.posthog.com/code/channel/chan1/tasks/task1",
40+
{ kind: "channel", channelId: "chan1", taskId: "task1" },
41+
],
42+
])("parses a %s", (_label, href, expected) => {
43+
expect(parseShareLink(href)).toEqual(expected);
44+
});
45+
46+
it.each([
47+
["a non-PostHog host", "https://evil.com/code/canvas/chan1/dash1"],
48+
[
49+
"an unrelated PostHog path",
50+
"https://us.posthog.com/project/2/dashboard/1",
51+
],
52+
[
53+
"a canvas link missing the dashboard id",
54+
"https://us.posthog.com/code/canvas/chan1",
55+
],
56+
[
57+
"a channel thread link with a malformed tail",
58+
"https://us.posthog.com/code/channel/chan1/foo/task1",
59+
],
60+
["a malformed url", "not a url"],
61+
])("returns null for %s", (_label, href) => {
62+
expect(parseShareLink(href)).toBeNull();
63+
});
64+
});
65+
1966
describe("errorTrackingIssueUrl", () => {
2067
it("links to the issue when no fingerprint is provided", () => {
2168
expect(

packages/ui/src/utils/posthogLinks.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import type { CloudRegion } from "@posthog/shared";
1+
import {
2+
type CloudRegion,
3+
getCloudUrlFromRegion,
4+
REGION_LABELS,
5+
} from "@posthog/shared";
26
import { useAuthStore } from "@posthog/ui/features/auth/store";
37
import { getPostHogUrl } from "@posthog/ui/utils/urls";
48

@@ -117,6 +121,93 @@ export function channelShareUrl(
117121
);
118122
}
119123

124+
export type ShareLinkTarget =
125+
| { kind: "canvas"; channelId: string; dashboardId: string }
126+
| { kind: "channel"; channelId: string; taskId?: string };
127+
128+
const POSTHOG_HOSTS = new Set(
129+
(Object.keys(REGION_LABELS) as CloudRegion[])
130+
.map((region) => {
131+
try {
132+
return new URL(getCloudUrlFromRegion(region)).host;
133+
} catch {
134+
return "";
135+
}
136+
})
137+
.filter(Boolean),
138+
);
139+
140+
interface ShareLinkRoute {
141+
pattern: string[];
142+
build: (params: Record<string, string>) => ShareLinkTarget;
143+
}
144+
145+
const SHARE_LINK_ROUTES: ShareLinkRoute[] = [
146+
{
147+
pattern: ["code", "canvas", ":channelId", ":dashboardId"],
148+
build: ({ channelId, dashboardId }) => ({
149+
kind: "canvas",
150+
channelId,
151+
dashboardId,
152+
}),
153+
},
154+
{
155+
pattern: ["code", "channel", ":channelId"],
156+
build: ({ channelId }) => ({ kind: "channel", channelId }),
157+
},
158+
{
159+
pattern: ["code", "channel", ":channelId", "tasks", ":taskId"],
160+
build: ({ channelId, taskId }) => ({ kind: "channel", channelId, taskId }),
161+
},
162+
];
163+
164+
function decodePathSegments(pathname: string): string[] {
165+
return pathname
166+
.split("/")
167+
.filter(Boolean)
168+
.map((segment) => {
169+
try {
170+
return decodeURIComponent(segment);
171+
} catch {
172+
return segment;
173+
}
174+
});
175+
}
176+
177+
function matchRoute(
178+
segments: string[],
179+
route: ShareLinkRoute,
180+
): ShareLinkTarget | null {
181+
if (segments.length !== route.pattern.length) return null;
182+
const params: Record<string, string> = {};
183+
for (const [index, token] of route.pattern.entries()) {
184+
const segment = segments[index];
185+
if (token.startsWith(":")) {
186+
params[token.slice(1)] = segment;
187+
} else if (token !== segment) {
188+
return null;
189+
}
190+
}
191+
return route.build(params);
192+
}
193+
194+
export function parseShareLink(href: string): ShareLinkTarget | null {
195+
let url: URL;
196+
try {
197+
url = new URL(href);
198+
} catch {
199+
return null;
200+
}
201+
if (!POSTHOG_HOSTS.has(url.host)) return null;
202+
203+
const segments = decodePathSegments(url.pathname);
204+
for (const route of SHARE_LINK_ROUTES) {
205+
const target = matchRoute(segments, route);
206+
if (target) return target;
207+
}
208+
return null;
209+
}
210+
120211
export function errorTrackingIssueUrl(
121212
issueId: string,
122213
overrides?: ErrorTrackingIssueLinkOverrides,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { handleShareLinkClick } from "@posthog/ui/utils/shareLinks";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const navigateToChannel = vi.fn();
5+
const navigateToChannelDashboard = vi.fn();
6+
const navigateToChannelTask = vi.fn();
7+
8+
vi.mock("@posthog/ui/router/navigationBridge", () => ({
9+
navigateToChannel: (...args: unknown[]) => navigateToChannel(...args),
10+
navigateToChannelDashboard: (...args: unknown[]) =>
11+
navigateToChannelDashboard(...args),
12+
navigateToChannelTask: (...args: unknown[]) => navigateToChannelTask(...args),
13+
}));
14+
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
});
18+
19+
describe("handleShareLinkClick", () => {
20+
it("navigates in-app and cancels the default open for a share link", () => {
21+
const event = { preventDefault: vi.fn() };
22+
23+
const handled = handleShareLinkClick(
24+
"https://us.posthog.com/code/canvas/chan1/dash1",
25+
event,
26+
);
27+
28+
expect(handled).toBe(true);
29+
expect(event.preventDefault).toHaveBeenCalledOnce();
30+
expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan1", "dash1");
31+
});
32+
33+
it("routes a channel thread link to the task navigator", () => {
34+
const event = { preventDefault: vi.fn() };
35+
36+
handleShareLinkClick(
37+
"https://us.posthog.com/code/channel/chan1/tasks/task1",
38+
event,
39+
);
40+
41+
expect(navigateToChannelTask).toHaveBeenCalledWith("chan1", "task1");
42+
});
43+
44+
it.each([
45+
["meta", { metaKey: true }],
46+
["ctrl", { ctrlKey: true }],
47+
["shift", { shiftKey: true }],
48+
["a middle button", { button: 1 }],
49+
])(
50+
"leaves a %s-modified click to open in a new tab/window",
51+
(_label, modifier) => {
52+
const event = { preventDefault: vi.fn(), ...modifier };
53+
54+
const handled = handleShareLinkClick(
55+
"https://us.posthog.com/code/canvas/chan1/dash1",
56+
event,
57+
);
58+
59+
expect(handled).toBe(false);
60+
expect(event.preventDefault).not.toHaveBeenCalled();
61+
expect(navigateToChannelDashboard).not.toHaveBeenCalled();
62+
},
63+
);
64+
65+
it("leaves an external link alone", () => {
66+
const event = { preventDefault: vi.fn() };
67+
68+
const handled = handleShareLinkClick("https://example.com/docs", event);
69+
70+
expect(handled).toBe(false);
71+
expect(event.preventDefault).not.toHaveBeenCalled();
72+
expect(navigateToChannel).not.toHaveBeenCalled();
73+
expect(navigateToChannelDashboard).not.toHaveBeenCalled();
74+
expect(navigateToChannelTask).not.toHaveBeenCalled();
75+
});
76+
77+
it("returns false for a missing href", () => {
78+
const event = { preventDefault: vi.fn() };
79+
80+
expect(handleShareLinkClick(undefined, event)).toBe(false);
81+
expect(event.preventDefault).not.toHaveBeenCalled();
82+
});
83+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {
2+
navigateToChannel,
3+
navigateToChannelDashboard,
4+
navigateToChannelTask,
5+
} from "@posthog/ui/router/navigationBridge";
6+
import {
7+
parseShareLink,
8+
type ShareLinkTarget,
9+
} from "@posthog/ui/utils/posthogLinks";
10+
11+
export function navigateToShareTarget(target: ShareLinkTarget): void {
12+
switch (target.kind) {
13+
case "canvas":
14+
navigateToChannelDashboard(target.channelId, target.dashboardId);
15+
break;
16+
case "channel":
17+
if (target.taskId) {
18+
navigateToChannelTask(target.channelId, target.taskId);
19+
} else {
20+
navigateToChannel(target.channelId);
21+
}
22+
break;
23+
}
24+
}
25+
26+
interface ShareLinkClickEvent {
27+
preventDefault: () => void;
28+
metaKey?: boolean;
29+
ctrlKey?: boolean;
30+
shiftKey?: boolean;
31+
altKey?: boolean;
32+
button?: number;
33+
}
34+
35+
function isModifiedClick(event: ShareLinkClickEvent): boolean {
36+
return Boolean(
37+
event.metaKey ||
38+
event.ctrlKey ||
39+
event.shiftKey ||
40+
event.altKey ||
41+
(event.button != null && event.button !== 0),
42+
);
43+
}
44+
45+
export function handleShareLinkClick(
46+
href: string | undefined,
47+
event: ShareLinkClickEvent,
48+
): boolean {
49+
if (!href || isModifiedClick(event)) return false;
50+
const target = parseShareLink(href);
51+
if (!target) return false;
52+
event.preventDefault();
53+
navigateToShareTarget(target);
54+
return true;
55+
}

0 commit comments

Comments
 (0)