CI / Build & Quality Checks (push) Successful in 4m32s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 16m32s
Firefox, Safari and the WebKitGTK desktop app can't hand the user's click to the call frame (no Capability Delegation), and getDisplayMedia needs it. The host used to click EC's hidden footer button through the DOM instead, which dies with same-origin. Now (pins element-call-embedded 0.25.0-lotus.20): - those engines get `lotusFrameScreenshare`, the fork shows EC's own screenshare button in the frame, and the call bar and status bar hide theirs once controls_state reports `frameScreenshare`; the screenshare-audio mute stays; - the room's call policy is pushed with io.lotus.set_frame_screenshare, so the frame button hides where the server would refuse a share, like ours; - Chromium keeps the delegated io.lotus.set_screenshare from the host bar. Removed the fallbacks for forks older than lotus.14, which read or clicked EC's DOM: the screenshare/layout/settings/reactions/leave button lookups and their MutationObservers, the frame-window hotkey binding, and the speaking/muted tile scrape in useCallSpeakers (io.lotus.call_state is the only source now). getCallDocument is gone; the host's only handle on the frame is postMessage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
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<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);
|
|
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<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);
|
|
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]);
|
|
}
|