Skip to content
Source
Sign inSign up
Open History to see the last commit

UsageLimitsSection.tsx

359 lines12.5 KB

1import * as Dialog from "../../components/ui/Dialog";
2import { RegistryContext, useAtomMount, useAtomValue } from "@effect/atom-react";
3import { normalizeProviderAccentColor } from "@t3tools/client-runtime/state/provider-instance-display";
4import {
5 AuthProvidersManageScope,
6 type EnvironmentId,
7 type ProviderConsumeResetCreditOutcome,
8 type ProviderConsumeResetCreditInput,
9 type ServerProvider,
10 type ServerProviderResetCredits,
11 type ServerProviderUsageWindow,
12} from "@t3tools/contracts";
13import {
14 type ResetCreditChoice,
15 resetCreditChoices,
16 formatResetsIn,
17 limitsNotice,
18 paceOf,
19 paceRemainingPercent,
20 remainingPercent,
21} from "@t3tools/shared/usageLimits";
22import { type ReactNode, useContext, useRef, useState } from "react";
23import { Atom } from "effect/reactivity";
24import { Linking, Pressable, View } from "react-native";
25​
26import { AppText as Text } from "../../components/ui/AppText";
27import { ProviderIcon } from "../../components/ProviderIcon";
28import { environmentPresentations } from "../../state/presentation";
29import { serverEnvironment } from "../../state/server";
30import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
31import { useAtomCommand } from "../../state/use-atom-command";
32import { useNowMinuteMs } from "../../lib/clock";
33import {
34 environmentIdsKey,
35 limitsAutoRefreshSetAtom,
36 limitsRefreshFailuresAtom,
37 refreshEnvironmentLimits,
38} from "./usageLimitsRefresh";
39import { usageProviderKind, useProviderColors } from "./usageProviders";
40​
41const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const;
42​
43type Driver = ServerProvider["driver"];
44​
45function useBarColor(driver: Driver): string | null {
46 const colors = useProviderColors();
47 const kind = usageProviderKind(driver);
48 return kind ? colors[kind] : null;
50​
51function WindowRow(props: {
52 readonly window: ServerProviderUsageWindow;
53 readonly color: string | null;
54 readonly now: number;
55}) {
56 const { window, now } = props;
57 const remaining = remainingPercent(window);
58 const timeLeft = paceRemainingPercent(window, now);
59 const pace = paceOf(window, now);
60 const resetsIn = formatResetsIn(window, now);
61 const [barWidth, setBarWidth] = useState(0);
62 const tickX =
63 timeLeft === null || barWidth < 6
64 ? null
65 : Math.min(Math.max((barWidth * timeLeft) / 100, 3), barWidth - 3);
66 const bar = (
67 <View
68 className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle"
69 style={barWidth > 0 ? { width: barWidth } : undefined}
70 >
71 <View
72 className={
73 remaining <= 10
74 ? "h-full rounded-full bg-red-500"
75 : remaining <= 30
76 ? "h-full rounded-full bg-amber-500"
77 : "h-full rounded-full bg-foreground"
78 }
79 style={[
80 { flex: remaining },
81 remaining > 30 && props.color ? { backgroundColor: props.color } : null,
82 ]}
83 />
84 <View style={{ flex: 100 - remaining }} />
85 </View>
86 );
87 return (
88 <View className="gap-1">
89 <View className="flex-row items-baseline justify-between gap-3">
90 <Text className="text-sm text-foreground">{window.label}</Text>
91 <Text className="text-sm font-t3-medium tabular-nums text-foreground">
92 {remaining}% left
93 </Text>
94 </View>
95 <View
96 className="h-3 justify-center"
97 onLayout={(event) => setBarWidth(event.nativeEvent.layout.width)}
98 >
99 {tickX === null ? (
100 bar
101 ) : (
102 <View className="h-1.5">
103 {/* Clip the same full-width bar so neither fill proportions nor corner radii change. */}
104 {(
105 [
106 [0, tickX - 3],
107 [tickX + 3, barWidth],
108 ] as const
109 ).map(([start, end]) => (
110 <View
111 key={start}
112 className="absolute top-0 bottom-0 overflow-hidden"
113 style={{ left: start, width: end - start }}
114 >
115 <View style={{ marginLeft: -start }}>{bar}</View>
116 </View>
117 ))}
118 </View>
119 )}
120 {tickX !== null ? (
121 <View
122 className="absolute top-px bottom-px w-0.5 rounded-full bg-foreground"
123 style={{ left: tickX - 1 }}
124 />
125 ) : null}
126 </View>
127 {pace || resetsIn ? (
128 <View className="flex-row justify-between gap-3">
129 <Text className="text-xs text-foreground-tertiary">{pace ? PACE_LABEL[pace] : ""}</Text>
130 <Text className="text-xs tabular-nums text-foreground-tertiary">{resetsIn ?? ""}</Text>
131 </View>
132 ) : null}
133 </View>
134 );
136​
137function AccountInstanceLabel({ value }: { readonly value: string }) {
138 const [revealed, setRevealed] = useState(false);
139 if (!value.includes("@")) {
140 return (
141 <Text className="shrink text-xs text-foreground-tertiary" numberOfLines={1}>
142 · {value}
143 </Text>
144 );
145 }
146 return (
147 <Pressable
148 className="shrink active:opacity-60"
149 accessibilityRole="button"
150 accessibilityLabel={revealed ? "Hide account label" : "Reveal account label"}
151 onPress={() => setRevealed((current) => !current)}
152 >
153 <Text className="text-xs text-foreground-tertiary" numberOfLines={1}>
154 · {revealed ? value : "••••••@••••••"}
155 </Text>
156 </Pressable>
157 );
159​
160export function AccountLimits(props: {
161 readonly driver: Driver;
162 readonly accentColor?: string | undefined;
163 readonly label: string;
164 readonly instanceLabel: string;
165 readonly detail: string | undefined;
166 readonly limits: ServerProvider["usageLimits"];
167 readonly now: number;
168 readonly first: boolean;
169 readonly dense?: boolean;
170 readonly trailing?: ReactNode;
171 readonly footer?: ReactNode;
172}) {
173 const { limits, now, dense = false } = props;
174 const providerColor = useBarColor(props.driver);
175 const color = normalizeProviderAccentColor(props.accentColor) ?? providerColor;
176 if (!limits) return null;
177 const notice = limitsNotice(limits);
178 const externalUsage = limits.externalUsage;
179 const padding = dense ? "px-4 py-3" : "p-4";
180 return (
181 <View
182 className={
183 props.first ? `gap-3 ${padding}` : `gap-3 border-t border-border-subtle ${padding}`
184 }
185 >
186 <View className="flex-row items-center gap-2">
187 <ProviderIcon provider={props.driver} size={16} />
188 <View className="min-w-0 flex-1 flex-row items-baseline gap-2">
189 <Text className="text-base font-t3-medium text-foreground">{props.label}</Text>
190 {props.instanceLabel !== props.label ? (
191 <AccountInstanceLabel key={props.instanceLabel} value={props.instanceLabel} />
192 ) : null}
193 {props.detail ? (
194 <Text className="shrink text-sm text-foreground-muted" numberOfLines={1}>
195 · {props.detail}
196 </Text>
197 ) : null}
198 </View>
199 {props.trailing}
200 </View>
201 {notice ? (
202 <Text className="text-sm text-foreground-muted">{notice}</Text>
203 ) : (
204 <View className="gap-3">
205 {limits.windows.map((window) => (
206 <WindowRow key={window.id} window={window} color={color} now={now} />
207 ))}
208 </View>
209 )}
210 {externalUsage ? (
211 <Pressable
212 accessibilityRole="link"
213 className="min-h-11 justify-center"
214 onPress={() => void Linking.openURL(externalUsage.url).catch(() => undefined)}
215 >
216 <Text className="text-sm font-t3-medium text-primary">Manage usage</Text>
217 </Pressable>
218 ) : null}
219 {props.footer}
220 </View>
221 );
223​
224const OUTCOME_TEXT: Record<ProviderConsumeResetCreditOutcome, string> = {
225 reset: "Reset applied. Your windows have cleared.",
226 nothingToReset: "Nothing to reset right now.",
227 noCredit: "No reset credit left.",
228 alreadyRedeemed: "That credit was already redeemed.",
229};
230​
231export function ResetCredits(props: {
232 readonly environmentId: EnvironmentId;
233 readonly input: ProviderConsumeResetCreditInput;
234 readonly credits: ServerProviderResetCredits;
235 readonly now: number;
236 readonly dense?: boolean;
237}) {
238 const { environmentId, input, credits, now, dense = false } = props;
239 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
240 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
241 reportFailure: false,
242 });
243 const [busy, setBusy] = useState(false);
244 const [status, setStatus] = useState<string | null>(null);
245 if (dense && credits.availableCount === 0 && status === null) return null;
246​
247 const redeem = async (choice: ResetCreditChoice) => {
248 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
249 setBusy(true);
250 setStatus(null);
251 const result = await consume({ environmentId, input: choice.input });
252 setBusy(false);
253 if (result._tag === "Success") {
254 setStatus(result.value.warning ?? OUTCOME_TEXT[result.value.outcome]);
255 return;
256 }
257 setStatus(
258 "error" in result.cause && result.cause.error instanceof Error
259 ? result.cause.error.message
260 : "Could not use the reset credit.",
261 );
262 };
263​
264 const confirm = (choice: ResetCreditChoice) => {
265 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
266 Dialog.alert("Use a reset credit?", choice.confirmDescription, [
267 { text: "Cancel", style: "cancel" },
268 { text: "Use credit", onPress: () => void redeem(choice) },
269 ]);
270 };
271​
272 return (
273 <View className="gap-1">
274 {resetCreditChoices(credits, input, now).map((choice) => (
275 <View key={choice.key} className="flex-row flex-wrap items-center gap-x-3 gap-y-1">
276 <Text className="text-xs tabular-nums text-foreground-tertiary">{choice.summary}</Text>
277 {choice.availableCount > 0 ? (
278 <Pressable
279 accessibilityRole="button"
280 accessibilityState={{ disabled: busy || !canManageProviders }}
281 disabled={busy || !canManageProviders}
282 onPress={() => confirm(choice)}
283 className={
284 dense
285 ? "rounded-full bg-subtle-strong px-2.5 py-1 disabled:opacity-[0.45]"
286 : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]"
287 }
288 >
289 <Text
290 className={
291 dense
292 ? "text-xs font-t3-medium text-foreground"
293 : "text-sm font-t3-medium text-foreground"
294 }
295 >
296 {busy ? "Using…" : choice.actionLabel}
297 </Text>
298 </Pressable>
299 ) : null}
300 </View>
301 ))}
302 {!canManageProviders ? (
303 <Text className="text-xs text-foreground-tertiary">
304 This connection cannot manage provider accounts.
305 </Text>
306 ) : null}
307 {status ? <Text className="text-sm text-foreground">{status}</Text> : null}
308 </View>
309 );
311​
312const IDLE_ATOM = Atom.make(undefined);
313​
314export function useRefreshLimits(
315 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
316 active = false,
317) {
318 const registry = useContext(RegistryContext);
319 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
320 const now = useNowMinuteMs();
321 const [refreshing, setRefreshing] = useState(false);
322 const refreshingRef = useRef(false);
323 const selected = [...presentations].filter(
324 ([environmentId]) =>
325 selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId),
326 );
327 const connectedIds = selected
328 .filter(([, presentation]) => presentation.connection.phase === "connected")
329 .map(([environmentId]) => environmentId);
330 const connectedKey = environmentIdsKey(connectedIds);
331 useAtomMount(active && connectedKey !== "" ? limitsAutoRefreshSetAtom(connectedKey) : IDLE_ATOM);
332 const failedIds = new Set(
333 useAtomValue(limitsRefreshFailuresAtom(environmentIdsKey(selected.map(([id]) => id)))),
334 );
335 const failedLabels = selected
336 .filter(([environmentId]) => failedIds.has(environmentId))
337 .map(([, presentation]) => presentation.entry.target.label);
338​
339 const refresh = () =>
340 Promise.all(
341 connectedIds.map((environmentId) => refreshEnvironmentLimits(registry, environmentId)),
342 );
343 // Android RefreshControl needs a true-to-false cycle to stop, even with nothing to probe.
344 const refreshManually = async () => {
345 if (refreshingRef.current) return;
346 refreshingRef.current = true;
347 setRefreshing(true);
348 await refresh().finally(() => {
349 refreshingRef.current = false;
350 setRefreshing(false);
351 });
352 };
353 return {
354 now,
355 refreshing,
356 failedLabels,
357 refresh: refreshManually,
358 };