diff --git a/Client/tauri-client/src-tauri/src/credentials.rs b/Client/tauri-client/src-tauri/src/credentials.rs index 92ea931d..26e0ef31 100644 --- a/Client/tauri-client/src-tauri/src/credentials.rs +++ b/Client/tauri-client/src-tauri/src/credentials.rs @@ -12,7 +12,6 @@ use windows::Win32::Security::Credentials::{ pub struct CredentialData { pub username: String, pub token: String, - /// Optional saved password (only present when user opted in). #[serde(skip_serializing_if = "Option::is_none")] pub password: Option, } diff --git a/Client/tauri-client/src/components/settings/AccountTab.ts b/Client/tauri-client/src/components/settings/AccountTab.ts index 9da55dbf..f7534d84 100644 --- a/Client/tauri-client/src/components/settings/AccountTab.ts +++ b/Client/tauri-client/src/components/settings/AccountTab.ts @@ -47,17 +47,21 @@ export function buildAccountTab( const usernameError = createElement("div", { style: "color:var(--red);font-size:13px;margin-top:4px" }); editForm.appendChild(usernameError); + const MAX_USERNAME_LEN = 32; + saveBtn.addEventListener("click", () => { const newName = editInput.value.trim(); - if (newName.length > 0) { - setText(usernameError, ""); - void options.onUpdateProfile(newName).then(() => { - setText(usernameValue, newName); - editForm.style.display = "none"; - }).catch((err: unknown) => { - setText(usernameError, err instanceof Error ? err.message : "Failed to update username."); - }); + if (newName.length === 0 || newName.length > MAX_USERNAME_LEN) { + setText(usernameError, `Username must be 1\u2013${MAX_USERNAME_LEN} characters.`); + return; } + setText(usernameError, ""); + void options.onUpdateProfile(newName).then(() => { + setText(usernameValue, newName); + editForm.style.display = "none"; + }).catch((err: unknown) => { + setText(usernameError, err instanceof Error ? err.message : "Failed to update username."); + }); }, { signal }); section.appendChild(editForm); diff --git a/Client/tauri-client/src/components/settings/AppearanceTab.ts b/Client/tauri-client/src/components/settings/AppearanceTab.ts index 1237740d..e38c1c67 100644 --- a/Client/tauri-client/src/components/settings/AppearanceTab.ts +++ b/Client/tauri-client/src/components/settings/AppearanceTab.ts @@ -3,7 +3,7 @@ */ import { createElement, appendChildren, setText } from "@lib/dom"; -import { loadPref, savePref, applyTheme, THEMES } from "./helpers"; +import { loadPref, savePref, applyTheme, THEMES, createToggle } from "./helpers"; import type { ThemeName } from "./helpers"; import { setTheme } from "@stores/ui.store"; @@ -57,15 +57,13 @@ export function buildAppearanceTab(signal: AbortSignal): HTMLDivElement { // Compact mode toggle const compactRow = createElement("div", { class: "setting-row" }); const compactLabel = createElement("span", { class: "setting-label" }, "Compact Mode"); - const compactToggle = createElement("div", { - class: currentCompact ? "toggle on" : "toggle", + const compactToggle = createToggle(currentCompact, { + signal, + onChange: (isNowCompact) => { + savePref("compactMode", isNowCompact); + document.documentElement.classList.toggle("compact-mode", isNowCompact); + }, }); - compactToggle.addEventListener("click", () => { - const isNowCompact = !compactToggle.classList.contains("on"); - compactToggle.classList.toggle("on", isNowCompact); - savePref("compactMode", isNowCompact); - document.documentElement.classList.toggle("compact-mode", isNowCompact); - }, { signal }); appendChildren(compactRow, compactLabel, compactToggle); section.appendChild(compactRow); diff --git a/Client/tauri-client/src/components/settings/KeybindsTab.ts b/Client/tauri-client/src/components/settings/KeybindsTab.ts index 45af66b0..db18a979 100644 --- a/Client/tauri-client/src/components/settings/KeybindsTab.ts +++ b/Client/tauri-client/src/components/settings/KeybindsTab.ts @@ -15,15 +15,15 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement { // ── Push to Talk ────────────────────────────────────────── const pttRow = createElement("div", { class: "keybind-row" }); const pttLabel = createElement("span", { class: "setting-label" }, "Push to Talk"); - const savedVk = loadPref("pttVk", 0); + let currentVk = loadPref("pttVk", 0); const pttValue = createElement("span", { class: "kbd", style: "cursor: pointer; min-width: 80px; text-align: center;", title: "Click to set keybind", - }, savedVk !== 0 ? vkName(savedVk) : "Not set"); + }, currentVk !== 0 ? vkName(currentVk) : "Not set"); const pttClear = createElement("button", { class: "ac-btn", - style: `margin-left: 8px; font-size: 12px; padding: 4px 10px; ${savedVk !== 0 ? "" : "display: none;"}`, + style: `margin-left: 8px; font-size: 12px; padding: 4px 10px; ${currentVk !== 0 ? "" : "display: none;"}`, }, "Clear"); let capturing = false; @@ -43,9 +43,10 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement { pttValue.style.color = ""; if (vk === 0) { // Timed out — restore previous value - setText(pttValue, savedVk !== 0 ? vkName(savedVk) : "Not set"); + setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set"); return; } + currentVk = vk; setText(pttValue, vkName(vk)); pttClear.style.display = ""; void updatePttKey(vk); @@ -54,12 +55,13 @@ export function buildKeybindsTab(signal: AbortSignal): HTMLDivElement { capturing = false; pttValue.style.borderColor = ""; pttValue.style.color = ""; - setText(pttValue, savedVk !== 0 ? vkName(savedVk) : "Not set"); + setText(pttValue, currentVk !== 0 ? vkName(currentVk) : "Not set"); }); }, { signal }); pttClear.addEventListener("click", (e) => { e.stopPropagation(); + currentVk = 0; setText(pttValue, "Not set"); pttClear.style.display = "none"; void updatePttKey(0); diff --git a/Client/tauri-client/src/components/settings/NotificationsTab.ts b/Client/tauri-client/src/components/settings/NotificationsTab.ts index 1bd2b7f7..4b25d784 100644 --- a/Client/tauri-client/src/components/settings/NotificationsTab.ts +++ b/Client/tauri-client/src/components/settings/NotificationsTab.ts @@ -3,7 +3,7 @@ */ import { createElement, appendChildren } from "@lib/dom"; -import { loadPref, savePref } from "./helpers"; +import { loadPref, savePref, createToggle } from "./helpers"; export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement { const section = createElement("div", { class: "settings-pane active" }); @@ -25,12 +25,10 @@ export function buildNotificationsTab(signal: AbortSignal): HTMLDivElement { appendChildren(info, label, desc); const isOn = loadPref(item.key, item.fallback); - const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" }); - toggle.addEventListener("click", () => { - const nowOn = !toggle.classList.contains("on"); - toggle.classList.toggle("on", nowOn); - savePref(item.key, nowOn); - }, { signal }); + const toggle = createToggle(isOn, { + signal, + onChange: (nowOn) => { savePref(item.key, nowOn); }, + }); appendChildren(row, info, toggle); section.appendChild(row); diff --git a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts index 60b8be8a..c4ce7b8f 100644 --- a/Client/tauri-client/src/components/settings/VoiceAudioTab.ts +++ b/Client/tauri-client/src/components/settings/VoiceAudioTab.ts @@ -3,7 +3,7 @@ */ import { createElement, appendChildren, setText } from "@lib/dom"; -import { loadPref, savePref } from "./helpers"; +import { loadPref, savePref, createToggle } from "./helpers"; import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, updateSilenceSuppressionPref } from "@lib/voiceSession"; import { sensitivityToThreshold } from "@lib/vad"; @@ -40,6 +40,10 @@ export function createVoiceAudioTab(signal: AbortSignal): VoiceAudioTabHandle { micAudioCtx = ctx; micAnimFrame = frame; }, (stream) => { + // Stop old camera tracks before registering new stream + if (cameraPreviewStream !== null && cameraPreviewStream !== stream) { + for (const track of cameraPreviewStream.getTracks()) track.stop(); + } cameraPreviewStream = stream; }); } @@ -109,8 +113,6 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar, previewWrap.appendChild(previewVideo); section.appendChild(previewWrap); - let previewStream: MediaStream | null = null; - // Populate devices asynchronously void (async () => { try { @@ -159,50 +161,52 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar, void switchOutputDevice(outputSelect.value); }, { signal }); - videoSelect.addEventListener("change", () => { - savePref("videoInputDevice", videoSelect.value); - if (previewStream !== null) { - for (const track of previewStream.getTracks()) track.stop(); - previewStream = null; - } + function stopCameraPreview(): void { + registerCamera(null); previewVideo.srcObject = null; - const selectedDevice = videoSelect.value; + } + + let previewErrorEl: HTMLDivElement | null = null; + + function clearPreviewError(): void { + if (previewErrorEl !== null) { + previewErrorEl.remove(); + previewErrorEl = null; + } + } + + function startCameraPreview(deviceId: string): void { + stopCameraPreview(); + clearPreviewError(); void (async () => { try { const constraints: MediaStreamConstraints = { - video: selectedDevice - ? { deviceId: { exact: selectedDevice }, width: { ideal: 320 }, height: { ideal: 180 } } + video: deviceId + ? { deviceId: { exact: deviceId }, width: { ideal: 320 }, height: { ideal: 180 } } : { width: { ideal: 320 }, height: { ideal: 180 } }, audio: false, }; - previewStream = await navigator.mediaDevices.getUserMedia(constraints); - registerCamera(previewStream); - previewVideo.srcObject = previewStream; - } catch { /* Camera unavailable */ } + const stream = await navigator.mediaDevices.getUserMedia(constraints); + registerCamera(stream); + previewVideo.srcObject = stream; + } catch (err) { + const msg = err instanceof Error ? err.message : "Camera unavailable"; + previewErrorEl = createElement("div", { class: "setting-desc" }, msg) as HTMLDivElement; + previewWrap.appendChild(previewErrorEl); + } })(); + } + + videoSelect.addEventListener("change", () => { + savePref("videoInputDevice", videoSelect.value); + startCameraPreview(videoSelect.value); }, { signal }); // Start initial camera preview - void (async () => { - try { - const savedDevice = loadPref("videoInputDevice", ""); - const constraints: MediaStreamConstraints = { - video: savedDevice ? { deviceId: { exact: savedDevice }, width: { ideal: 320 }, height: { ideal: 180 } } : { width: { ideal: 320 }, height: { ideal: 180 } }, - audio: false, - }; - previewStream = await navigator.mediaDevices.getUserMedia(constraints); - registerCamera(previewStream); - previewVideo.srcObject = previewStream; - } catch { /* Camera unavailable */ } - })(); + startCameraPreview(loadPref("videoInputDevice", "")); signal.addEventListener("abort", () => { - if (previewStream !== null) { - for (const track of previewStream.getTracks()) track.stop(); - previewStream = null; - registerCamera(null); - } - previewVideo.srcObject = null; + stopCameraPreview(); }); // ── Mic level meter + sensitivity slider ────────────────────────── @@ -317,20 +321,18 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar, appendChildren(info, label, desc); const isOn = loadPref(item.key, item.fallback); - const toggle = createElement("div", { class: isOn ? "toggle on" : "toggle" }); - toggle.addEventListener("click", () => { - const nowOn = !toggle.classList.contains("on"); - toggle.classList.toggle("on", nowOn); - savePref(item.key, nowOn); - if (item.key === "silenceSuppression") { - // Silence suppression takes effect on next VAD tick — no device switch needed - updateSilenceSuppressionPref(); - } else { - // Re-acquire mic with new constraints if in an active voice session - const currentDevice = loadPref("audioInputDevice", ""); - void switchInputDevice(currentDevice); - } - }, { signal }); + const toggle = createToggle(isOn, { + signal, + onChange: (nowOn) => { + savePref(item.key, nowOn); + if (item.key === "silenceSuppression") { + updateSilenceSuppressionPref(); + } else { + const currentDevice = loadPref("audioInputDevice", ""); + void switchInputDevice(currentDevice); + } + }, + }); appendChildren(row, info, toggle); section.appendChild(row); diff --git a/Client/tauri-client/src/components/settings/helpers.ts b/Client/tauri-client/src/components/settings/helpers.ts index 2e4db573..e3bf6c98 100644 --- a/Client/tauri-client/src/components/settings/helpers.ts +++ b/Client/tauri-client/src/components/settings/helpers.ts @@ -2,6 +2,8 @@ * Shared helpers and constants for settings tabs. */ +import { createElement } from "@lib/dom"; + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -33,6 +35,43 @@ export function savePref(key: string, value: unknown): void { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); } +// --------------------------------------------------------------------------- +// Accessible toggle creation +// --------------------------------------------------------------------------- + +/** + * Create an accessible toggle switch element with proper ARIA attributes + * and keyboard support (Enter/Space to toggle). + */ +export function createToggle( + isOn: boolean, + opts: { signal: AbortSignal; onChange: (nowOn: boolean) => void }, +): HTMLDivElement { + const toggle = createElement("div", { + class: isOn ? "toggle on" : "toggle", + role: "switch", + tabindex: "0", + "aria-checked": isOn ? "true" : "false", + }); + + function doToggle(): void { + const nowOn = !toggle.classList.contains("on"); + toggle.classList.toggle("on", nowOn); + toggle.setAttribute("aria-checked", String(nowOn)); + opts.onChange(nowOn); + } + + toggle.addEventListener("click", doToggle, { signal: opts.signal }); + toggle.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + doToggle(); + } + }, { signal: opts.signal }); + + return toggle; +} + // --------------------------------------------------------------------------- // Theme application // --------------------------------------------------------------------------- diff --git a/Client/tauri-client/src/lib/credentials.ts b/Client/tauri-client/src/lib/credentials.ts index c1646570..cfe71104 100644 --- a/Client/tauri-client/src/lib/credentials.ts +++ b/Client/tauri-client/src/lib/credentials.ts @@ -65,12 +65,11 @@ export async function loadCredential( if (result && typeof result === "object") { const cred = result as Record; if (typeof cred.username === "string" && typeof cred.token === "string") { - const saved: SavedCredential = { + return { username: cred.username, token: cred.token, ...(typeof cred.password === "string" ? { password: cred.password } : {}), }; - return saved; } } return null;