Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/mobile/src/features/files/FileMarkdownPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -38,7 +39,7 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
<NativeText
onPress={() => {
if (href) {
void Linking.openURL(href);
void tryOpenExternalUrl(href, "markdown-link");
Comment thread
juliusmarminge marked this conversation as resolved.
}
}}
style={{
Expand Down
12 changes: 3 additions & 9 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -255,7 +249,7 @@ function FilePreviewHeader(props: {
)}
onPress={() => {
if (typeof props.externalPreviewUri === "string") {
void Linking.openURL(props.externalPreviewUri);
void tryOpenExternalUrl(props.externalPreviewUri, "file-preview");
}
}}
>
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/features/threads/GitActionProgressOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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") {
Expand Down
12 changes: 4 additions & 8 deletions apps/mobile/src/features/threads/ThreadGitControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);

Expand Down
12 changes: 4 additions & 8 deletions apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]);

Expand Down
51 changes: 51 additions & 0 deletions apps/mobile/src/lib/openExternalUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
44 changes: 44 additions & 0 deletions apps/mobile/src/lib/openExternalUrl.ts
Original file line number Diff line number Diff line change
@@ -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>()(
"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<boolean> {
try {
await Linking.openURL(url);
return true;
} catch (cause) {
console.error(new ExternalUrlOpenError({ target, ...externalUrlMetadata(url), cause }));
return false;
}
}
Loading