1/**
2 * Where a link clicked inside a thread should open.
3 *
4 * Settings → Integrations → Browser lets the user choose between the OS
5 * default browser and a tab in the in-app browser. This module turns that
6 * preference plus the click itself into one answer, so chat markdown and the
7 * terminal drawer make the same decision and offer the same escape hatch.
8 *
9 * @module browserLinkTarget
10 */
11import type { BrowserLinkTarget, ScopedThreadRef } from "@t3tools/contracts";
12
13import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings";
14import { isPreviewAvailableFor } from "./previewRuntime";
15
16export interface ResolveLinkTargetInput {
17 readonly url: string;
18 /** Cmd/Ctrl-click always goes to the system browser, whatever the default. */
19 readonly event: { readonly metaKey: boolean; readonly ctrlKey: boolean };
20 readonly preference: BrowserLinkTarget;
21 /** Whether this client has an in-app browser and a thread to open it beside. */
22 readonly canOpenInApp: boolean;
23}
24
25/**
26 * The target a click resolves to. "app" only comes back when the preference
27 * asks for it, the runtime can honour it, the URL is one the in-app browser
28 * can load, and the click carried no modifier — the modifier is the one-gesture
29 * way out when the default is in-app, mirroring how change-request links
30 * already treat it.
31 */
32export function resolveLinkTarget(input: ResolveLinkTargetInput): BrowserLinkTarget {
33 if (input.event.metaKey || input.event.ctrlKey) return "system";
34 if (input.preference !== "app") return "system";
35 if (!input.canOpenInApp) return "system";
36 if (!isWebUrl(input.url)) return "system";
37 return "app";
38}
39
40/**
41 * Only http(s) can load in the in-app browser. Anything else — mailto:,
42 * vscode://, a bare fragment — belongs to the shell whatever the preference.
43 */
44export function isWebUrl(url: string): boolean {
45 try {
46 const { protocol } = new URL(url);
47 return protocol === "http:" || protocol === "https:";
48 } catch {
49 return false;
50 }
51}
52
53/**
54 * The configured default, once client settings have actually loaded. Before
55 * hydration the snapshot is the schema default ("system"), so a link clicked
56 * in the first moments after launch would ignore a persisted "app" — opening
57 * is asynchronous anyway, so waiting costs nothing the user can see.
58 * Read failures reject rather than choosing a browser without the saved preference.
59 */
60export async function resolveBrowserLinkTargetPreference(): Promise<BrowserLinkTarget> {
61 await ensureClientSettingsHydrated();
62 return getClientSettings().browserLinkTarget;
63}
64
65/** Whether this thread's environment has an in-app browser available to this client. */
66export function canOpenLinksInApp(threadRef: ScopedThreadRef | null | undefined): boolean {
67 return threadRef != null && isPreviewAvailableFor(threadRef.environmentId);
68}