1import type { ContextMenuItem } from "@t3tools/contracts";
2
3export type ExternalLinkContextMenuAction =
4 | "open-in-preview"
5 | "open-external"
6 | "copy-link"
7 | "link-to-thread"
8 | "unlink-from-thread";
9
10export type ExternalLinkContextMenuFailureOperation =
11 | "show-link-context-menu"
12 | "open-link-in-preview"
13 | "open-link-external"
14 | "copy-link"
15 | "link-pull-request-to-thread"
16 | "unlink-pull-request-from-thread";
17
18const FAILURE_OPERATION_BY_ACTION = {
19 "open-in-preview": "open-link-in-preview",
20 "open-external": "open-link-external",
21 "copy-link": "copy-link",
22 "link-to-thread": "link-pull-request-to-thread",
23 "unlink-from-thread": "unlink-pull-request-from-thread",
24} as const satisfies Record<ExternalLinkContextMenuAction, ExternalLinkContextMenuFailureOperation>;
25
26const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [
27 { id: "open-in-preview", label: "Open in integrated browser" },
28 { id: "open-external", label: "Open in system browser" },
29 { id: "copy-link", label: "Copy Link" },
30] as const satisfies readonly ContextMenuItem<ExternalLinkContextMenuAction>[];
31
32/**
33 * The integrated browser is not always there to offer — it needs a thread to open beside and a
34 * runtime that can show it — but the other two answers hold wherever a link does. Dropping the
35 * whole menu with the one item that cannot be honoured is what left a right-click on a link
36 * showing the platform's cut-and-paste menu instead of a way to copy the link.
37 */
38function externalLinkContextMenuItems(options: {
39 readonly canOpenInPreview: boolean;
40 readonly threadLinkAction?: "link-to-thread" | "unlink-from-thread" | undefined;
41}): readonly ContextMenuItem<ExternalLinkContextMenuAction>[] {
42 const items = options.canOpenInPreview
43 ? EXTERNAL_LINK_CONTEXT_MENU_ITEMS
44 : EXTERNAL_LINK_CONTEXT_MENU_ITEMS.filter((item) => item.id !== "open-in-preview");
45 if (options.threadLinkAction === undefined) return items;
46 return [
47 {
48 id: options.threadLinkAction,
49 label:
50 options.threadLinkAction === "link-to-thread" ? "Link to thread" : "Unlink from thread",
51 },
52 ...items,
53 ];
54}
55
56interface ShowExternalLinkContextMenuOptions {
57 readonly href: string;
58 readonly position: { readonly x: number; readonly y: number };
59 /** Absent means yes, which is what every caller before the browser could be missing meant. */
60 readonly canOpenInPreview?: boolean;
61 readonly threadLinkAction?: "link-to-thread" | "unlink-from-thread" | undefined;
62 readonly showContextMenu: (
63 items: readonly ContextMenuItem<ExternalLinkContextMenuAction>[],
64 position: { readonly x: number; readonly y: number },
65 ) => Promise<ExternalLinkContextMenuAction | null>;
66 readonly openInPreview: (href: string) => Promise<void>;
67 readonly openExternal: (href: string) => Promise<void>;
68 readonly copyLink: (href: string) => Promise<unknown>;
69 readonly updateThreadLink?: (href: string, linked: boolean) => Promise<void>;
70 readonly reportFailure: (
71 operation: ExternalLinkContextMenuFailureOperation,
72 cause: unknown,
73 ) => void;
74}
75
76function resolveExternalWebLink(href: string | null | undefined): URL | null {
77 if (!href) return null;
78 try {
79 const url = new URL(href.startsWith("//") ? `https:${href}` : href);
80 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
81 return url;
82 } catch {
83 return null;
84 }
85}
86
87export function resolveExternalWebLinkHref(href: string | null | undefined): string | null {
88 return resolveExternalWebLink(href)?.href ?? null;
89}
90
91export function resolveExternalWebLinkHost(href: string | undefined): string | null {
92 return resolveExternalWebLink(href)?.hostname || null;
93}
94
95export async function showExternalLinkContextMenu({
96 href,
97 position,
98 canOpenInPreview = true,
99 threadLinkAction,
100 showContextMenu,
101 openInPreview,
102 openExternal,
103 copyLink,
104 updateThreadLink,
105 reportFailure,
106}: ShowExternalLinkContextMenuOptions): Promise<void> {
107 let action: ExternalLinkContextMenuAction | null;
108 try {
109 action = await showContextMenu(
110 externalLinkContextMenuItems({ canOpenInPreview, threadLinkAction }),
111 position,
112 );
113 } catch (cause) {
114 reportFailure("show-link-context-menu", cause);
115 return;
116 }
117
118 try {
119 if (action === "open-in-preview") {
120 await openInPreview(href);
121 } else if (action === "open-external") {
122 await openExternal(href);
123 } else if (action === "copy-link") {
124 await copyLink(href);
125 } else if (action === "link-to-thread" || action === "unlink-from-thread") {
126 await updateThreadLink?.(href, action === "link-to-thread");
127 }
128 } catch (cause) {
129 if (action) reportFailure(FAILURE_OPERATION_BY_ACTION[action], cause);
130 }
131}