Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/<reportId>` 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/<reportId>` and
`posthog://inbox/<reportId>/<slug>` both resolve here. */}
<Stack.Screen
name="inbox/[id]"
name="inbox/[...id]"
options={{ presentation: "modal", headerShown: false }}
/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uuid>/<slug>` 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();
Expand Down Expand Up @@ -455,6 +461,7 @@ export default function ReportDetailScreen() {
<DiscussReportSheet
visible={discussOpen}
reportId={report.id}
reportTitle={report.title}
onClose={() => setDiscussOpen(false)}
onSubmit={handleDiscussSubmit}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,21 @@ 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;
}

export function DiscussReportSheet({
visible,
reportId,
reportTitle,
onClose,
onSubmit,
}: DiscussReportSheetProps) {
Expand All @@ -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,
Expand Down
139 changes: 139 additions & 0 deletions apps/mobile/src/lib/deep-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import {
externalUrlToAppPath,
inboxReportShareUrl,
slugifyTitle,
} from "./deep-links";

describe("slugifyTitle", () => {
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", () => {
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 argument is passed", () => {
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.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",
);
});

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.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);
});
});
83 changes: 75 additions & 8 deletions apps/mobile/src/lib/deep-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* posthog://task/<taskId>
* posthog://task/<taskId>/run/<runId>
* posthog://inbox/<reportId>
* posthog://inbox/<reportId>/<slug> (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
Expand All @@ -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";
Expand Down Expand Up @@ -56,38 +58,103 @@ 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/<reportId>` 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/<id>/<slug>` link (or the universal-link equivalent) is
* normalized to `/inbox/<id>` — 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.
*/
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/<id>/<slug>[?query]` to `/inbox/<id>[?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;
}
Loading