authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:42:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:42:41-07:00
logd60f5c61013dfda68cbbf79bdac2425b73ddaa05
tree09a02fe054eb76c5e5ceaca58aac5db183f8a4ad
parenteacceb9e7b3791a9fa3d2d8dfb212f4b43e3c91d
signature Signed by PGP key B5690EEEBB952194

[codex] Structure mobile external link failures (#3363)

Co-authored-by: codex <codex@users.noreply.github.com>

7 files changed, 135 insertions(+), 31 deletions(-)

apps/mobile/src/features/files/FileMarkdownPreview.tsx+12-4
......@@ -1,12 +1,13 @@
1import { useMemo } from "react";
1import { useCallback, useMemo } from "react";
22import {
33 Markdown,
44 type CustomRenderers,
55 type NodeStyleOverrides,
66 type PartialMarkdownTheme,
77} from "react-native-nitro-markdown";
8import { Linking, ScrollView, Text as NativeText, View } from "react-native";
8import { ScrollView, Text as NativeText, View } from "react-native";
99
10import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
1011import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
1112import { useThemeColor } from "../../lib/useThemeColor";
1213import {
......@@ -38,7 +39,7 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
3839 <NativeText
3940 onPress={() => {
4041 if (href) {
41 void Linking.openURL(href);
42 void tryOpenExternalUrl(href, "markdown-link");
4243 }
4344 }}
4445 style={{
......@@ -143,12 +144,19 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
143144
144145export function FileMarkdownPreview(props: { readonly markdown: string }) {
145146 const styles = useMarkdownPreviewStyles();
147 const onLinkPress = useCallback((href: string) => {
148 void tryOpenExternalUrl(href, "markdown-link");
149 }, []);
146150
147151 return (
148152 <ScrollView className="flex-1 bg-card" contentContainerStyle={{ padding: 18 }}>
149153 <View className="mx-auto w-full max-w-[760px]">
150154 {hasNativeSelectableMarkdownText() ? (
151 <SelectableMarkdownText markdown={props.markdown} textStyle={styles.nativeTextStyle} />
155 <SelectableMarkdownText
156 markdown={props.markdown}
157 onLinkPress={onLinkPress}
158 textStyle={styles.nativeTextStyle}
159 />
152160 ) : (
153161 <Markdown
154162 options={{ gfm: true }}
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+3-9
......@@ -2,14 +2,7 @@ import Stack from "expo-router/stack";
22import { SymbolView } from "expo-symbols";
33import { useLocalSearchParams, useRouter } from "expo-router";
44import { useCallback, useMemo, useRef, useState } from "react";
5import {
6 ActivityIndicator,
7 Linking,
8 Pressable,
9 ScrollView,
10 Text as RNText,
11 View,
12} from "react-native";
5import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native";
136import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
147import {
158 EnvironmentId,
......@@ -23,6 +16,7 @@ import { CopyTextButton } from "../../components/CopyTextButton";
2316import { EmptyState } from "../../components/EmptyState";
2417import { LoadingScreen } from "../../components/LoadingScreen";
2518import { cn } from "../../lib/cn";
19import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
2620import { buildThreadFilesNavigation } from "../../lib/routes";
2721import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
2822import { useThemeColor } from "../../lib/useThemeColor";
......@@ -255,7 +249,7 @@ function FilePreviewHeader(props: {
255249 )}
256250 onPress={() => {
257251 if (typeof props.externalPreviewUri === "string") {
258 void Linking.openURL(props.externalPreviewUri);
252 void tryOpenExternalUrl(props.externalPreviewUri, "file-preview");
259253 }
260254 }}
261255 >
apps/mobile/src/features/threads/GitActionProgressOverlay.tsx+3-2
......@@ -1,11 +1,12 @@
11import * as Haptics from "expo-haptics";
22import { SymbolView } from "expo-symbols";
33import { useCallback, useEffect, useRef } from "react";
4import { ActivityIndicator, Linking, Pressable, View } from "react-native";
4import { ActivityIndicator, Pressable, View } from "react-native";
55import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
66import { useSafeAreaInsets } from "react-native-safe-area-context";
77
88import { AppText as Text } from "../../components/AppText";
9import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
910import { useThemeColor } from "../../lib/useThemeColor";
1011import type { GitActionProgress } from "../../state/use-vcs-action-state";
1112
......@@ -30,7 +31,7 @@ export function GitActionProgressOverlay(props: {
3031
3132 const handlePress = useCallback(() => {
3233 if (progress.prUrl) {
33 void Linking.openURL(progress.prUrl);
34 void tryOpenExternalUrl(progress.prUrl, "pull-request");
3435 return;
3536 }
3637 if (progress.phase === "success" || progress.phase === "error") {
apps/mobile/src/features/threads/ThreadGitControls.tsx+4-8
......@@ -13,8 +13,9 @@ import {
1313import { useLocalSearchParams, useRouter } from "expo-router";
1414import Stack from "expo-router/stack";
1515import { useCallback, useMemo } from "react";
16import { Alert, Linking } from "react-native";
16import { Alert } from "react-native";
1717import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes";
18import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
1819import {
1920 basename,
2021 getTerminalStatusLabel,
......@@ -125,13 +126,8 @@ export function ThreadGitControls(props: {
125126 Alert.alert("No open PR", "This branch does not have an open pull request.");
126127 return;
127128 }
128 try {
129 await Linking.openURL(prUrl);
130 } catch (error) {
131 Alert.alert(
132 "Unable to open PR",
133 error instanceof Error ? error.message : "An error occurred.",
134 );
129 if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) {
130 Alert.alert("Unable to open PR", "The pull request could not be opened.");
135131 }
136132 }, [gitStatus]);
137133
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+4-8
......@@ -8,11 +8,12 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
88import { useLocalSearchParams, useRouter } from "expo-router";
99import { SymbolView } from "expo-symbols";
1010import { useCallback, useEffect, useMemo } from "react";
11import { Alert, Linking, Pressable, ScrollView, View } from "react-native";
11import { Alert, Pressable, ScrollView, View } from "react-native";
1212import { useSafeAreaInsets } from "react-native-safe-area-context";
1313import { useThemeColor } from "../../../lib/useThemeColor";
1414
1515import { AppText as Text } from "../../../components/AppText";
16import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
1617import { buildThreadReviewRoutePath } from "../../../lib/routes";
1718import { useEnvironmentQuery } from "../../../state/query";
1819import { useThreadSelection } from "../../../state/use-thread-selection";
......@@ -83,13 +84,8 @@ export function GitOverviewSheet() {
8384 Alert.alert("No open PR", "This branch does not have an open pull request.");
8485 return;
8586 }
86 try {
87 await Linking.openURL(prUrl);
88 } catch (error) {
89 Alert.alert(
90 "Unable to open PR",
91 error instanceof Error ? error.message : "An error occurred.",
92 );
87 if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) {
88 Alert.alert("Unable to open PR", "The pull request could not be opened.");
9389 }
9490 }, [gitStatus.data]);
9591
apps/mobile/src/lib/openExternalUrl.test.ts created+58
......@@ -0,0 +1,58 @@
1import { Linking } from "react-native";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { tryOpenExternalUrl } from "./openExternalUrl";
5
6vi.mock("react-native", () => ({
7 Linking: { openURL: vi.fn() },
8}));
9
10const openURL = vi.mocked(Linking.openURL);
11
12beforeEach(() => {
13 vi.clearAllMocks();
14});
15
16afterEach(() => {
17 vi.restoreAllMocks();
18});
19
20describe("tryOpenExternalUrl", () => {
21 it("opens supported URLs", async () => {
22 openURL.mockResolvedValue(undefined);
23
24 await expect(
25 tryOpenExternalUrl("https://github.com/pingdotgg/t3code", "pull-request"),
26 ).resolves.toBe(true);
27 });
28
29 it("logs stable URL context without exposing the opening failure", async () => {
30 const cause = new Error("browser-unavailable-secret-sentinel");
31 openURL.mockRejectedValue(cause);
32 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
33
34 await expect(
35 tryOpenExternalUrl("https://github.com/pingdotgg/t3code/pull/1?token=secret", "pull-request"),
36 ).resolves.toBe(false);
37
38 expect(consoleError).toHaveBeenCalledTimes(1);
39 const [message, attributes] = consoleError.mock.calls[0] ?? [];
40 expect(message).toBe("Failed to open pull-request URL with the https scheme.");
41 expect(attributes).toEqual(
42 expect.objectContaining({
43 _tag: "ExternalUrlOpenError",
44 target: "pull-request",
45 scheme: "https",
46 host: "github.com",
47 stack: expect.stringContaining("ExternalUrlOpenError"),
48 }),
49 );
50 expect(attributes).not.toHaveProperty("url");
51 expect(attributes).not.toHaveProperty("cause");
52 const diagnosticText = [message, ...Object.values(attributes as Record<string, unknown>)]
53 .map(String)
54 .join("\n");
55 expect(diagnosticText).not.toContain("token=secret");
56 expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel");
57 });
58});
apps/mobile/src/lib/openExternalUrl.ts created+51
......@@ -0,0 +1,51 @@
1import * as Schema from "effect/Schema";
2import { Linking } from "react-native";
3
4const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]);
5
6export type ExternalUrlTarget = typeof ExternalUrlTarget.Type;
7
8export class ExternalUrlOpenError extends Schema.TaggedErrorClass<ExternalUrlOpenError>()(
9 "ExternalUrlOpenError",
10 {
11 target: ExternalUrlTarget,
12 scheme: Schema.String,
13 host: Schema.optional(Schema.String),
14 cause: Schema.Defect(),
15 },
16) {
17 override get message(): string {
18 return `Failed to open ${this.target} URL with the ${this.scheme} scheme.`;
19 }
20}
21
22function externalUrlMetadata(url: string): { readonly scheme: string; readonly host?: string } {
23 try {
24 const parsed = new URL(url);
25 return {
26 scheme: parsed.protocol.replace(/:$/, "") || "unknown",
27 host: parsed.hostname || undefined,
28 };
29 } catch {
30 return {
31 scheme: /^([a-z][a-z\d+.-]*):/i.exec(url)?.[1]?.toLowerCase() ?? "unknown",
32 };
33 }
34}
35
36export async function tryOpenExternalUrl(url: string, target: ExternalUrlTarget): Promise<boolean> {
37 try {
38 await Linking.openURL(url);
39 return true;
40 } catch (cause) {
41 const error = new ExternalUrlOpenError({ target, ...externalUrlMetadata(url), cause });
42 console.error(error.message, {
43 _tag: error._tag,
44 target: error.target,
45 scheme: error.scheme,
46 host: error.host,
47 stack: error.stack,
48 });
49 return false;
50 }
51}