Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 12 additions & 4 deletions apps/mobile/src/features/files/FileMarkdownPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import {
Markdown,
type CustomRenderers,
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 Expand Up @@ -143,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 (
<ScrollView className="flex-1 bg-card" contentContainerStyle={{ padding: 18 }}>
<View className="mx-auto w-full max-w-[760px]">
{hasNativeSelectableMarkdownText() ? (
<SelectableMarkdownText markdown={props.markdown} textStyle={styles.nativeTextStyle} />
<SelectableMarkdownText
markdown={props.markdown}
onLinkPress={onLinkPress}
textStyle={styles.nativeTextStyle}
/>
) : (
<Markdown
options={{ gfm: true }}
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
58 changes: 58 additions & 0 deletions apps/mobile/src/lib/openExternalUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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 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);

await expect(
tryOpenExternalUrl("https://github.com/pingdotgg/t3code/pull/1?token=secret", "pull-request"),
).resolves.toBe(false);

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",
stack: expect.stringContaining("ExternalUrlOpenError"),
}),
);
expect(attributes).not.toHaveProperty("url");
expect(attributes).not.toHaveProperty("cause");
const diagnosticText = [message, ...Object.values(attributes as Record<string, unknown>)]
.map(String)
.join("\n");
expect(diagnosticText).not.toContain("token=secret");
expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel");
});
});
51 changes: 51 additions & 0 deletions apps/mobile/src/lib/openExternalUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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) {
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;
}
}
Loading