2026-09-26 22:04:54 -04:00
|
|
|
import { useEffect, useRef } from 'react';
|
2026-09-12 19:46:05 -04:00
|
|
|
import { atom, useSetAtom } from 'jotai';
|
|
|
|
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
|
|
|
|
import { useSetting } from '../state/hooks/settings';
|
|
|
|
|
import { settingsAtom } from '../state/settings';
|
2026-09-17 13:39:51 -04:00
|
|
|
import { invokeTauri, isTauri, useTauriEvent } from './useTauri';
|
2026-09-19 17:13:14 -04:00
|
|
|
import { createPttHoldWatchdog } from '../utils/pttHoldWatchdog';
|
|
|
|
|
import { toastQueueAtom } from '../state/toast';
|
2026-09-17 13:39:51 -04:00
|
|
|
|
|
|
|
|
/** 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;
|
2026-09-12 19:46:05 -04:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
2026-09-18 23:15:00 -04:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
);
|
|
|
|
|
|
2026-09-12 19:46:05 -04:00
|
|
|
// 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');
|
2026-09-18 23:15:00 -04:00
|
|
|
const [deafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
|
2026-09-17 13:39:51 -04:00
|
|
|
const [globalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
|
2026-09-12 19:46:05 -04:00
|
|
|
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<boolean | null>(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);
|
2026-09-19 17:13:14 -04:00
|
|
|
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(), []);
|
2026-09-12 19:46:05 -04:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!embed || !pttMode) return undefined;
|
|
|
|
|
|
|
|
|
|
const release = () => {
|
2026-09-19 17:13:14 -04:00
|
|
|
holdWatchdog.current.disarm();
|
2026-09-12 19:46:05 -04:00
|
|
|
embed.control.pttActive = false;
|
|
|
|
|
embed.control.setMicrophone(false);
|
|
|
|
|
pttActiveRef.current = false;
|
|
|
|
|
setPttActive(false);
|
|
|
|
|
};
|
2026-09-26 14:35:44 -04:00
|
|
|
// `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) => {
|
2026-09-12 19:46:05 -04:00
|
|
|
if (!isPttKeyDown(e, pttKey)) return;
|
2026-09-26 14:35:44 -04:00
|
|
|
if (editable) return;
|
|
|
|
|
if (!interactive) prevent?.();
|
2026-09-19 17:13:14 -04:00
|
|
|
// Key auto-repeat re-fires keydown while held; don't restart the clock.
|
|
|
|
|
if (pttActiveRef.current) return;
|
2026-09-12 19:46:05 -04:00
|
|
|
// 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);
|
2026-09-19 17:13:14 -04:00
|
|
|
holdWatchdog.current.arm();
|
2026-09-12 19:46:05 -04:00
|
|
|
};
|
2026-09-26 14:35:44 -04:00
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
|
pttDown(e, isEditable(target), isInteractive(target), () => e.preventDefault());
|
|
|
|
|
};
|
2026-09-12 19:46:05 -04:00
|
|
|
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);
|
2026-09-26 22:04:54 -04:00
|
|
|
// 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).
|
2026-09-26 14:35:44 -04:00
|
|
|
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();
|
|
|
|
|
});
|
2026-09-12 19:46:05 -04:00
|
|
|
return () => {
|
|
|
|
|
unbindHost();
|
2026-09-26 14:35:44 -04:00
|
|
|
unbindFork();
|
|
|
|
|
embed.control.setHotkeyCodes('ptt', []);
|
2026-09-12 19:46:05 -04:00
|
|
|
// 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
|
2026-09-26 22:04:54 -04:00
|
|
|
}, [pttMode, pttKey, embed, setPttActive]);
|
2026-09-12 19:46:05 -04:00
|
|
|
|
2026-09-17 13:39:51 -04:00
|
|
|
// [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 }] : []),
|
2026-09-18 23:15:00 -04:00
|
|
|
// 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 }]
|
|
|
|
|
: []),
|
2026-09-17 13:39:51 -04:00
|
|
|
];
|
|
|
|
|
invokeTauri('set_global_hotkeys', { bindings });
|
|
|
|
|
return () => {
|
|
|
|
|
invokeTauri('set_global_hotkeys', { bindings: [] });
|
|
|
|
|
};
|
2026-09-18 23:15:00 -04:00
|
|
|
}, [embed, globalCallHotkeys, pttMode, pttKey, deafenKey, deafenHotkey]);
|
2026-09-17 13:39:51 -04:00
|
|
|
useTauriEvent<GlobalHotkeyEvent>('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);
|
2026-09-19 17:13:14 -04:00
|
|
|
holdWatchdog.current.arm();
|
2026-09-17 13:39:51 -04:00
|
|
|
} else if (pttActiveRef.current) {
|
2026-09-19 17:13:14 -04:00
|
|
|
holdWatchdog.current.disarm();
|
2026-09-17 13:39:51 -04:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-12 19:46:05 -04:00
|
|
|
useEffect(() => {
|
2026-09-18 23:15:00 -04:00
|
|
|
if (!embed || !deafenHotkey) return undefined;
|
2026-09-12 19:46:05 -04:00
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
if (!isDeafenKeyDown(e, deafenKey)) return;
|
2026-09-18 23:15:00 -04:00
|
|
|
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;
|
|
|
|
|
}
|
2026-09-12 19:46:05 -04:00
|
|
|
e.preventDefault();
|
|
|
|
|
embed.control.toggleSound();
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener('keydown', onKeyDown);
|
2026-09-26 14:35:44 -04:00
|
|
|
// 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();
|
|
|
|
|
}
|
2026-09-12 19:46:05 -04:00
|
|
|
});
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('keydown', onKeyDown);
|
2026-09-26 14:35:44 -04:00
|
|
|
unbindFork();
|
|
|
|
|
embed.control.setHotkeyCodes('deafen', []);
|
2026-09-12 19:46:05 -04:00
|
|
|
};
|
2026-09-26 22:04:54 -04:00
|
|
|
}, [embed, deafenKey, deafenHotkey]);
|
2026-09-12 19:46:05 -04:00
|
|
|
}
|