From 2e9191fcad4af53d8673442403925f0f3ed09924 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Thu, 5 Feb 2026 13:09:26 +0100 Subject: [PATCH] fix: open Join button in default browser instead of in-app browser (#27455) * fix: open Join button in default browser instead of in-app browser Co-Authored-By: peer@cal.com * feat(companion): open join in default browser (#27655) --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> --- .../(tabs)/(bookings)/booking-detail.ios.tsx | 27 ++------- .../app/(tabs)/(bookings)/booking-detail.tsx | 26 ++------- .../screens/BookingDetailScreen.tsx | 17 +----- companion/utils/booking.ts | 42 ++++++++++++++ companion/utils/browser.ts | 58 ++++++++++++++++--- companion/utils/index.ts | 1 + 6 files changed, 103 insertions(+), 68 deletions(-) create mode 100644 companion/utils/booking.ts diff --git a/companion/app/(tabs)/(bookings)/booking-detail.ios.tsx b/companion/app/(tabs)/(bookings)/booking-detail.ios.tsx index 2155ac5c1b..bf1b60c219 100644 --- a/companion/app/(tabs)/(bookings)/booking-detail.ios.tsx +++ b/companion/app/(tabs)/(bookings)/booking-detail.ios.tsx @@ -1,15 +1,15 @@ import * as Clipboard from "expo-clipboard"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; import { Stack, useLocalSearchParams } from "expo-router"; import { useCallback, useMemo, useRef } from "react"; import { useColorScheme } from "react-native"; import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen"; import { useAuth } from "@/contexts/AuthContext"; import { useBookingByUid } from "@/hooks/useBookings"; -import type { Booking } from "@/services/calcom"; import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts"; import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions"; -import { openInAppBrowser } from "@/utils/browser"; -import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { getMeetingUrl } from "@/utils/booking"; +import { openInDefaultBrowser } from "@/utils/browser"; // Empty actions result for when no booking is loaded const EMPTY_ACTIONS: BookingActionsResult = { @@ -42,25 +42,6 @@ const getMonthName = (dateString: string | undefined): string => { return date.toLocaleDateString("en-US", { month: "long" }); }; -// Get meeting URL from booking -const getMeetingUrl = (booking: Booking | null): string | null => { - if (!booking) return null; - - // Check metadata for videoCallUrl first - const videoCallUrl = booking.responses?.videoCallUrl; - if (typeof videoCallUrl === "string" && videoCallUrl.startsWith("http")) { - return videoCallUrl; - } - - // Check location - const location = booking.location; - if (typeof location === "string" && location.startsWith("http")) { - return location; - } - - return null; -}; - export default function BookingDetailIOS() { const { uid } = useLocalSearchParams<{ uid: string }>(); const { userInfo } = useAuth(); @@ -89,7 +70,7 @@ export default function BookingDetailIOS() { // Handle join meeting const handleJoinMeeting = useCallback(() => { if (meetingUrl) { - openInAppBrowser(meetingUrl, "meeting link"); + openInDefaultBrowser(meetingUrl, "meeting link"); } }, [meetingUrl]); diff --git a/companion/app/(tabs)/(bookings)/booking-detail.tsx b/companion/app/(tabs)/(bookings)/booking-detail.tsx index af965911a3..a83bf31450 100644 --- a/companion/app/(tabs)/(bookings)/booking-detail.tsx +++ b/companion/app/(tabs)/(bookings)/booking-detail.tsx @@ -1,8 +1,8 @@ -import * as Clipboard from "expo-clipboard"; import { Ionicons } from "@expo/vector-icons"; +import * as Clipboard from "expo-clipboard"; import { Stack, useLocalSearchParams, useNavigation, useRouter } from "expo-router"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { Platform, Text, View, useColorScheme } from "react-native"; +import { Platform, Text, useColorScheme, View } from "react-native"; import { AppPressable } from "@/components/AppPressable"; import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper"; import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen"; @@ -16,10 +16,10 @@ import { } from "@/components/ui/dropdown-menu"; import { useAuth } from "@/contexts/AuthContext"; import { useBookingByUid } from "@/hooks/useBookings"; -import type { Booking } from "@/services/calcom"; import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts"; import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions"; -import { openInAppBrowser } from "@/utils/browser"; +import { getMeetingUrl } from "@/utils/booking"; +import { openInDefaultBrowser } from "@/utils/browser"; // Empty actions result for when no booking is loaded const EMPTY_ACTIONS: BookingActionsResult = { @@ -33,22 +33,6 @@ const EMPTY_ACTIONS: BookingActionsResult = { markNoShow: { visible: false, enabled: false }, }; -const getMeetingUrl = (booking: Booking | null): string | null => { - if (!booking) return null; - - const videoCallUrl = booking.responses?.videoCallUrl; - if (typeof videoCallUrl === "string" && videoCallUrl.startsWith("http")) { - return videoCallUrl; - } - - const location = booking.location; - if (typeof location === "string" && location.startsWith("http")) { - return location; - } - - return null; -}; - // Type for action handlers exposed by BookingDetailScreen type ActionHandlers = { openRescheduleModal: () => void; @@ -280,7 +264,7 @@ export default function BookingDetail() { const handleJoinMeeting = useCallback(() => { if (meetingUrl) { - openInAppBrowser(meetingUrl, "meeting link"); + openInDefaultBrowser(meetingUrl, "meeting link"); } }, [meetingUrl]); diff --git a/companion/components/screens/BookingDetailScreen.tsx b/companion/components/screens/BookingDetailScreen.tsx index 6d0cce57e0..2280ff92e2 100644 --- a/companion/components/screens/BookingDetailScreen.tsx +++ b/companion/components/screens/BookingDetailScreen.tsx @@ -43,6 +43,7 @@ import { useCancelBooking } from "@/hooks/useBookings"; import type { Booking } from "@/services/calcom"; import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts"; import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions"; +import { getMeetingUrl } from "@/utils/booking"; import { openInAppBrowser } from "@/utils/browser"; import { getColors } from "@/constants/colors"; @@ -136,22 +137,6 @@ const calculateDuration = (startDateString: string, endDateString: string): numb return Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60)); }; -const getMeetingUrl = (booking: Booking | null): string | null => { - if (!booking) return null; - - const videoCallUrl = booking.responses?.videoCallUrl; - if (typeof videoCallUrl === "string" && videoCallUrl.startsWith("http")) { - return videoCallUrl; - } - - const location = booking.location; - if (typeof location === "string" && location.startsWith("http")) { - return location; - } - - return null; -}; - export interface BookingDetailScreenProps { /** * The booking data to display. When null/undefined, shows loading or error state. diff --git a/companion/utils/booking.ts b/companion/utils/booking.ts new file mode 100644 index 0000000000..0b8d8fc35f --- /dev/null +++ b/companion/utils/booking.ts @@ -0,0 +1,42 @@ +/** + * Booking Utilities + * + * Shared utility functions for working with bookings. + */ + +import type { Booking } from "@/services/calcom"; + +/** + * Extract the meeting URL from a booking. + * + * Checks both the booking's responses.videoCallUrl and location fields + * for a valid HTTP(S) URL that can be used to join the meeting. + * + * @param booking - The booking to extract the meeting URL from + * @returns The meeting URL if found, null otherwise + * + * @example + * ```tsx + * const meetingUrl = getMeetingUrl(booking); + * if (meetingUrl) { + * openInDefaultBrowser(meetingUrl, "meeting link"); + * } + * ``` + */ +export const getMeetingUrl = (booking: Booking | null): string | null => { + if (!booking) return null; + + // Check metadata for videoCallUrl first + const videoCallUrl = booking.responses?.videoCallUrl; + if (typeof videoCallUrl === "string" && videoCallUrl.startsWith("http")) { + return videoCallUrl; + } + + // Check location + const location = booking.location; + if (typeof location === "string" && location.startsWith("http")) { + return location; + } + + return null; +}; diff --git a/companion/utils/browser.ts b/companion/utils/browser.ts index 40b906a100..16d04147b6 100644 --- a/companion/utils/browser.ts +++ b/companion/utils/browser.ts @@ -6,10 +6,31 @@ */ import * as WebBrowser from "expo-web-browser"; -import { Platform } from "react-native"; +import { Linking, Platform } from "react-native"; import { showErrorAlert } from "./alerts"; +/** + * Handle errors from browser functions in a consistent way. + * + * @param error - The error that occurred + * @param functionName - Name of the function for debug logging + * @param fallbackMessage - Optional message to show in error alert (defaults to "link") + */ +const handleBrowserError = ( + error: unknown, + functionName: string, + fallbackMessage?: string +): void => { + console.error(`Failed to open link in ${functionName}`); + if (__DEV__) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.debug(`[${functionName}] failed`, { message, stack, fallbackMessage }); + } + showErrorAlert("Error", `Failed to open ${fallbackMessage || "link"}. Please try again.`); +}; + /** * Configuration options for in-app browser */ @@ -100,12 +121,33 @@ export const openInAppBrowser = async ( await WebBrowser.openBrowserAsync(processedUrl, browserOptions); } catch (error) { - console.error("Failed to open link"); - if (__DEV__) { - const message = error instanceof Error ? error.message : String(error); - const stack = error instanceof Error ? error.stack : undefined; - console.debug("[openInAppBrowser] failed", { message, stack, fallbackMessage }); - } - showErrorAlert("Error", `Failed to open ${fallbackMessage || "link"}. Please try again.`); + handleBrowserError(error, "openInAppBrowser", fallbackMessage); + } +}; + +/** + * Open a URL in the device's default browser (Safari on iOS, Chrome on Android). + * + * Unlike openInAppBrowser, this opens the URL in the actual browser app + * rather than an in-app browser view. This is useful for video meeting links + * where users may want to use their browser's native features or extensions. + * + * @param url - The URL to open + * @param fallbackMessage - Optional message to show in error alert (defaults to "link") + * + * @example + * ```tsx + * // Open a meeting link in the default browser + * await openInDefaultBrowser("https://meet.cal.com/abc123", "meeting link"); + * ``` + */ +export const openInDefaultBrowser = async ( + url: string, + fallbackMessage?: string +): Promise => { + try { + await Linking.openURL(url); + } catch (error) { + handleBrowserError(error, "openInDefaultBrowser", fallbackMessage); } }; diff --git a/companion/utils/index.ts b/companion/utils/index.ts index 62125153fe..273ad3f488 100644 --- a/companion/utils/index.ts +++ b/companion/utils/index.ts @@ -14,6 +14,7 @@ export { setGlobalToastFunction, showErrorAlert, showInfoAlert, showSuccessAlert } from "./alerts"; // Browser utilities export { openInAppBrowser } from "./browser"; +export { openInDefaultBrowser } from "./browser"; // Default location utilities export { type DefaultLocation,