import { useEffect, useRef } from 'react'; import { atom, useSetAtom } from 'jotai'; import { CallEmbed, useCallControlState } from '../plugins/call'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { invokeTauri, isTauri, useTauriEvent } from './useTauri'; import { createPttHoldWatchdog } from '../utils/pttHoldWatchdog'; import { toastQueueAtom } from '../state/toast'; /** Payload of the desktop's `lotus-global-hotkey` DOM event (cinny-desktop #2). */ type GlobalHotkeyEvent = { id: 'ptt' | 'deafen'; state: 'pressed' | 'released'; ctrl: boolean; alt: boolean; meta: boolean; }; /** * Whether a global (unfocused-window) hotkey event should act. Mirrors the DOM * rules: PTT ignores Ctrl/Alt/Meta chords, deafen ignores any modifier. There * is no Shift flag in the payload, so a Shift+deafen chord outside the window * does toggle — acceptable for a key the user chose for exactly that. */ export const shouldActOnGlobalHotkey = (e: GlobalHotkeyEvent): boolean => !e.ctrl && !e.alt && !e.meta; /** * True while a push-to-talk key is held. Written by useCallHotkeys (mounted for * the lifetime of the embed) and read by the PTT chip in CallControls, which * only renders while the call room is selected. */ export const pttActiveAtom = atom(false); type KeyLike = { code: string; repeat: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; shiftKey: boolean; }; /** * Whether a keydown should engage push-to-talk for `pttKey`. * [Gitea #23] Ignore the PTT key with Ctrl/Alt/Meta held so it doesn't hijack * OS/app chords (e.g. Cmd+Space) that happen to share the code. Shift is * allowed through — Shift+Space is a harmless combo for PTT. */ export const isPttKeyDown = (e: KeyLike, pttKey: string): boolean => e.code === pttKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey; /** * Whether a keydown should toggle deafen for `deafenKey`. * [Gitea #23] Ignore the deafen key with any modifier held — with the default * 'KeyM', Ctrl+M / Alt+M / Cmd+M are common OS/app chords that shouldn't also * toggle deafen (and previously got preventDefault()ed). */ export const isDeafenKeyDown = (e: KeyLike, deafenKey: string): boolean => e.code === deafenKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey; /** * Whether a key may be watched SYSTEM-WIDE as a toggle (deafen). The desktop * poll cannot tell "typed the letter m in Discord" from "pressed the deafen * key", so a bare letter/digit/Space/Enter bound globally deafens people at * random while they type in other apps (the "why did I go deaf?" reports). * Only keys nobody types prose with qualify: F-keys, the numpad, and the * lock/navigation cluster. Everything else stays in-window only, where the * editable-field check protects typing. */ export const isSafeGlobalToggleKey = (code: string): boolean => /^F\d{1,2}$/.test(code) || /^Numpad/.test(code) || /^(ScrollLock|Pause|Insert|Home|End|PageUp|PageDown|CapsLock|NumLock|PrintScreen|ContextMenu)$/.test( code, ); // BUG-7: use ownerDocument.body so isEditable works inside the EC iframe const isEditable = (el: HTMLElement): boolean => { const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; let node: HTMLElement | null = el; while (node && node !== el.ownerDocument.body) { if (node.contentEditable === 'true') return true; if (node.contentEditable === 'false') return false; node = node.parentElement; } return false; }; // [Gitea #23] Don't swallow Space on a focused button/link/etc — PTT still // engages the mic, but the key's default action (activating the control) // is left alone so keyboard users can still Tab+Space the call buttons. const isInteractive = (el: HTMLElement): boolean => { const tag = el.tagName; if (tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') return true; let node: HTMLElement | null = el; while (node && node !== el.ownerDocument.body) { const role = node.getAttribute('role'); if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') { return true; } node = node.parentElement; } return false; }; /** * Push-to-talk and deafen hotkeys for the active call. * * [Gitea #9] Mounted from CallUtils (CallEmbedProvider) so the bindings live as * long as the embed — not only while the call room is the selected room. Before * this lived in CallControls, so navigating away (PiP) or opening the in-call * chat on mobile silently dropped PTT and the deafen key. Gated on `joined` so * nothing is sent over the widget transport before EC's handler mounts. */ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean): void { const embed = joined ? callEmbed : undefined; const [pttMode] = useSetting(settingsAtom, 'pttMode'); const [pttKey] = useSetting(settingsAtom, 'pttKey'); const [deafenKey] = useSetting(settingsAtom, 'deafenKey'); const [deafenHotkey] = useSetting(settingsAtom, 'deafenHotkey'); const [globalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys'); const { microphone } = useCallControlState(embed?.control); const setPttActive = useSetAtom(pttActiveAtom); // Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn) const microphoneRef = useRef(microphone); useEffect(() => { microphoneRef.current = microphone; }, [microphone]); // Handle PTT mode toggle mid-call — save/restore mic state (I-4) const pttModeRef = useRef(pttMode); const micBeforePTTRef = useRef(null); useEffect(() => { if (embed) { if (pttMode && !pttModeRef.current) { micBeforePTTRef.current = microphoneRef.current; embed.control.setMicrophone(false); } else if (!pttMode && pttModeRef.current) { embed.control.setMicrophone(micBeforePTTRef.current ?? true); micBeforePTTRef.current = null; } } pttModeRef.current = pttMode; }, [pttMode, embed]); const pttActiveRef = useRef(false); const setToast = useSetAtom(toastQueueAtom); const embedRef = useRef(embed); useEffect(() => { embedRef.current = embed; }, [embed]); // [Gitea #136] Elbow-on-the-keyboard guard, shared by the DOM and the // desktop-global PTT paths: a hold longer than 5 min is released like a keyup. const holdWatchdog = useRef( createPttHoldWatchdog(() => { const current = embedRef.current; if (!current || !pttActiveRef.current) return; current.control.pttActive = false; current.control.setMicrophone(false); pttActiveRef.current = false; setPttActive(false); setToast({ id: `ptt-stuck-${Date.now()}`, displayName: 'Lotus Chat', body: 'Push to talk released after 5 minutes — press the key again to keep talking.', roomName: 'Voice call', roomId: current.roomId, }); }), ); useEffect(() => () => holdWatchdog.current.disarm(), []); useEffect(() => { if (!embed || !pttMode) return undefined; const release = () => { holdWatchdog.current.disarm(); embed.control.pttActive = false; embed.control.setMicrophone(false); pttActiveRef.current = false; setPttActive(false); }; // `prevent` is absent for keys reported by the fork, which already // cancelled the default action inside its frame. const pttDown = (e: KeyLike, editable: boolean, interactive: boolean, prevent?: () => void) => { if (!isPttKeyDown(e, pttKey)) return; if (editable) return; if (!interactive) prevent?.(); // Key auto-repeat re-fires keydown while held; don't restart the clock. if (pttActiveRef.current) return; // C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState) // doesn't treat this transient unmute as a user-initiated undeafen. embed.control.pttActive = true; if (!microphoneRef.current) embed.control.setMicrophone(true); pttActiveRef.current = true; setPttActive(true); holdWatchdog.current.arm(); }; const onKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; pttDown(e, isEditable(target), isInteractive(target), () => e.preventDefault()); }; const onKeyUp = (e: KeyboardEvent) => { if (e.code !== pttKey) return; release(); }; // BUG-9: release a held key when focus leaves/enters so the mic can't stick // open after the keyup lands elsewhere. [Gitea #27] Only when a PTT hold is // actually in progress — the mic button is still usable in PTT mode, and a // deliberate hands-free unmute must survive a click into the iframe/alt-tab. const onFocusChange = () => { if (pttActiveRef.current) release(); }; const bind = (win: Window) => { win.addEventListener('keydown', onKeyDown); win.addEventListener('keyup', onKeyUp); win.addEventListener('blur', onFocusChange); win.addEventListener('focus', onFocusChange); return () => { win.removeEventListener('keydown', onKeyDown); win.removeEventListener('keyup', onKeyUp); win.removeEventListener('blur', onFocusChange); win.removeEventListener('focus', onFocusChange); }; }; const unbindHost = bind(window); // BUG-9: PTT also works with focus in the call frame, reported by the // fork (io.lotus.hotkey; the host no longer listens on the frame's window). embed.control.setHotkeyCodes('ptt', [pttKey]); const unbindFork = embed.control.onHotkey((r) => { if (r.type === 'focus') onFocusChange(); else if (r.type === 'keydown') pttDown(r, r.editable, r.interactive); else if (r.code === pttKey) release(); }); return () => { unbindHost(); unbindFork(); embed.control.setHotkeyCodes('ptt', []); // BUG-8: if the embed changes while PTT is active, release mic on cleanup if (pttActiveRef.current) release(); }; // microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn }, [pttMode, pttKey, embed, setPttActive]); // [cinny-desktop #2] System-wide PTT/deafen while a game has focus. The // desktop polls the configured keys without consuming them and emits one // event per press/release transition. While Lotus itself (or the EC iframe) // has focus the DOM handlers above/below own the key — with their // editable-field and interactive-element checks — so global events are // ignored then and nothing double-fires. useEffect(() => { if (!isTauri() || !embed || !globalCallHotkeys) return undefined; const bindings = [ ...(pttMode ? [{ id: 'ptt', code: pttKey }] : []), // A bare letter/digit deafen key is never watched system-wide (see // isSafeGlobalToggleKey) — it would fire while typing in other apps. ...(deafenHotkey && isSafeGlobalToggleKey(deafenKey) ? [{ id: 'deafen', code: deafenKey }] : []), ]; invokeTauri('set_global_hotkeys', { bindings }); return () => { invokeTauri('set_global_hotkeys', { bindings: [] }); }; }, [embed, globalCallHotkeys, pttMode, pttKey, deafenKey, deafenHotkey]); useTauriEvent('lotus-global-hotkey', (detail) => { const current = embedRef.current; if (!current || !globalCallHotkeys) return; if (document.hasFocus()) return; if (detail.id === 'ptt') { if (!pttModeRef.current) return; if (detail.state === 'pressed') { if (pttActiveRef.current || !shouldActOnGlobalHotkey(detail)) return; current.control.pttActive = true; if (!microphoneRef.current) current.control.setMicrophone(true); pttActiveRef.current = true; setPttActive(true); holdWatchdog.current.arm(); } else if (pttActiveRef.current) { holdWatchdog.current.disarm(); current.control.pttActive = false; current.control.setMicrophone(false); pttActiveRef.current = false; setPttActive(false); } return; } if (detail.id === 'deafen' && detail.state === 'pressed' && shouldActOnGlobalHotkey(detail)) { current.control.toggleSound(); } }); useEffect(() => { if (!embed || !deafenHotkey) return undefined; const onKeyDown = (e: KeyboardEvent) => { if (!isDeafenKeyDown(e, deafenKey)) return; const target = e.target as HTMLElement; if (isEditable(target)) return; // Typing always wins over a typable deafen key. Cinny's "type anywhere // to focus the composer" (RoomView) also listens on window, so with the // default `M` the FIRST letter of a message typed after clicking the // timeline used to toggle deafen — and get swallowed ("mom" → "om"). If a // composer is on this screen and the key is one people type with, let the // composer have it; dedicated keys (F-keys, numpad, …) still toggle. if ( !isSafeGlobalToggleKey(deafenKey) && target.ownerDocument.querySelector('[data-slate-editor]') ) { return; } e.preventDefault(); embed.control.toggleSound(); }; window.addEventListener('keydown', onKeyDown); // C-L4: the deafen key also works with focus inside the call frame (no // composer there, so only the editable-field check applies). embed.control.setHotkeyCodes('deafen', [deafenKey]); const unbindFork = embed.control.onHotkey((r) => { if (r.type === 'keydown' && isDeafenKeyDown(r, deafenKey) && !r.editable) { embed.control.toggleSound(); } }); return () => { window.removeEventListener('keydown', onKeyDown); unbindFork(); embed.control.setHotkeyCodes('deafen', []); }; }, [embed, deafenKey, deafenHotkey]); }