diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 469a4c983a9..ce762ab184e 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -1,12 +1,13 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { Markdown, type CustomRenderers, type NodeStyleOverrides, type PartialMarkdownTheme, } from "react-native-nitro-markdown"; -import { Linking, ScrollView, Text as NativeText, View } from "react-native"; +import { ScrollView, Text as NativeText, View } from "react-native"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThemeColor } from "../../lib/useThemeColor"; import { @@ -38,7 +39,7 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { { if (href) { - void Linking.openURL(href); + void tryOpenExternalUrl(href, "markdown-link"); } }} style={{ @@ -143,12 +144,19 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { export function FileMarkdownPreview(props: { readonly markdown: string }) { const styles = useMarkdownPreviewStyles(); + const onLinkPress = useCallback((href: string) => { + void tryOpenExternalUrl(href, "markdown-link"); + }, []); return ( {hasNativeSelectableMarkdownText() ? ( - + ) : ( { if (typeof props.externalPreviewUri === "string") { - void Linking.openURL(props.externalPreviewUri); + void tryOpenExternalUrl(props.externalPreviewUri, "file-preview"); } }} > diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index bda966cf16e..93d929e5961 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -1,11 +1,12 @@ import * as Haptics from "expo-haptics"; import { SymbolView } from "expo-symbols"; import { useCallback, useEffect, useRef } from "react"; -import { ActivityIndicator, Linking, Pressable, View } from "react-native"; +import { ActivityIndicator, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { useThemeColor } from "../../lib/useThemeColor"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; @@ -30,7 +31,7 @@ export function GitActionProgressOverlay(props: { const handlePress = useCallback(() => { if (progress.prUrl) { - void Linking.openURL(progress.prUrl); + void tryOpenExternalUrl(progress.prUrl, "pull-request"); return; } if (progress.phase === "success" || progress.phase === "error") { diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 59b9af442ef..d5920a72411 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -13,8 +13,9 @@ import { import { useLocalSearchParams, useRouter } from "expo-router"; import Stack from "expo-router/stack"; import { useCallback, useMemo } from "react"; -import { Alert, Linking } from "react-native"; +import { Alert } from "react-native"; import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { basename, getTerminalStatusLabel, @@ -125,13 +126,8 @@ export function ThreadGitControls(props: { Alert.alert("No open PR", "This branch does not have an open pull request."); return; } - try { - await Linking.openURL(prUrl); - } catch (error) { - Alert.alert( - "Unable to open PR", - error instanceof Error ? error.message : "An error occurred.", - ); + if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) { + Alert.alert("Unable to open PR", "The pull request could not be opened."); } }, [gitStatus]); diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index d6255a296b7..0db7876a774 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -8,11 +8,12 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useLocalSearchParams, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; import { useCallback, useEffect, useMemo } from "react"; -import { Alert, Linking, Pressable, ScrollView, View } from "react-native"; +import { Alert, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text } from "../../../components/AppText"; +import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; import { buildThreadReviewRoutePath } from "../../../lib/routes"; import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; @@ -83,13 +84,8 @@ export function GitOverviewSheet() { Alert.alert("No open PR", "This branch does not have an open pull request."); return; } - try { - await Linking.openURL(prUrl); - } catch (error) { - Alert.alert( - "Unable to open PR", - error instanceof Error ? error.message : "An error occurred.", - ); + if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) { + Alert.alert("Unable to open PR", "The pull request could not be opened."); } }, [gitStatus.data]); diff --git a/apps/mobile/src/lib/openExternalUrl.test.ts b/apps/mobile/src/lib/openExternalUrl.test.ts new file mode 100644 index 00000000000..5a69cbdd43b --- /dev/null +++ b/apps/mobile/src/lib/openExternalUrl.test.ts @@ -0,0 +1,58 @@ +import { Linking } from "react-native"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { tryOpenExternalUrl } from "./openExternalUrl"; + +vi.mock("react-native", () => ({ + Linking: { openURL: vi.fn() }, +})); + +const openURL = vi.mocked(Linking.openURL); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("tryOpenExternalUrl", () => { + it("opens supported URLs", async () => { + openURL.mockResolvedValue(undefined); + + await expect( + tryOpenExternalUrl("https://github.com/pingdotgg/t3code", "pull-request"), + ).resolves.toBe(true); + }); + + it("logs stable URL context without exposing the opening failure", async () => { + const cause = new Error("browser-unavailable-secret-sentinel"); + openURL.mockRejectedValue(cause); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect( + tryOpenExternalUrl("https://github.com/pingdotgg/t3code/pull/1?token=secret", "pull-request"), + ).resolves.toBe(false); + + expect(consoleError).toHaveBeenCalledTimes(1); + const [message, attributes] = consoleError.mock.calls[0] ?? []; + expect(message).toBe("Failed to open pull-request URL with the https scheme."); + expect(attributes).toEqual( + expect.objectContaining({ + _tag: "ExternalUrlOpenError", + target: "pull-request", + scheme: "https", + host: "github.com", + stack: expect.stringContaining("ExternalUrlOpenError"), + }), + ); + expect(attributes).not.toHaveProperty("url"); + expect(attributes).not.toHaveProperty("cause"); + const diagnosticText = [message, ...Object.values(attributes as Record)] + .map(String) + .join("\n"); + expect(diagnosticText).not.toContain("token=secret"); + expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel"); + }); +}); diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts new file mode 100644 index 00000000000..10e6378bc00 --- /dev/null +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -0,0 +1,51 @@ +import * as Schema from "effect/Schema"; +import { Linking } from "react-native"; + +const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]); + +export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; + +export class ExternalUrlOpenError extends Schema.TaggedErrorClass()( + "ExternalUrlOpenError", + { + target: ExternalUrlTarget, + scheme: Schema.String, + host: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to open ${this.target} URL with the ${this.scheme} scheme.`; + } +} + +function externalUrlMetadata(url: string): { readonly scheme: string; readonly host?: string } { + try { + const parsed = new URL(url); + return { + scheme: parsed.protocol.replace(/:$/, "") || "unknown", + host: parsed.hostname || undefined, + }; + } catch { + return { + scheme: /^([a-z][a-z\d+.-]*):/i.exec(url)?.[1]?.toLowerCase() ?? "unknown", + }; + } +} + +export async function tryOpenExternalUrl(url: string, target: ExternalUrlTarget): Promise { + try { + await Linking.openURL(url); + return true; + } catch (cause) { + const error = new ExternalUrlOpenError({ target, ...externalUrlMetadata(url), cause }); + console.error(error.message, { + _tag: error._tag, + target: error.target, + scheme: error.scheme, + host: error.host, + stack: error.stack, + }); + return false; + } +}