Skip to content
Draft
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
90 changes: 90 additions & 0 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"appleTeamId": "ADWKXDJV75",
"deploymentTarget": "18.0",
"bundleIdentifier": "com.cal.companion",
"associatedDomains": ["applinks:app.cal.com", "applinks:app.cal.eu"],
"userInterfaceStyle": "automatic",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
Expand All @@ -39,6 +40,95 @@
},
"edgeToEdgeEnabled": true,
"package": "com.calcom.companion",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "app.cal.com",
"path": "/bookings"
},
{
"scheme": "https",
"host": "app.cal.com",
"pathPrefix": "/bookings/"
},
{
"scheme": "https",
"host": "app.cal.com",
"pathPrefix": "/booking/"
},
{
"scheme": "https",
"host": "app.cal.com",
"path": "/event-types"
},
{
"scheme": "https",
"host": "app.cal.com",
"pathPrefix": "/event-types/"
},
{
"scheme": "https",
"host": "app.cal.com",
"path": "/availability"
},
{
"scheme": "https",
"host": "app.cal.com",
"pathPrefix": "/availability/"
},
{
"scheme": "https",
"host": "app.cal.com",
"path": "/more"
},
{
"scheme": "https",
"host": "app.cal.eu",
"path": "/bookings"
},
{
"scheme": "https",
"host": "app.cal.eu",
"pathPrefix": "/bookings/"
},
{
"scheme": "https",
"host": "app.cal.eu",
"pathPrefix": "/booking/"
},
{
"scheme": "https",
"host": "app.cal.eu",
"path": "/event-types"
},
{
"scheme": "https",
"host": "app.cal.eu",
"pathPrefix": "/event-types/"
},
{
"scheme": "https",
"host": "app.cal.eu",
"path": "/availability"
},
{
"scheme": "https",
"host": "app.cal.eu",
"pathPrefix": "/availability/"
},
{
"scheme": "https",
"host": "app.cal.eu",
"path": "/more"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
],
"softwareKeyboardLayoutMode": "pan",
"usesCleartextTraffic": false,
"userInterfaceStyle": "automatic"
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Ionicons } from "@expo/vector-icons";
import { Tabs, useRouter, useSegments, VectorIcon } from "expo-router";
import { Tabs, useGlobalSearchParams, useRouter, useSegments, VectorIcon } from "expo-router";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useEffect, useRef } from "react";
import type { ColorValue, ImageSourcePropType } from "react-native";
Expand All @@ -15,6 +15,7 @@ type VectorIconFamily = {
export default function TabLayout() {
const router = useRouter();
const segments = useSegments();
const { source } = useGlobalSearchParams<{ source?: string }>();
const segmentKey = segments.join("/");
const { preferences, isLoading } = useUserPreferences();
const hasNavigated = useRef(false);
Expand All @@ -39,6 +40,7 @@ export default function TabLayout() {
const decision = getInitialLandingRedirectDecision({
landingPage: preferences.landingPage,
segments: segmentKey ? segmentKey.split("/") : [],
isAppLink: source === "app-link",
});

if (decision === "wait") {
Expand All @@ -59,7 +61,7 @@ export default function TabLayout() {
// biome-ignore lint/suspicious/noExplicitAny: expo-router types don't support dynamic route strings from preferences
router.navigate(route as any);
}, 50);
}, [isLoading, preferences.landingPage, router, segmentKey]);
}, [isLoading, preferences.landingPage, router, segmentKey, source]);

if (Platform.OS === "web") {
return <WebTabs colors={colors} />;
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/app/+native-intent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { getNativeRouteFromAppLink } from "@/utils/app-link-navigation";

export function redirectSystemPath({ path }: { path: string; initial: boolean }): string {
return getNativeRouteFromAppLink(path);
}
7 changes: 4 additions & 3 deletions apps/mobile/scripts/check-no-cal-hostnames.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
# - Lines whose content starts with `//`, `*`, or `/*` are filtered, since
# documentation prose / docstring @example URLs don't cause runtime bugs.
# - `ALLOWLIST` exempts files that legitimately reference the bare hostnames:
# the region helpers themselves, the env template, the hostname match-set
# used for video-call URL detection, and this script.
# the region helpers themselves, native app-link configuration, the env
# template, the hostname match-set used for video-call URL detection, and
# this script.
set -euo pipefail
cd "$(dirname "$0")/.."

PATTERN='\bcal\.(com|eu)\b'
ALLOWLIST='^(\./)?(utils/region\.ts|utils/booking\.ts|\.env\.example|scripts/check-no-cal-hostnames\.sh):'
ALLOWLIST='^(\./)?(utils/region\.ts|utils/cal-app-url\.ts|utils/booking\.ts|app\.json|\.env\.example|scripts/check-no-cal-hostnames\.sh):'

raw=$(rg -n --no-heading "$PATTERN" . 2>/dev/null || true)
hits=$(echo "$raw" | grep -Ev "$ALLOWLIST" | grep -Ev '^[^:]+:[0-9]+:[[:space:]]*(//|\*|/\*)' || true)
Expand Down
48 changes: 48 additions & 0 deletions apps/mobile/utils/app-link-navigation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, test } from "@jest/globals";
import { getNativeRouteFromAppLink } from "@/utils/app-link-navigation";
import { CAL_APP_URLS } from "@/utils/cal-app-url";

describe("getNativeRouteFromAppLink", () => {
const { us: usAppUrl, eu: euAppUrl } = CAL_APP_URLS;

test.each([
[`${usAppUrl}/bookings`, "/(tabs)/(bookings)?source=app-link"],
[`${usAppUrl}/bookings/unconfirmed`, "/(tabs)/(bookings)?filter=unconfirmed&source=app-link"],
[`${usAppUrl}/bookings/recurring`, "/(tabs)/(bookings)?filter=recurring&source=app-link"],
[`${usAppUrl}/bookings/cancelled`, "/(tabs)/(bookings)?filter=cancelled&source=app-link"],
[
`${usAppUrl}/bookings/upcoming?uid=booking-uid`,
"/(tabs)/(bookings)/booking-detail?uid=booking-uid&source=app-link",
],
[
`${usAppUrl}/booking/booking-uid`,
"/(tabs)/(bookings)/booking-detail?uid=booking-uid&source=app-link",
],
[`${usAppUrl}/event-types`, "/(tabs)/(event-types)?source=app-link"],
[
`${usAppUrl}/event-types/123`,
"/(tabs)/(event-types)/event-type-detail?id=123&source=app-link",
],
[`${usAppUrl}/availability`, "/(tabs)/(availability)?source=app-link"],
[
`${usAppUrl}/availability/456`,
"/(tabs)/(availability)/availability-detail?id=456&source=app-link",
],
[`${usAppUrl}/more`, "/(tabs)/(more)?source=app-link"],
[`${euAppUrl}/bookings/past`, "/(tabs)/(bookings)?filter=past&source=app-link"],
])("maps %s to %s", (appLink, nativeRoute) => {
expect(getNativeRouteFromAppLink(appLink)).toBe(nativeRoute);
});

test.each([
`${usAppUrl.replace("://app.", "://")}/peer`,
`${usAppUrl}/apps`,
`${usAppUrl}/bookings/invalid`,
`${usAppUrl}/event-types/not-an-id`,
"https://example.com/bookings",
"calcom://bookings",
"/bookings",
])("leaves unsupported links unchanged: %s", (path) => {
expect(getNativeRouteFromAppLink(path)).toBe(path);
});
});
95 changes: 95 additions & 0 deletions apps/mobile/utils/app-link-navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { CAL_APP_HOSTNAMES } from "@/utils/cal-app-url";

const BOOKING_FILTERS = new Set(["upcoming", "unconfirmed", "recurring", "past", "cancelled"]);
const APP_LINK_SOURCE = "app-link";

function routeWithParams(pathname: string, params: Record<string, string> = {}): string {
const searchParams = new URLSearchParams({ ...params, source: APP_LINK_SOURCE });
return `${pathname}?${searchParams.toString()}`;
}

function isNumericId(value: string | undefined): value is string {
return value !== undefined && /^\d+$/.test(value);
}

function decodePathSegment(segment: string): string | null {
try {
return decodeURIComponent(segment);
} catch {
return null;
}
}

export function getNativeRouteFromAppLink(path: string): string {
let url: URL;

try {
url = new URL(path);
} catch {
return path;
}

if (url.protocol !== "https:" || !CAL_APP_HOSTNAMES.has(url.hostname.toLowerCase())) {
return path;
}

const segments = url.pathname.split("/").filter(Boolean);
const [section, detail, extra] = segments;

if (section === "bookings" && extra === undefined) {
const isBookingsIndex = detail === undefined;
const isBookingFilter = detail !== undefined && BOOKING_FILTERS.has(detail);

if (!isBookingsIndex && !isBookingFilter) {
return path;
}

const uid = url.searchParams.get("uid");
if (uid) {
return routeWithParams("/(tabs)/(bookings)/booking-detail", { uid });
}

return routeWithParams("/(tabs)/(bookings)", detail ? { filter: detail } : {});
}

if (section === "booking" && detail && extra === undefined) {
const uid = decodePathSegment(detail);
if (!uid) {
return path;
}

return routeWithParams("/(tabs)/(bookings)/booking-detail", {
uid,
});
}

if (section === "event-types" && extra === undefined) {
if (detail === undefined) {
return routeWithParams("/(tabs)/(event-types)");
}

if (isNumericId(detail)) {
return routeWithParams("/(tabs)/(event-types)/event-type-detail", { id: detail });
}

return path;
}

if (section === "availability" && extra === undefined) {
if (detail === undefined) {
return routeWithParams("/(tabs)/(availability)");
}

if (isNumericId(detail)) {
return routeWithParams("/(tabs)/(availability)/availability-detail", { id: detail });
}

return path;
}

if (section === "more" && detail === undefined) {
return routeWithParams("/(tabs)/(more)");
}

return path;
}
8 changes: 8 additions & 0 deletions apps/mobile/utils/cal-app-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const CAL_APP_URLS = {
us: "https://app.cal.com",
eu: "https://app.cal.eu",
} as const;

export const CAL_APP_HOSTNAMES: ReadonlySet<string> = new Set(
Object.values(CAL_APP_URLS).map((url) => new URL(url).hostname)
);
10 changes: 10 additions & 0 deletions apps/mobile/utils/landing-page-navigation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ describe("getInitialLandingRedirectDecision", () => {
).toBe("skip");
});

test("skips redirect for app links to the bookings index", () => {
expect(
getInitialLandingRedirectDecision({
landingPage: "event-types",
segments: ["(tabs)", "(bookings)"],
isAppLink: true,
})
).toBe("skip");
});

test("waits while segments are empty or outside the tabs layout", () => {
expect(
getInitialLandingRedirectDecision({
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/utils/landing-page-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type InitialLandingRedirectDecision = "wait" | "skip" | "redirect";
type InitialLandingRedirectDecisionInput = {
landingPage: LandingPage;
segments: readonly string[];
isAppLink?: boolean;
};

const TABS_SEGMENT = "(tabs)";
Expand All @@ -26,11 +27,16 @@ function isDefaultBookingsIndex(segments: readonly string[]): boolean {
export function getInitialLandingRedirectDecision({
landingPage,
segments,
isAppLink = false,
}: InitialLandingRedirectDecisionInput): InitialLandingRedirectDecision {
if (segments.length === 0 || segments[0] !== TABS_SEGMENT) {
return "wait";
}

if (isAppLink) {
return "skip";
}

if (!isTabsRoot(segments) && !isDefaultBookingsIndex(segments)) {
return "skip";
}
Expand Down
17 changes: 9 additions & 8 deletions apps/mobile/utils/region.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@

import { Platform } from "react-native";

import { CAL_APP_URLS } from "./cal-app-url";
import { generalStorage, isChromeStorageAvailable } from "./storage";

/**
* Returns the set of Cal.com app hostnames the app talks to across regions.
* Useful for code that needs to recognize any Cal.com app URL regardless of
* the user's current region.
*/
export { CAL_APP_HOSTNAMES } from "./cal-app-url";

export type CalRegion = "us" | "eu";

const REGION_STORAGE_KEY = "cal_region";
Expand Down Expand Up @@ -123,7 +131,7 @@ function notify(): void {

/** Fully-qualified origin of the Cal.com web app for the current region. */
export function getCalAppUrl(region: CalRegion = currentRegion): string {
return region === "eu" ? "https://app.cal.eu" : "https://app.cal.com";
return CAL_APP_URLS[region];
}

/** Fully-qualified origin of the Cal.com API for the current region. */
Expand Down Expand Up @@ -153,10 +161,3 @@ export function getCalHelpUrl(slug: string): string {
const trimmed = slug.replace(/^\/+/, "");
return `https://cal.com/help/${trimmed}`;
}

/**
* Returns the set of Cal.com app hostnames the app talks to across regions.
* Useful for code (e.g. `appendStandaloneParam`) that needs to recognize any
* Cal.com app URL regardless of the user's current region.
*/
export const CAL_APP_HOSTNAMES: ReadonlySet<string> = new Set(["app.cal.com", "app.cal.eu"]);
Loading