From aa8f9dca29d0eb05bba2cc39a97898b4048ce19e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:13:18 -0700 Subject: [PATCH 1/3] Structure mobile external link failures Co-authored-by: codex --- .../features/files/FileMarkdownPreview.tsx | 5 +- .../features/files/ThreadFilesRouteScreen.tsx | 12 ++--- .../threads/GitActionProgressOverlay.tsx | 5 +- .../features/threads/ThreadGitControls.tsx | 12 ++--- .../features/threads/git/GitOverviewSheet.tsx | 12 ++--- apps/mobile/src/lib/openExternalUrl.test.ts | 51 +++++++++++++++++++ apps/mobile/src/lib/openExternalUrl.ts | 44 ++++++++++++++++ 7 files changed, 112 insertions(+), 29 deletions(-) create mode 100644 apps/mobile/src/lib/openExternalUrl.test.ts create mode 100644 apps/mobile/src/lib/openExternalUrl.ts diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 469a4c983a9..0605efd322f 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -5,8 +5,9 @@ import { 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={{ diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 5cd7fe9d02b..fba032c0369 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -2,14 +2,7 @@ import Stack from "expo-router/stack"; import { SymbolView } from "expo-symbols"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useMemo, useRef, useState } from "react"; -import { - ActivityIndicator, - Linking, - Pressable, - ScrollView, - Text as RNText, - View, -} from "react-native"; +import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { EnvironmentId, @@ -23,6 +16,7 @@ import { CopyTextButton } from "../../components/CopyTextButton"; import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; import { cn } from "../../lib/cn"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { buildThreadFilesNavigation } from "../../lib/routes"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -255,7 +249,7 @@ function FilePreviewHeader(props: { )} onPress={() => { 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..d329ff7878d --- /dev/null +++ b/apps/mobile/src/lib/openExternalUrl.test.ts @@ -0,0 +1,51 @@ +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 with the exact opening failure", async () => { + const cause = new Error("browser unavailable"); + 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).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "ExternalUrlOpenError", + target: "pull-request", + scheme: "https", + host: "github.com", + cause, + }), + ); + const loggedError = consoleError.mock.calls[0]?.[0]; + expect(loggedError).not.toHaveProperty("url"); + expect(JSON.stringify(loggedError)).not.toContain("token=secret"); + }); +}); diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts new file mode 100644 index 00000000000..72e25e34341 --- /dev/null +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -0,0 +1,44 @@ +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) { + console.error(new ExternalUrlOpenError({ target, ...externalUrlMetadata(url), cause })); + return false; + } +} From bfd03e201127b47c7275ff41e976984137e2f502 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:26:09 -0700 Subject: [PATCH 2/3] fix: route native markdown links through boundary Co-authored-by: codex --- .../mobile/src/features/files/FileMarkdownPreview.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 0605efd322f..ce762ab184e 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { Markdown, type CustomRenderers, @@ -144,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() ? ( - + ) : ( Date: Sat, 20 Jun 2026 10:11:04 -0700 Subject: [PATCH 3/3] fix(mobile): redact external URL logging Co-authored-by: codex --- apps/mobile/src/lib/openExternalUrl.test.ts | 21 ++++++++++++++------- apps/mobile/src/lib/openExternalUrl.ts | 9 ++++++++- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/openExternalUrl.test.ts b/apps/mobile/src/lib/openExternalUrl.test.ts index d329ff7878d..5a69cbdd43b 100644 --- a/apps/mobile/src/lib/openExternalUrl.test.ts +++ b/apps/mobile/src/lib/openExternalUrl.test.ts @@ -26,8 +26,8 @@ describe("tryOpenExternalUrl", () => { ).resolves.toBe(true); }); - it("logs stable URL context with the exact opening failure", async () => { - const cause = new Error("browser unavailable"); + 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); @@ -35,17 +35,24 @@ describe("tryOpenExternalUrl", () => { tryOpenExternalUrl("https://github.com/pingdotgg/t3code/pull/1?token=secret", "pull-request"), ).resolves.toBe(false); - expect(consoleError).toHaveBeenCalledWith( + 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", - cause, + stack: expect.stringContaining("ExternalUrlOpenError"), }), ); - const loggedError = consoleError.mock.calls[0]?.[0]; - expect(loggedError).not.toHaveProperty("url"); - expect(JSON.stringify(loggedError)).not.toContain("token=secret"); + 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 index 72e25e34341..10e6378bc00 100644 --- a/apps/mobile/src/lib/openExternalUrl.ts +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -38,7 +38,14 @@ export async function tryOpenExternalUrl(url: string, target: ExternalUrlTarget) await Linking.openURL(url); return true; } catch (cause) { - console.error(new ExternalUrlOpenError({ target, ...externalUrlMetadata(url), 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; } }