1import type { MenuAction } from "@react-native-menu/menu";
2import {
3 DEFAULT_PREVIEW_ZOOM_FACTOR,
4 type EnvironmentId,
5 FILL_PREVIEW_VIEWPORT,
6 PREVIEW_ZOOM_LEVELS,
7 type PreviewAdjustInput,
8 type PreviewSessionSnapshot,
9 type PreviewViewportSetting,
10} from "@t3tools/contracts";
11import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
12import { Alert } from "react-native";
13
14import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
15import { previewEnvironment } from "../../state/preview";
16import { useAtomCommand } from "../../state/use-atom-command";
17
18const APPEARANCES = [
19 { id: "system", title: "System" },
20 { id: "light", title: "Light" },
21 { id: "dark", title: "Dark" },
22] as const;
23
24/** One preset per kind of screen; the web's device toolbar offers the full list. */
25const VIEWPORTS = [
26 { id: "fill", title: "Fit to screen", setting: FILL_PREVIEW_VIEWPORT },
27 ...(["Phone", "Tablet", "Desktop"] as const).flatMap((category) => {
28 const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.category === category);
29 if (!preset) return [];
30 const setting: PreviewViewportSetting = {
31 _tag: "preset",
32 presetId: preset.id,
33 width: preset.width,
34 height: preset.height,
35 };
36 return [{ id: `preset:${preset.id}`, title: `${category} (${preset.label})`, setting }];
37 }),
38];
39
40/**
41 * The browser tab's menu: appearance, zoom, viewport, a hard reload, and its
42 * profile's site data. Each runs through the environment, as on web and
43 * desktop, so every client and agent sees the same tab. None need control.
44 */
45export function BrowserTabMenu({
46 environmentId,
47 tab,
48 disabled,
49}: {
50 readonly environmentId: EnvironmentId;
51 readonly tab: PreviewSessionSnapshot;
52 readonly disabled: boolean;
53}) {
54 const adjust = useAtomCommand(previewEnvironment.adjust, "browser tab change");
55 const resize = useAtomCommand(previewEnvironment.resize, "browser viewport change");
56 const target = { threadId: tab.threadId as PreviewAdjustInput["threadId"], tabId: tab.tabId };
57 const colorScheme = tab.colorScheme ?? "system";
58 const zoomFactor = tab.zoomFactor ?? DEFAULT_PREVIEW_ZOOM_FACTOR;
59 const zoomIndex = PREVIEW_ZOOM_LEVELS.indexOf(zoomFactor);
60 const zoomTo = (step: -1 | 1) =>
61 PREVIEW_ZOOM_LEVELS[
62 Math.min(Math.max((zoomIndex < 0 ? 7 : zoomIndex) + step, 0), PREVIEW_ZOOM_LEVELS.length - 1)
63 ]!;
64 const viewportId =
65 tab.viewport === undefined || tab.viewport._tag === "fill"
66 ? "fill"
67 : tab.viewport._tag === "preset"
68 ? `preset:${tab.viewport.presetId}`
69 : "custom";
70
71 const actions: MenuAction[] = [
72 { id: "hard-reload", title: "Hard reload", image: "arrow.clockwise.circle" },
73 {
74 id: "appearance",
75 title: "Appearance",
76 image: "circle.lefthalf.filled",
77 subactions: APPEARANCES.map((option) => ({
78 id: `appearance:${option.id}`,
79 title: option.title,
80 state: option.id === colorScheme ? "on" : "off",
81 })),
82 },
83 {
84 id: "zoom",
85 title: `Zoom (${Math.round(zoomFactor * 100)}%)`,
86 image: "plus.magnifyingglass",
87 subactions: [
88 { id: "zoom:in", title: "Zoom in", image: "plus.magnifyingglass" },
89 { id: "zoom:out", title: "Zoom out", image: "minus.magnifyingglass" },
90 { id: "zoom:reset", title: "Actual size", image: "1.magnifyingglass" },
91 ],
92 },
93 {
94 id: "viewport",
95 title: "Viewport",
96 image: "rectangle.and.arrow.up.right.and.arrow.down.left",
97 subactions: VIEWPORTS.map((option) => ({
98 id: `viewport:${option.id}`,
99 title: option.title,
100 state: option.id === viewportId ? "on" : "off",
101 })),
102 },
103 {
104 id: "site-data",
105 title: "Site data",
106 image: "trash",
107 subactions: [
108 { id: "clear:cookies", title: "Clear cookies", attributes: { destructive: true } },
109 { id: "clear:cache", title: "Clear cache", attributes: { destructive: true } },
110 ],
111 },
112 ];
113
114 const run = async (change: Omit<PreviewAdjustInput, "threadId" | "tabId">) => {
115 const result = await adjust({ environmentId, input: { ...target, ...change } });
116 if (result._tag === "Failure") Alert.alert("Could not change this browser tab");
117 };
118
119 const onAction = (id: string) => {
120 if (id === "hard-reload") return void run({ hardReload: true });
121 if (id === "zoom:in") return void run({ zoomFactor: zoomTo(1) });
122 if (id === "zoom:out") return void run({ zoomFactor: zoomTo(-1) });
123 if (id === "zoom:reset") return void run({ zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR });
124 if (id === "clear:cookies") return void run({ clear: "cookies" });
125 if (id === "clear:cache") return void run({ clear: "cache" });
126 if (id.startsWith("appearance:")) {
127 const option = APPEARANCES.find((candidate) => `appearance:${candidate.id}` === id);
128 if (option) void run({ colorScheme: option.id });
129 return;
130 }
131 if (id.startsWith("viewport:")) {
132 const option = VIEWPORTS.find((candidate) => `viewport:${candidate.id}` === id);
133 if (!option) return;
134 void resize({ environmentId, input: { ...target, viewport: option.setting } }).then(
135 (result) => {
136 if (result._tag === "Failure") Alert.alert("Could not change the viewport");
137 },
138 );
139 }
140 };
141
142 const pill = (
143 <ControlPill icon="ellipsis" accessibilityLabel="Browser tab options" disabled={disabled} />
144 );
145 if (disabled) return pill;
146 return (
147 <ControlPillMenu
148 accessible
149 accessibilityLabel="Browser tab options"
150 accessibilityRole="button"
151 actions={actions}
152 onPressAction={({ nativeEvent }) => onAction(nativeEvent.event)}
153 >
154 {pill}
155 </ControlPillMenu>
156 );
157}