| author | |
| committer | |
| log | d60f5c61013dfda68cbbf79bdac2425b73ddaa05 |
| tree | 09a02fe054eb76c5e5ceaca58aac5db183f8a4ad |
| parent | eacceb9e7b3791a9fa3d2d8dfb212f4b43e3c91d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { useMemo } from "react"; | 1 | import { useCallback, useMemo } from "react"; |
| 2 | import { | 2 | import { |
| 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"; |
| 8 | import { Linking, ScrollView, Text as NativeText, View } from "react-native"; | 8 | import { ScrollView, Text as NativeText, View } from "react-native"; |
| 9 | 9 | ||
| 10 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | ||
| 10 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 11 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 11 | import { useThemeColor } from "../../lib/useThemeColor"; | 12 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 12 | import { | 13 | import { |
| ... | @@ -38,7 +39,7 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { | ... | @@ -38,7 +39,7 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { |
| 38 | <NativeText | 39 | <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 { |
| 143 | 144 | ||
| 144 | export function FileMarkdownPreview(props: { readonly markdown: string }) { | 145 | export 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 | }, []); | ||
| 146 | 150 | ||
| 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 | <Markdown | 161 | <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"; |
| 2 | import { SymbolView } from "expo-symbols"; | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | import { useLocalSearchParams, useRouter } from "expo-router"; | 3 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 4 | import { useCallback, useMemo, useRef, useState } from "react"; | 4 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 5 | import { | 5 | import { 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"; | ||
| 13 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; | 6 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 14 | import { | 7 | import { |
| 15 | EnvironmentId, | 8 | EnvironmentId, |
| ... | @@ -23,6 +16,7 @@ import { CopyTextButton } from "../../components/CopyTextButton"; | ... | @@ -23,6 +16,7 @@ import { CopyTextButton } from "../../components/CopyTextButton"; |
| 23 | import { EmptyState } from "../../components/EmptyState"; | 16 | import { EmptyState } from "../../components/EmptyState"; |
| 24 | import { LoadingScreen } from "../../components/LoadingScreen"; | 17 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| 25 | import { cn } from "../../lib/cn"; | 18 | import { cn } from "../../lib/cn"; |
| 19 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | ||
| 26 | import { buildThreadFilesNavigation } from "../../lib/routes"; | 20 | import { buildThreadFilesNavigation } from "../../lib/routes"; |
| 27 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | 21 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 28 | import { useThemeColor } from "../../lib/useThemeColor"; | 22 | import { 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 @@ |
| 1 | import * as Haptics from "expo-haptics"; | 1 | import * as Haptics from "expo-haptics"; |
| 2 | import { SymbolView } from "expo-symbols"; | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | import { useCallback, useEffect, useRef } from "react"; | 3 | import { useCallback, useEffect, useRef } from "react"; |
| 4 | import { ActivityIndicator, Linking, Pressable, View } from "react-native"; | 4 | import { ActivityIndicator, Pressable, View } from "react-native"; |
| 5 | import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; | 5 | import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; |
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | 7 | ||
| 8 | import { AppText as Text } from "../../components/AppText"; | 8 | import { AppText as Text } from "../../components/AppText"; |
| 9 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | ||
| 9 | import { useThemeColor } from "../../lib/useThemeColor"; | 10 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 10 | import type { GitActionProgress } from "../../state/use-vcs-action-state"; | 11 | import type { GitActionProgress } from "../../state/use-vcs-action-state"; |
| 11 | 12 | ||
| ... | @@ -30,7 +31,7 @@ export function GitActionProgressOverlay(props: { | ... | @@ -30,7 +31,7 @@ export function GitActionProgressOverlay(props: { |
| 30 | 31 | ||
| 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 { |
| 13 | import { useLocalSearchParams, useRouter } from "expo-router"; | 13 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 14 | import Stack from "expo-router/stack"; | 14 | import Stack from "expo-router/stack"; |
| 15 | import { useCallback, useMemo } from "react"; | 15 | import { useCallback, useMemo } from "react"; |
| 16 | import { Alert, Linking } from "react-native"; | 16 | import { Alert } from "react-native"; |
| 17 | import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes"; | 17 | import { buildThreadFilesNavigation, buildThreadReviewRoutePath } from "../../lib/routes"; |
| 18 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | ||
| 18 | import { | 19 | import { |
| 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]); |
| 137 | 133 |
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"; |
| 8 | import { useLocalSearchParams, useRouter } from "expo-router"; | 8 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 9 | import { SymbolView } from "expo-symbols"; | 9 | import { SymbolView } from "expo-symbols"; |
| 10 | import { useCallback, useEffect, useMemo } from "react"; | 10 | import { useCallback, useEffect, useMemo } from "react"; |
| 11 | import { Alert, Linking, Pressable, ScrollView, View } from "react-native"; | 11 | import { Alert, Pressable, ScrollView, View } from "react-native"; |
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | import { useThemeColor } from "../../../lib/useThemeColor"; | 13 | import { useThemeColor } from "../../../lib/useThemeColor"; |
| 14 | 14 | ||
| 15 | import { AppText as Text } from "../../../components/AppText"; | 15 | import { AppText as Text } from "../../../components/AppText"; |
| 16 | import { tryOpenExternalUrl } from "../../../lib/openExternalUrl"; | ||
| 16 | import { buildThreadReviewRoutePath } from "../../../lib/routes"; | 17 | import { buildThreadReviewRoutePath } from "../../../lib/routes"; |
| 17 | import { useEnvironmentQuery } from "../../../state/query"; | 18 | import { useEnvironmentQuery } from "../../../state/query"; |
| 18 | import { useThreadSelection } from "../../../state/use-thread-selection"; | 19 | import { 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]); |
| 95 | 91 |
apps/mobile/src/lib/openExternalUrl.test.ts created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | import { Linking } from "react-native"; | ||
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { tryOpenExternalUrl } from "./openExternalUrl"; | ||
| 5 | |||
| 6 | vi.mock("react-native", () => ({ | ||
| 7 | Linking: { openURL: vi.fn() }, | ||
| 8 | })); | ||
| 9 | |||
| 10 | const openURL = vi.mocked(Linking.openURL); | ||
| 11 | |||
| 12 | beforeEach(() => { | ||
| 13 | vi.clearAllMocks(); | ||
| 14 | }); | ||
| 15 | |||
| 16 | afterEach(() => { | ||
| 17 | vi.restoreAllMocks(); | ||
| 18 | }); | ||
| 19 | |||
| 20 | describe("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 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { Linking } from "react-native"; | ||
| 3 | |||
| 4 | const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]); | ||
| 5 | |||
| 6 | export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; | ||
| 7 | |||
| 8 | export 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 | |||
| 22 | function 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 | |||
| 36 | export 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 | } | ||