From 30c8392ad9d77a87fa3b5782e482d9fbc0b1fa5f Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 29 May 2026 11:51:08 +0100 Subject: [PATCH 1/2] feat(mobile): add slug suffix to inbox report deep links Port of the desktop change in #2298: when the mobile "Discuss this report" flow builds a link back to the report, append the slugified title as a trailing path segment so shared/copied links read like `posthog://inbox//fix-inbox--Add-foo` instead of just the UUID. The slug is purely cosmetic; receivers ignore everything past the UUID. - Add `slugifyTitle` and `inboxReportShareUrl` to `lib/deep-links.ts`, mirroring `buildInboxDeeplink` from `apps/code/src/shared/deeplink.ts` (NFD fold, colon-run `--`, URL-unreserved punctuation passthrough, etc). - Thread `reportTitle` through `DiscussReportSheet` so the link it hands to `buildDiscussReportPrompt` is the slug-suffixed share URL. - Tolerate inbound `/inbox//` two ways: convert the route to a catch-all (`inbox/[...id].tsx`) so expo-router matches the deeper path, and strip the trailing slug in `externalUrlToAppPath` so notification-tap navigation lands on the bare `/inbox/` path. - Unit tests cover the slug rules (accented-fold, colon-run, punctuation collapse, trim) and the inbound-path tolerance on both the custom-scheme and universal-link entry points. Generated-By: PostHog Code Task-Id: a37e6f21-3453-4ac4-9853-d61de0ea362a --- apps/mobile/src/app/_layout.tsx | 8 +- .../src/app/inbox/{[id].tsx => [...id].tsx} | 9 +- .../inbox/components/DiscussReportSheet.tsx | 6 +- apps/mobile/src/lib/deep-links.test.ts | 129 ++++++++++++++++++ apps/mobile/src/lib/deep-links.ts | 83 +++++++++-- 5 files changed, 221 insertions(+), 14 deletions(-) rename apps/mobile/src/app/inbox/{[id].tsx => [...id].tsx} (96%) create mode 100644 apps/mobile/src/lib/deep-links.test.ts diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 235a4c8b9..344a4fe18 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -108,10 +108,12 @@ function RootLayoutNav({ isConnected }: RootLayoutNavProps) { /> {/* Inbox report detail - modal presentation, no native header - (the in-content title block is the canonical header). Path mirrors - the desktop app's `posthog-code://inbox/` deep-link shape. */} + (the in-content title block is the canonical header). Catch-all + segment so the route also tolerates the cosmetic slug suffix on + shared links: `posthog://inbox/` and + `posthog://inbox//` both resolve here. */} diff --git a/apps/mobile/src/app/inbox/[id].tsx b/apps/mobile/src/app/inbox/[...id].tsx similarity index 96% rename from apps/mobile/src/app/inbox/[id].tsx rename to apps/mobile/src/app/inbox/[...id].tsx index 05808e627..5da1282c7 100644 --- a/apps/mobile/src/app/inbox/[id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -108,7 +108,13 @@ function ActionabilityBadge({ value }: { value: string }) { } export default function ReportDetailScreen() { - const { id: reportId } = useLocalSearchParams<{ id: string }>(); + // Catch-all route: `id` arrives as string[] for `/inbox//` and + // we only read the first segment (the UUID). The slug is purely cosmetic; + // receivers ignore everything past the UUID, matching the desktop contract + // in `apps/code/src/shared/deeplink.ts`. Expo-router can hand us either a + // string or string[] depending on the URL shape, so tolerate both. + const { id: idParam } = useLocalSearchParams<{ id: string | string[] }>(); + const reportId = Array.isArray(idParam) ? idParam[0] : idParam; const router = useRouter(); const themeColors = useThemeColors(); const insets = useSafeAreaInsets(); @@ -455,6 +461,7 @@ export default function ReportDetailScreen() { setDiscussOpen(false)} onSubmit={handleDiscussSubmit} /> diff --git a/apps/mobile/src/features/inbox/components/DiscussReportSheet.tsx b/apps/mobile/src/features/inbox/components/DiscussReportSheet.tsx index a19e2714b..350bf194e 100644 --- a/apps/mobile/src/features/inbox/components/DiscussReportSheet.tsx +++ b/apps/mobile/src/features/inbox/components/DiscussReportSheet.tsx @@ -11,12 +11,13 @@ import { View, } from "react-native"; import { SheetContainer } from "@/components/SheetContainer"; -import { customSchemeUrl, paths } from "@/lib/deep-links"; +import { inboxReportShareUrl } from "@/lib/deep-links"; import { useThemeColors } from "@/lib/theme"; interface DiscussReportSheetProps { visible: boolean; reportId: string; + reportTitle?: string | null; onClose: () => void; onSubmit: (params: { prompt: string; question: string }) => void; } @@ -24,6 +25,7 @@ interface DiscussReportSheetProps { export function DiscussReportSheet({ visible, reportId, + reportTitle, onClose, onSubmit, }: DiscussReportSheetProps) { @@ -37,7 +39,7 @@ export function DiscussReportSheet({ const handleSubmit = () => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); const trimmed = question.trim(); - const reportLink = customSchemeUrl(paths.inboxReport(reportId)); + const reportLink = inboxReportShareUrl(reportId, reportTitle); const prompt = buildDiscussReportPrompt({ reportId, reportLink, diff --git a/apps/mobile/src/lib/deep-links.test.ts b/apps/mobile/src/lib/deep-links.test.ts new file mode 100644 index 000000000..fd86323cd --- /dev/null +++ b/apps/mobile/src/lib/deep-links.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + externalUrlToAppPath, + inboxReportShareUrl, + slugifyTitle, +} from "./deep-links"; + +describe("slugifyTitle", () => { + it("returns an empty string when the title is missing or blank", () => { + expect(slugifyTitle(null)).toBe(""); + expect(slugifyTitle(undefined)).toBe(""); + expect(slugifyTitle("")).toBe(""); + expect(slugifyTitle(" ")).toBe(""); + expect(slugifyTitle(":::")).toBe(""); + }); + + it("emits `--` for runs that mix a colon with other unsafe chars", () => { + expect(slugifyTitle("fix(inbox): Add foo")).toBe("fix-inbox--Add-foo"); + }); + + it("emits a single `-` for a colon-only run", () => { + expect(slugifyTitle("feat:bar")).toBe("feat-bar"); + }); + + it("preserves URL-unreserved punctuation (- _ . ~)", () => { + expect(slugifyTitle("v1.2.3_final~ish")).toBe("v1.2.3_final~ish"); + }); + + it("collapses runs of unsafe punctuation into a single hyphen", () => { + expect(slugifyTitle("Cost $5, 50% off!")).toBe("Cost-5-50-off"); + }); + + it("folds accented Latin letters to their ASCII base", () => { + expect(slugifyTitle("café résumé naïve")).toBe("cafe-resume-naive"); + }); + + it("hyphenizes non-Latin scripts that have no ASCII fold", () => { + expect(slugifyTitle("Hello Привет world")).toBe("Hello-world"); + }); + + it("preserves case", () => { + expect(slugifyTitle("Hello World")).toBe("Hello-World"); + }); +}); + +describe("inboxReportShareUrl", () => { + it("returns just the UUID when no title is given", () => { + expect(inboxReportShareUrl("abc-123")).toBe("posthog://inbox/abc-123"); + expect(inboxReportShareUrl("abc-123", null)).toBe( + "posthog://inbox/abc-123", + ); + expect(inboxReportShareUrl("abc-123", undefined)).toBe( + "posthog://inbox/abc-123", + ); + expect(inboxReportShareUrl("abc-123", "")).toBe("posthog://inbox/abc-123"); + }); + + it("appends a slug derived from the title", () => { + expect(inboxReportShareUrl("abc-123", "Hello World")).toBe( + "posthog://inbox/abc-123/Hello-World", + ); + }); + + it("omits the slug when the title slugifies to empty", () => { + expect(inboxReportShareUrl("abc-123", ":::")).toBe( + "posthog://inbox/abc-123", + ); + expect(inboxReportShareUrl("abc-123", " ")).toBe( + "posthog://inbox/abc-123", + ); + }); + + it("preserves the desktop colon-run convention", () => { + expect(inboxReportShareUrl("abc-123", "fix(inbox): Add foo")).toBe( + "posthog://inbox/abc-123/fix-inbox--Add-foo", + ); + }); +}); + +describe("externalUrlToAppPath", () => { + it("returns the router path for a custom-scheme URL", () => { + expect(externalUrlToAppPath("posthog://task/task-123")).toBe( + "/task/task-123", + ); + }); + + it("returns the router path for a universal-link URL", () => { + expect(externalUrlToAppPath("https://code.posthog.com/task/task-123")).toBe( + "/task/task-123", + ); + }); + + it("preserves the query string", () => { + expect(externalUrlToAppPath("posthog://task/task-123?foo=bar")).toBe( + "/task/task-123?foo=bar", + ); + }); + + it("strips a trailing slug segment from inbox custom-scheme URLs", () => { + expect( + externalUrlToAppPath("posthog://inbox/report-abc/fix-inbox--Add-foo"), + ).toBe("/inbox/report-abc"); + }); + + it("strips a trailing slug segment from inbox universal links", () => { + expect( + externalUrlToAppPath( + "https://code.posthog.com/inbox/report-abc/Hello-World", + ), + ).toBe("/inbox/report-abc"); + }); + + it("preserves the query string when stripping an inbox slug", () => { + expect( + externalUrlToAppPath("posthog://inbox/report-abc/Hello-World?x=1"), + ).toBe("/inbox/report-abc?x=1"); + }); + + it("leaves a bare inbox URL untouched", () => { + expect(externalUrlToAppPath("posthog://inbox/report-abc")).toBe( + "/inbox/report-abc", + ); + }); + + it("ignores URLs from unrelated hosts/schemes", () => { + expect(externalUrlToAppPath("https://example.com/inbox/x")).toBe(null); + expect(externalUrlToAppPath("not a url")).toBe(null); + }); +}); diff --git a/apps/mobile/src/lib/deep-links.ts b/apps/mobile/src/lib/deep-links.ts index 50be15f6a..0e1d01aa9 100644 --- a/apps/mobile/src/lib/deep-links.ts +++ b/apps/mobile/src/lib/deep-links.ts @@ -7,6 +7,7 @@ * posthog://task/ * posthog://task//run/ * posthog://inbox/ + * posthog://inbox// (slug is cosmetic, ignored on receive) * * Mobile uses the `posthog://` custom scheme (registered in app.json) and * https://code.posthog.com as the universal-link host. Both share the same @@ -16,7 +17,8 @@ * For in-app navigation, prefer the `paths.*` helpers — they return the * router-relative path that `router.push()` expects. For external/shareable * links (push notifications, Slack messages, copy-link buttons), use - * `universalUrl()` or `customSchemeUrl()`. + * `universalUrl()` or `customSchemeUrl()`. To produce a human-readable share + * link for an inbox report, use `inboxReportShareUrl(reportId, title)`. */ export const MOBILE_SCHEME = "posthog"; @@ -56,11 +58,61 @@ export function universalUrl(path: AppPath): string { return `${UNIVERSAL_LINK_PREFIX}${normalized}`; } +/** + * Slugify a free-form title for use as a trailing path segment on a shareable + * deep link. Mirrors `buildInboxDeeplink`'s slug rules in the desktop app + * (apps/code/src/shared/deeplink.ts) exactly: + * + * - Accented Latin letters are folded to their ASCII base (`café` → `cafe`) + * via NFD decomposition + combining-mark stripping. + * - Letters, digits, and the URL-unreserved punctuation `_ . ~` are kept + * verbatim (case preserved). + * - Any run of other characters collapses to a single `-`, except runs that + * mix a colon with other unsafe chars collapse to `--`. This preserves the + * title-like break in `fix(inbox): Add foo` → `fix-inbox--Add-foo` while + * keeping standalone colons compact (`feat:bar` → `feat-bar`) and unrelated + * runs single (`Cost $5, 50% off` → `Cost-5-50-off`). + * - Leading and trailing hyphens are stripped. + * + * Returns the empty string when the input is null/undefined/empty or + * slugifies to nothing. + */ +export function slugifyTitle(title: string | null | undefined): string { + if (!title) return ""; + return title + .normalize("NFD") + .replace(/\p{M}/gu, "") + .replace(/[^a-zA-Z0-9_.~]+/g, (run) => + run.includes(":") && /[^:]/.test(run) ? "--" : "-", + ) + .replace(/^-+|-+$/g, ""); +} + +/** + * Build a shareable `posthog://inbox/` URL, optionally with a + * cosmetic slug suffix derived from the title. The slug is purely cosmetic; + * receivers must ignore everything after the UUID. See + * `externalUrlToAppPath` for the corresponding inbound tolerance. + */ +export function inboxReportShareUrl( + reportId: string, + title?: string | null, +): string { + const slug = slugifyTitle(title); + const path = slug ? `/inbox/${reportId}/${slug}` : `/inbox/${reportId}`; + return customSchemeUrl(path); +} + /** * Convert an incoming external URL (custom scheme or universal link) to the * router-relative path expo-router uses. Returns null if the URL doesn't * belong to us. * + * A `posthog://inbox//` link (or the universal-link equivalent) is + * normalized to `/inbox/` — the slug is decorative and the route only + * cares about the UUID. Mirrors the desktop receiver, which also ignores the + * slug. + * * Used by the auth gate to round-trip the originally-requested URL through * the sign-in flow. */ @@ -68,26 +120,41 @@ export function externalUrlToAppPath(url: string): AppPath | null { try { const parsed = new URL(url); + let path: AppPath | null = null; if (parsed.protocol === `${MOBILE_SCHEME}:`) { // posthog://task/abc → /task/abc const host = parsed.hostname; if (!host) return null; const rest = parsed.pathname || ""; const search = parsed.search || ""; - return `/${host}${rest}${search}`; - } - - if ( + path = `/${host}${rest}${search}`; + } else if ( (parsed.protocol === "https:" || parsed.protocol === "http:") && parsed.hostname === UNIVERSAL_LINK_HOST ) { // https://code.posthog.com/task/abc → /task/abc - const path = parsed.pathname || "/"; - return `${path}${parsed.search || ""}`; + const pathname = parsed.pathname || "/"; + path = `${pathname}${parsed.search || ""}`; } - return null; + if (path === null) return null; + return stripInboxSlugSuffix(path); } catch { return null; } } + +/** + * Collapse `/inbox//[?query]` to `/inbox/[?query]`. No-op for + * any path that isn't an inbox-report deep link with a trailing segment. + */ +function stripInboxSlugSuffix(path: AppPath): AppPath { + const queryStart = path.indexOf("?"); + const pathOnly = queryStart === -1 ? path : path.slice(0, queryStart); + const query = queryStart === -1 ? "" : path.slice(queryStart); + const segments = pathOnly.split("/").filter(Boolean); + if (segments.length >= 3 && segments[0] === "inbox") { + return `/inbox/${segments[1]}${query}`; + } + return path; +} From 51f9c301feae7088861657fbec34f8dbd0764b3d Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 29 May 2026 12:08:37 +0100 Subject: [PATCH 2/2] test(mobile): parameterise deep-links tests with it.each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Greptile review feedback on #2432 — convert multi-expect blocks in `apps/mobile/src/lib/deep-links.test.ts` to `it.each` so each input/expected pair has its own test name and failure line. Generated-By: PostHog Code Task-Id: a37e6f21-3453-4ac4-9853-d61de0ea362a --- apps/mobile/src/lib/deep-links.test.ts | 54 +++++++++++++++----------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/lib/deep-links.test.ts b/apps/mobile/src/lib/deep-links.test.ts index fd86323cd..d2455383d 100644 --- a/apps/mobile/src/lib/deep-links.test.ts +++ b/apps/mobile/src/lib/deep-links.test.ts @@ -6,12 +6,14 @@ import { } from "./deep-links"; describe("slugifyTitle", () => { - it("returns an empty string when the title is missing or blank", () => { - expect(slugifyTitle(null)).toBe(""); - expect(slugifyTitle(undefined)).toBe(""); - expect(slugifyTitle("")).toBe(""); - expect(slugifyTitle(" ")).toBe(""); - expect(slugifyTitle(":::")).toBe(""); + it.each([ + ["null", null], + ["undefined", undefined], + ["empty string", ""], + ["whitespace only", " "], + ["unsafe-only characters", ":::"], + ])("returns an empty string when the title is %s", (_label, input) => { + expect(slugifyTitle(input)).toBe(""); }); it("emits `--` for runs that mix a colon with other unsafe chars", () => { @@ -44,28 +46,34 @@ describe("slugifyTitle", () => { }); describe("inboxReportShareUrl", () => { - it("returns just the UUID when no title is given", () => { + it("returns just the UUID when no title argument is passed", () => { expect(inboxReportShareUrl("abc-123")).toBe("posthog://inbox/abc-123"); - expect(inboxReportShareUrl("abc-123", null)).toBe( - "posthog://inbox/abc-123", - ); - expect(inboxReportShareUrl("abc-123", undefined)).toBe( - "posthog://inbox/abc-123", - ); - expect(inboxReportShareUrl("abc-123", "")).toBe("posthog://inbox/abc-123"); }); + it.each([ + ["null", null], + ["undefined", undefined], + ["empty string", ""], + ])( + "returns just the UUID when the title is %s", + (_label, input: string | null | undefined) => { + expect(inboxReportShareUrl("abc-123", input)).toBe( + "posthog://inbox/abc-123", + ); + }, + ); + it("appends a slug derived from the title", () => { expect(inboxReportShareUrl("abc-123", "Hello World")).toBe( "posthog://inbox/abc-123/Hello-World", ); }); - it("omits the slug when the title slugifies to empty", () => { - expect(inboxReportShareUrl("abc-123", ":::")).toBe( - "posthog://inbox/abc-123", - ); - expect(inboxReportShareUrl("abc-123", " ")).toBe( + it.each([ + ["unsafe-only characters", ":::"], + ["whitespace only", " "], + ])("omits the slug when the title is %s", (_label, input) => { + expect(inboxReportShareUrl("abc-123", input)).toBe( "posthog://inbox/abc-123", ); }); @@ -122,8 +130,10 @@ describe("externalUrlToAppPath", () => { ); }); - it("ignores URLs from unrelated hosts/schemes", () => { - expect(externalUrlToAppPath("https://example.com/inbox/x")).toBe(null); - expect(externalUrlToAppPath("not a url")).toBe(null); + it.each([ + ["unrelated https host", "https://example.com/inbox/x"], + ["unparseable string", "not a url"], + ])("ignores URLs from %s", (_label, input) => { + expect(externalUrlToAppPath(input)).toBe(null); }); });