Open History to see the last commit
1import * as Schema from "effect/Schema";
2import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
3import { useAtomMount, useAtomSet, useAtomValue } from "@effect/atom-react";
4import { useRef, useState } from "react";
5import { Button } from "~/components/ui/button";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
7import { AndroidFoldError, setAndroidFold, type AndroidFoldPosture } from "./deviceFold";
8import { androidFoldReadAtom, androidFoldViewAtom, foldAngle, NOOP_ATOM } from "./deviceHubAtoms";
9import { DeviceDuoGlyph } from "./DeviceDuoGlyph";
10
11const isAndroidFoldError = Schema.is(AndroidFoldError);
12
13export function DeviceAndroidFoldControls(props: {
14 readonly access: DeviceHubAccess;
15 readonly deviceKey: string;
16 readonly deviceId: string;
17 readonly visible: boolean;
18 readonly enabled: boolean;
19 readonly screenWidth: number | undefined;
20 readonly screenHeight: number | undefined;
21 readonly renewAccess: () => Promise<DeviceHubAccess | null>;
22}) {
23 const [pending, setPending] = useState(false);
24 const [error, setError] = useState<string | null>(null);
25 const busy = useRef(false);
26 const { access, deviceKey, deviceId, visible, enabled, screenWidth, screenHeight } = props;
27 const viewAtom = androidFoldViewAtom(deviceKey);
28 const { fold } = useAtomValue(viewAtom);
29 const setView = useAtomSet(viewAtom);
30 // A change in flight owns the posture; reading meanwhile could show the old one.
31 useAtomMount(
32 visible && enabled && !pending && screenWidth && screenHeight
33 ? androidFoldReadAtom({ deviceKey, access, deviceId, screenWidth, screenHeight })
34 : NOOP_ATOM,
35 );
36
37 if (!fold?.supported || !props.visible) return null;
38
39 const setAngle = (angle: number) => setView((current) => ({ ...current, angle }));
40 const change = (posture: AndroidFoldPosture) => {
41 if (busy.current || !props.enabled) return;
42 busy.current = true;
43 setPending(true);
44 setError(null);
45 setAngle(posture === "closed" ? 0 : 180);
46 const controller = new AbortController();
47 const timeout = window.setTimeout(() => controller.abort(), 12_000);
48 const send = (access: DeviceHubAccess) =>
49 setAndroidFold(access, props.deviceId, posture, controller.signal);
50 void send(props.access)
51 .catch(async (cause: unknown) => {
52 // Hub tickets expire after a few minutes, while the panel stays open much longer.
53 if (!(isAndroidFoldError(cause) && cause.status === 401)) throw cause;
54 const renewed = await props.renewAccess();
55 if (!renewed || controller.signal.aborted) throw cause;
56 return send(renewed);
57 })
58 .then((next) => setView({ fold: next, angle: foldAngle(next) }))
59 .catch((cause: unknown) => {
60 setAngle(foldAngle(fold));
61 setError(
62 controller.signal.aborted
63 ? "Fold command timed out."
64 : cause instanceof Error
65 ? cause.message
66 : "Could not change fold posture.",
67 );
68 })
69 .finally(() => {
70 window.clearTimeout(timeout);
71 busy.current = false;
72 setPending(false);
73 });
74 };
75
76 return (
77 <div aria-label="Android fold controls" className="flex flex-col items-center gap-2">
78 <div className="pointer-events-auto flex shrink-0 flex-col items-center gap-1 rounded-full border border-border/50 bg-background/80 p-1 shadow-sm">
79 <Tooltip>
80 <TooltipTrigger
81 render={
82 <Button
83 size="icon"
84 variant={fold.posture === "closed" ? "secondary" : "ghost"}
85 aria-label="Fold device"
86 aria-pressed={fold.posture === "closed"}
87 disabled={pending || !props.enabled}
88 onClick={() => change("closed")}
89 />
90 }
91 >
92 <DeviceDuoGlyph pose="closed" />
93 </TooltipTrigger>
94 <TooltipPopup side="left">Fold device</TooltipPopup>
95 </Tooltip>
96 <Tooltip>
97 <TooltipTrigger
98 render={
99 <Button
100 size="icon"
101 variant={fold.posture === "opened" ? "secondary" : "ghost"}
102 aria-label="Unfold device"
103 aria-pressed={fold.posture === "opened"}
104 disabled={pending || !props.enabled}
105 onClick={() => change("opened")}
106 />
107 }
108 >
109 <DeviceDuoGlyph pose="open" />
110 </TooltipTrigger>
111 <TooltipPopup side="left">Unfold device</TooltipPopup>
112 </Tooltip>
113 </div>
114 {error ? (
115 <Tooltip>
116 <TooltipTrigger
117 render={
118 <span
119 tabIndex={0}
120 role="alert"
121 aria-label={error}
122 className="pointer-events-auto text-xs text-destructive"
123 >
124 !
125 </span>
126 }
127 />
128 <TooltipPopup side="left">{error}</TooltipPopup>
129 </Tooltip>
130 ) : null}
131 </div>
132 );
133}