Skip to content
Source
Sign inSign up

voiceConsent.ts

121 lines4.4 KB

1import { VoiceTranscriptionDestination } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3import { useSyncExternalStore } from "react";
4import { randomUUID } from "../lib/utils";
5import {
6 getLocalStorageItem,
7 readLocalStorageValue,
8 subscribeLocalStorageKey,
9 useLocalStorage,
10 writeLocalStorageValue,
11} from "../hooks/useLocalStorage";
12​
13// Separate keys: a stale window saving ordinary preferences must not restore either grant.
14const AUDIO_KEY = "t3code:voice-audio-grant:v1";
15const CLEANUP_KEY = "t3code:voice-cleanup-grant:v1";
16const Grant = Schema.Struct({ id: Schema.String, destination: VoiceTranscriptionDestination });
17const AudioGrant = Schema.NullOr(Grant);
18const sameGrant = Schema.toEquivalence(AudioGrant);
19const SavedChoice = Schema.Union([AudioGrant, Schema.Literal("off")]);
20const AudioChoice = Schema.Union([
21 SavedChoice,
22 Schema.Struct({ pending: Grant, previous: SavedChoice }),
23]);
24type AudioChoice = typeof AudioChoice.Type;
25const savedKey = (id: string) => `${AUDIO_KEY}:saved:${id}`;
26const pendingGrant = (choice: AudioChoice) =>
27 choice && choice !== "off" && "pending" in choice ? choice.pending : null;
28const CleanupGrant = Schema.NullOr(Schema.String);
29const decodeChoice = Schema.decodeSync(Schema.fromJsonString(AudioChoice));
30const readChoice = () => readLocalStorageValue(AUDIO_KEY, null, AudioChoice);
31​
32function readSaveMarker(id: string | undefined) {
33 try {
34 return id ? window.localStorage.getItem(savedKey(id)) : null;
35 } catch {
36 return undefined;
37 }
39​
40function activeChoice(choice: AudioChoice, marker: string | null | undefined) {
41 if (!choice || choice === "off" || !("pending" in choice)) return choice;
42 // Only a confirmed missing marker means the previous grant still applies.
43 if (marker === null) return choice.previous;
44 return marker === "true" ? choice.pending : ("off" as const);
46​
47export function useAudioChoice() {
48 const [choice] = useLocalStorage(AUDIO_KEY, null, AudioChoice);
49 const id = pendingGrant(choice)?.id;
50 const marker = useSyncExternalStore(
51 (notify) => (id ? subscribeLocalStorageKey(savedKey(id), notify) : () => {}),
52 () => readSaveMarker(id),
53 () => undefined,
54 );
55 return activeChoice(choice, marker);
57​
58function replaceChoice(choice: AudioChoice, previous = readChoice()) {
59 const old = pendingGrant(previous);
60 writeLocalStorageValue(AUDIO_KEY, choice, null, AudioChoice);
61 if (old && pendingGrant(getLocalStorageItem(AUDIO_KEY, AudioChoice))?.id !== old.id)
62 window.localStorage.removeItem(savedKey(old.id));
64​
65export function chooseAudioDestination(destination: VoiceTranscriptionDestination | null) {
66 replaceChoice(destination ? { id: randomUUID(), destination } : "off");
68​
69export function pendingCloudAudioChoice(
70 destination: Extract<VoiceTranscriptionDestination, { kind: "cloud" }>,
71) {
72 const choice = readChoice();
73 const pending = { id: randomUUID(), destination };
74 replaceChoice(
75 { pending, previous: activeChoice(choice, readSaveMarker(pendingGrant(choice)?.id)) },
76 choice,
77 );
78 // A late response can acknowledge only its own choice, never write the destination another tab picked.
79 return () => {
80 if (pendingGrant(readChoice())?.id === pending.id)
81 writeLocalStorageValue(savedKey(pending.id), true, false, Schema.Boolean);
82 };
84​
85// Bind this recording to this act of consent, even if the user later reselects the same destination.
86export function bindAudioGrant(id: string) {
87 try {
88 let cachedRaw: string | null = null;
89 let choice: AudioChoice = null;
90 const current = () => {
91 const raw = window.localStorage.getItem(AUDIO_KEY);
92 if (raw !== cachedRaw) {
93 choice = raw ? decodeChoice(raw) : null;
94 cachedRaw = raw;
95 }
96 const active = activeChoice(choice, readSaveMarker(pendingGrant(choice)?.id));
97 return active === "off" ? null : active;
98 };
99 const bound = current();
100 if (bound?.id !== id) return () => false;
101 return () => {
102 try {
103 return sameGrant(current(), bound);
104 } catch {
105 return false;
106 }
107 };
108 } catch {
109 return () => false;
110 }
112​
113export function useCleanupGrant() {
114 return useLocalStorage(CLEANUP_KEY, null, CleanupGrant)[0];
116export function readCleanupGrant() {
117 return readLocalStorageValue(CLEANUP_KEY, null, CleanupGrant);
119export function chooseTranscriptCleanup(enabled: boolean) {
120 writeLocalStorageValue(CLEANUP_KEY, enabled ? randomUUID() : null, null, CleanupGrant);