All files / src/hooks useApiKey.ts

100% Statements 95/95
100% Branches 44/44
100% Functions 16/16
100% Lines 81/81

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199                                                                                              2x                 2x 1170x 1170x 1170x 1170x 1170x       1170x 1170x 1170x     1170x     1170x 1170x   1170x 430x 430x         1170x 1170x 11x 11x     1170x 331x 331x     331x 331x 331x     323x 321x 323x 323x   323x     8x   331x 331x 331x 331x       1170x 58x 58x 58x 58x 58x   58x     1170x 42x 41x 41x 41x 41x 41x     77x 41x 41x 41x 36x   5x 5x   41x   36x 33x 33x   36x 31x     31x 31x 31x   5x       36x 36x   1x       1170x 16x 16x 14x 14x   14x 14x   1x       14x 12x     1170x                        
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { useCallback, useEffect, useRef, useState } from "react";
import { useChanged } from "@/hooks/useChanged";
 
/** Where a save is up to. "saved" and "removed" show for a moment, then go back to idle. */
export type KeySaveStatus = "idle" | "saving" | "saved" | "removed" | "error";
 
/** What the last check of the key found. "unreachable" means the check itself failed. */
export type KeyTestStatus = "idle" | "testing" | "valid" | "invalid" | "unreachable";
 
/** The backend calls behind one API key. */
export interface ApiKeyService {
	/** The saved key, or null when none is saved. */
	load: () => Promise<string | null>;
	/** Saves the key. A blank key removes the saved one. */
	save: (key: string) => Promise<void>;
	/** Whether the service takes the key. Rejects when it can't be reached. */
	test: (key: string) => Promise<boolean>;
	/**
	 * Runs after every save that goes through, with the trimmed key or "" for
	 * a removal. It runs even when the field has gone by then, since the key
	 * is saved either way.
	 */
	onSaved?: (key: string) => void;
}
 
export interface ApiKeyState {
	/** The text in the field. */
	value: string;
	/** The saved key, or "" when none is saved or it couldn't be read. */
	saved: string;
	loadFailed: boolean;
	saveStatus: KeySaveStatus;
	testStatus: KeyTestStatus;
	/**
	 * Whether Save has something to do. It does when the field differs from
	 * the saved key, and always when the saved key couldn't be read.
	 */
	dirty: boolean;
	setValue: (value: string) => void;
	save: () => Promise<void>;
	test: () => Promise<void>;
}
 
/** How long Saved or Removed shows before the button goes back to normal. */
export const SAVED_MS = 2000;
 
/**
 * The state behind an API key field: the saved key, the text being edited,
 * and the results of saving and checking it. Editing the field drops any
 * result that hasn't come back yet, so a check of the old text can't mark
 * the new text valid. Pass a service defined once, outside any component,
 * since a new one loads the key again.
 */
export const useApiKey = (service: ApiKeyService): ApiKeyState => {
	const [value, setValueState] = useState("");
	const [saved, setSaved] = useState("");
	const [loadFailed, setLoadFailed] = useState(false);
	const [saveStatus, setSaveStatus] = useState<KeySaveStatus>("idle");
	const [testStatus, setTestStatus] = useState<KeyTestStatus>("idle");
 
	// Goes up on every edit. A result only shows when it hasn't moved since
	// the save or check began.
	const generation = useRef(0);
	const edited = useRef(false);
	const saving = useRef(false);
	// The service a save last went through since the key began loading. A
	// load that lands after it is out of date.
	const savedFor = useRef<ApiKeyService | null>(null);
	// The service the field shows, or null once the field has gone. A save or
	// check only shows its result while its service is still this one.
	const shown = useRef<ApiKeyService | null>(null);
	const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
 
	const clearTimer = () => {
		if (timer.current) clearTimeout(timer.current);
		timer.current = null;
	};
 
	// Results for another service don't belong to this one. A save or check
	// still on its way clears its own status when it lands.
	const serviceChanged = useChanged(service);
	if (serviceChanged) {
		setSaveStatus((status) => (status === "saving" ? status : "idle"));
		setTestStatus((status) => (status === "testing" ? status : "idle"));
	}
 
	useEffect(() => {
		shown.current = service;
		savedFor.current = null;
		// Each run drops its own load when it ends, so a load from an earlier
		// run, or from Strict Mode's first one, can't land after a newer one.
		let current = true;
		const isStale = () => !current || savedFor.current === service;
		service
			.load()
			.then((key) => {
				if (isStale()) return;
				const stored = key?.trim() ?? "";
				setSaved(stored);
				setLoadFailed(false);
				// Text typed while the key loaded stays.
				if (!edited.current) setValueState(stored);
			})
			.catch(() => {
				if (!isStale()) setLoadFailed(true);
			});
		return () => {
			current = false;
			shown.current = null;
			clearTimer();
		};
	}, [service]);
 
	const setValue = useCallback((next: string) => {
		edited.current = true;
		generation.current += 1;
		clearTimer();
		setValueState(next);
		setTestStatus("idle");
		// A save already on its way keeps showing until it lands.
		setSaveStatus((status) => (status === "saving" ? status : "idle"));
	}, []);
 
	const save = useCallback(async () => {
		if (saving.current) return;
		saving.current = true;
		const key = value.trim();
		const started = generation.current;
		clearTimer();
		setSaveStatus("saving");
		// Whether the field still shows this service and the text that was saved.
		// When it moved to another service, only Saving needs to go.
		const forService = () => shown.current === service;
		const forText = () => forService() && generation.current === started;
		try {
			await service.save(key);
			savedFor.current = service;
		} catch {
			setSaveStatus(forText() ? "error" : "idle");
			return;
		} finally {
			saving.current = false;
		}
		if (forService()) {
			setSaved(key);
			setLoadFailed(false);
		}
		if (forText()) {
			setValueState(key);
			// The status line says the key saved, which a check result would
			// cover up. The saved key can be checked again.
			setTestStatus("idle");
			setSaveStatus(key ? "saved" : "removed");
			timer.current = setTimeout(() => setSaveStatus("idle"), SAVED_MS);
		} else {
			setSaveStatus("idle");
		}
		// The key is saved either way, so a listener that throws doesn't make
		// the save look failed.
		try {
			service.onSaved?.(key);
		} catch (e) {
			console.error("Couldn't report the saved key", e);
		}
	}, [service, value]);
 
	const test = useCallback(async () => {
		const key = value.trim();
		if (!key || testStatus === "testing") return;
		const started = generation.current;
		setTestStatus("testing");
		let result: KeyTestStatus;
		try {
			result = (await service.test(key)) ? "valid" : "invalid";
		} catch {
			result = "unreachable";
		}
		// An edit already cleared Testing, and the new text may have a check of
		// its own on the way.
		if (generation.current !== started) return;
		setTestStatus(shown.current === service ? result : "idle");
	}, [service, value, testStatus]);
 
	return {
		value,
		saved,
		loadFailed,
		saveStatus,
		testStatus,
		dirty: loadFailed || value.trim() !== saved,
		setValue,
		save,
		test,
	};
};