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 @@...@@ -1,12 +1,13 @@
1import { useMemo } from "react";1import { useCallback, useMemo } from "react";
2import {2import {
3 Markdown,3 Markdown,
4 type CustomRenderers,4 type CustomRenderers,
5 type NodeStyleOverrides,5 type NodeStyleOverrides,
6 type PartialMarkdownTheme,6 type PartialMarkdownTheme,
7} from "react-native-nitro-markdown";7} 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";
10import { MOBILE_TYPOGRAPHY } from "../../lib/typography";11import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
11import { useThemeColor } from "../../lib/useThemeColor";12import { useThemeColor } from "../../lib/useThemeColor";
12import {13import {
...@@ -38,7 +39,7 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {...@@ -38,7 +39,7 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
38 <NativeText39 <NativeText
39 onPress={() => {40 onPress={() => {
40 if (href) {41 if (href) {
41 void Linking.openURL(href);42 void tryOpenExternalUrl(href, "markdown-link");
42 }43 }
43 }}44 }}
44 style={{45 style={{
...@@ -143,12 +144,19 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {...@@ -143,12 +144,19 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
143144
144export function FileMarkdownPreview(props: { readonly markdown: string }) {145export function FileMarkdownPreview(props: { readonly markdown: string }) {
145 const styles = useMarkdownPreviewStyles();146 const styles = useMarkdownPreviewStyles();
147 const onLinkPress = useCallback((href: string) => {
148 void tryOpenExternalUrl(href, "markdown-link");
149 }, []);
146150
147 return (151 return (
148 <ScrollView className="flex-1 bg-card" contentContainerStyle={{ padding: 18 }}>152 <ScrollView className="flex-1 bg-card" contentContainerStyle={{ padding: 18 }}>
149 <View className="mx-auto w-full max-w-[760px]">153 <View className="mx-auto w-full max-w-[760px]">
150 {hasNativeSelectableMarkdownText() ? (154 {hasNativeSelectableMarkdownText() ? (
151 <SelectableMarkdownText markdown={props.markdown} textStyle={styles.nativeTextStyle} />155 <SelectableMarkdownText
156 markdown={props.markdown}
157 onLinkPress={onLinkPress}
158 textStyle={styles.nativeTextStyle}
159 />
152 ) : (160 ) : (
153 <Markdown161 <Markdown
154 options={{ gfm: true }}162 options={{ gfm: true }}
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+3-9
...@@ -2,14 +2,7 @@ import Stack from "expo-router/stack";...@@ -2,14 +2,7 @@ import Stack from "expo-router/stack";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { useLocalSearchParams, useRouter } from "expo-router";3import { useLocalSearchParams, useRouter } from "expo-router";
4import { useCallback, useMemo, useRef, useState } from "react";4import { useCallback, useMemo, useRef, useState } from "react";
5import {5import { ActivityIndicator, Pressable, ScrollView, Text as RNText, View } from "react-native";
6 ActivityIndicator,
7 Linking,
8 Pressable,
9 ScrollView,
10 Text as RNText,
11 View,
12} from "react-native";
13import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";6import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
14import {7import {
15 EnvironmentId,8 EnvironmentId,
...@@ -23,6 +16,7 @@ import { CopyTextButton } from "../../components/CopyTextButton";...@@ -23,6 +16,7 @@ import { CopyTextButton } from "../../components/CopyTextButton";
23import { EmptyState } from "../../components/EmptyState";16import { EmptyState } from "../../components/EmptyState";
24import { LoadingScreen } from "../../components/LoadingScreen";17import { LoadingScreen } from "../../components/LoadingScreen";
25import { cn } from "../../lib/cn";18import { cn } from "../../lib/cn";
19import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
26import { buildThreadFilesNavigation } from "../../lib/routes";20import { buildThreadFilesNavigation } from "../../lib/routes";
27import { MOBILE_TYPOGRAPHY } from "../../lib/typography";21import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
28import { useThemeColor } from "../../lib/useThemeColor";22import { useThemeColor } from "../../lib/useThemeColor";
...@@ -255,7 +249,7 @@ function FilePreviewHeader(props: {...@@ -255,7 +249,7 @@ function FilePreviewHeader(props: {
255 )}249 )}
256 onPress={() => {250 onPress={() => {
257 if (typeof props.externalPreviewUri === "string") {251 if (typeof props.externalPreviewUri === "string") {
258 void Linking.openURL(props.externalPreviewUri);252 void tryOpenExternalUrl(props.externalPreviewUri, "file-preview");
259 }253 }
260 }}254 }}
261 >255 >
apps/mobile/src/features/threads/GitActionProgressOverlay.tsx+3-2
...@@ -1,11 +1,12 @@...@@ -1,11 +1,12 @@
1import * as Haptics from "expo-haptics";1import * as Haptics from "expo-haptics";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import { useCallback, useEffect, useRef } from "react";3import { useCallback, useEffect, useRef } from "react";
4import { ActivityIndicator, Linking, Pressable, View } from "react-native";4import { ActivityIndicator, Pressable, View } from "react-native";
5import Animated, { FadeIn, FadeOut } from "react-native-reanimated";5import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
6import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
77
8import { AppText as Text } from "../../components/AppText";8import { AppText as Text } from "../../components/AppText";
9import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
9import { useThemeColor } from "../../lib/useThemeColor";10import { useThemeColor } from "../../lib/useThemeColor";
10import type { GitActionProgress } from "../../state/use-vcs-action-state";11import type { GitActionProgress } from "../../state/use-vcs-action-state";
1112
...@@ -30,7 +31,7 @@ export function GitActionProgressOverlay(props: {...@@ -30,7 +31,7 @@ export function GitActionProgressOverlay(props: {
3031
31 const handlePress = useCallback(() => {32 const handlePress = useCallback(() => {
32 if (progress.prUrl) {33 if (progress.prUrl) {
33 void Linking.openURL(progress.prUrl);34 void tryOpenExternalUrl(progress.prUrl, "pull-request");
34 return;35 return;
35 }36 }
36 if (progress.phase === "success" || progress.phase === "error") {37 if (progress.phase === "success" || progress.phase === "error") {
apps/mobile/src/features/threads/ThreadGitControls.tsx+4-8
...@@ -13,8 +13,9 @@ import {...@@ -13,8 +13,9 @@ import {
13import { useLocalSearchParams, useRouter } from "expo-router";13import { useLocalSearchParams, useRouter } from "expo-router";
14import Stack from "expo-router/stack";14import Stack from "expo-router/stack";
15import { useCallback, useMemo } from "react";15import { useCallback, useMemo } from "react";
16import { Alert, Linking } from "react-native";16import { Alert } from "react-native";
17import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes";17import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes";
18import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
18import {19import {
19 basename,20 basename,
20 getTerminalStatusLabel,21 getTerminalStatusLabel,
...@@ -125,13 +126,8 @@ export function ThreadGitControls(props: {...@@ -125,13 +126,8 @@ export function ThreadGitControls(props: {
125 Alert.alert("No open PR", "This branch does not have an open pull request.");126 Alert.alert("No open PR", "This branch does not have an open pull request.");
126 return;127 return;
127 }128 }
128 try {129 if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) {
129 await Linking.openURL(prUrl);130 Alert.alert("Unable to open PR", "The pull request could not be opened.");
130 } catch (error) {
131 Alert.alert(
132 "Unable to open PR",
133 error instanceof Error ? error.message : "An error occurred.",
134 );
135 }131 }
136 }, [gitStatus]);132 }, [gitStatus]);
137133
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+4-8
...@@ -8,11 +8,12 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts";...@@ -8,11 +8,12 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import { useLocalSearchParams, useRouter } from "expo-router";8import { useLocalSearchParams, useRouter } from "expo-router";
9import { SymbolView } from "expo-symbols";9import { SymbolView } from "expo-symbols";
10import { useCallback, useEffect, useMemo } from "react";10import { useCallback, useEffect, useMemo } from "react";
11import { Alert, Linking, Pressable, ScrollView, View } from "react-native";11import { Alert, Pressable, ScrollView, View } from "react-native";
12import { useSafeAreaInsets } from "react-native-safe-area-context";12import { useSafeAreaInsets } from "react-native-safe-area-context";
13import { useThemeColor } from "../../../lib/useThemeColor";13import { useThemeColor } from "../../../lib/useThemeColor";
1414
15import { AppText as Text } from "../../../components/AppText";15import { AppText as Text } from "../../../components/AppText";
16import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
16import { buildThreadReviewRoutePath } from "../../../lib/routes";17import { buildThreadReviewRoutePath } from "../../../lib/routes";
17import { useEnvironmentQuery } from "../../../state/query";18import { useEnvironmentQuery } from "../../../state/query";
18import { useThreadSelection } from "../../../state/use-thread-selection";19import { useThreadSelection } from "../../../state/use-thread-selection";
...@@ -83,13 +84,8 @@ export function GitOverviewSheet() {...@@ -83,13 +84,8 @@ export function GitOverviewSheet() {
83 Alert.alert("No open PR", "This branch does not have an open pull request.");84 Alert.alert("No open PR", "This branch does not have an open pull request.");
84 return;85 return;
85 }86 }
86 try {87 if (!(await tryOpenExternalUrl(prUrl, "pull-request"))) {
87 await Linking.openURL(prUrl);88 Alert.alert("Unable to open PR", "The pull request could not be opened.");
88 } catch (error) {
89 Alert.alert(
90 "Unable to open PR",
91 error instanceof Error ? error.message : "An error occurred.",
92 );
93 }89 }
94 }, [gitStatus.data]);90 }, [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}