fix(calls): Push to Deafen can be switched off, and a typable deafen key can no longer fire while typing
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 2m0s
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 2m0s
Root cause of the 'went deaf while typing' reports: the deafen key (default M) and Cinny's type-anywhere-to-focus-the-composer both listen on window, so the first letter of a message typed after clicking the timeline toggled deafen and was swallowed (reproduced: typing 'mom' → deafened, composer shows 'om'). Now: (1) Settings → Calls → Push to Deafen has an on/off switch (deafenHotkey); (2) a letter/digit/Space deafen key only toggles where no composer is on screen — typing wins; (3) such keys are never bound system-wide on desktop — only F-keys, numpad and the lock/navigation cluster qualify (isSafeGlobalToggleKey) — so 'm' typed in another app can't deafen you. Verified in live calls: M still toggles in the call view, is ignored on a chat screen, switch off disables it entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -1179,6 +1179,10 @@ Links you paste, send, edit, or merely _see_ lose ad/analytics identifiers — `
|
|||||||
|
|
||||||
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
|
The syncable subset of Lotus settings (theme, composer toolbar order, notification/quiet-hour preferences, call keys, privacy toggles, …) is mirrored to the `io.lotus.settings` account-data event on the user's own homeserver and applied on every other device. Device-bound keys stay local (`DEVICE_LOCAL_KEYS` in `src/app/utils/settingsSync.ts`: page zoom, media auto-load, animation pause, glassmorphism, noise-suppression tier/model, bitrates, volumes, notification permission, developer tools, PTT mode, camera-on-join, drawer state). Conflicts are last-write-wins on an `updatedAt` stamp forced monotonic per device; a per-account `lastSyncedAt` marker in localStorage stops a device from echoing a snapshot it just applied. **Settings → General → Sync** has the toggle (itself device-local), **Push now** (make this device win everywhere) and **Clear synced copy**. Hook: `src/app/hooks/useSettingsSync.ts`, mounted from `ClientNonUIFeatures`.
|
||||||
|
|
||||||
|
### Push to Deafen: off switch + typing-safe
|
||||||
|
|
||||||
|
**Settings → Calls → Push to Deafen** now has a switch (off = no key toggles deafen; the call-bar headphone button remains). Root cause of the "I went deaf while typing" reports: Cinny's type-anywhere-to-focus-the-composer and the deafen key both listen on `window`, so with the default `M` the first letter of a message typed after clicking the timeline toggled deafen and was swallowed (`mom` → `om`). Rules now: a typable key (letter/digit/Space/…) only toggles deafen in the call view — on any screen with a composer, typing wins; and such keys are never bound system-wide on desktop (`isSafeGlobalToggleKey`: F-keys, numpad, lock/navigation cluster qualify), so the letter `m` typed in Discord or a game can't deafen you either. The tile explains this and suggests an F-key/Numpad key for an everywhere binding.
|
||||||
|
|
||||||
### Forwarded messages show their provenance
|
### Forwarded messages show their provenance
|
||||||
|
|
||||||
A forwarded message used to arrive as if the forwarder had written it. `buildForwardContent` now stamps `io.lotus.forwarded` (`sender`, `origin_server_ts`, `room_id`, `event_id`; forwarding a forward keeps the _original_ stamp) and the timeline (main + threads) renders a reply-style line above the message — **↪ Forwarded from bob in Other Room · 9:05 PM** — which is a button that jumps to the original when you are in the source room; if you are not, it shows only the sender and time (the source room's name is deliberately not shared). Other Matrix clients ignore the key and see the plain content. Component: `src/app/components/message/ForwardedHeader.tsx`.
|
A forwarded message used to arrive as if the forwarder had written it. `buildForwardContent` now stamps `io.lotus.forwarded` (`sender`, `origin_server_ts`, `room_id`, `event_id`; forwarding a forward keeps the _original_ stamp) and the timeline (main + threads) renders a reply-style line above the message — **↪ Forwarded from bob in Other Room · 9:05 PM** — which is a button that jumps to the original when you are in the source room; if you are not, it shows only the sender and time (the source room's name is deliberately not shared). Other Matrix clients ignore the key and see the plain content. Component: `src/app/components/message/ForwardedHeader.tsx`.
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine
|
|||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||||||
|
import { isSafeGlobalToggleKey } from '../../../hooks/useCallHotkeys';
|
||||||
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { useReducedMotion } from '../../../hooks/useReducedMotion';
|
import { useReducedMotion } from '../../../hooks/useReducedMotion';
|
||||||
@@ -1644,6 +1645,7 @@ function Calls() {
|
|||||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
|
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
|
||||||
const [globalCallHotkeys, setGlobalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
|
const [globalCallHotkeys, setGlobalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
|
||||||
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
||||||
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||||
@@ -1962,20 +1964,36 @@ function Calls() {
|
|||||||
<SettingTile
|
<SettingTile
|
||||||
title="Push to Deafen"
|
title="Push to Deafen"
|
||||||
description={
|
description={
|
||||||
deafenBind.error ?? 'Toggle speaker mute during a call. Press Escape to cancel rebind.'
|
deafenBind.error ??
|
||||||
|
(deafenHotkey
|
||||||
|
? `Press ${keyLabel(deafenKey)} during a call to mute your speakers. Press Escape to cancel rebind.${
|
||||||
|
!isSafeGlobalToggleKey(deafenKey)
|
||||||
|
? ` A letter, digit or Space only works in the call view — on a chat screen typing takes priority${
|
||||||
|
isTauriEnv() && globalCallHotkeys ? ' and other apps are never watched' : ''
|
||||||
|
}. Bind an F-key or Numpad key to make it work everywhere.`
|
||||||
|
: ''
|
||||||
|
}`
|
||||||
|
: 'Off — no key toggles deafen. Use the headphone button in the call bar.')
|
||||||
}
|
}
|
||||||
after={
|
after={
|
||||||
<Button
|
<Box alignItems="Center" gap="200">
|
||||||
size="300"
|
<Switch variant="Primary" value={deafenHotkey} onChange={setDeafenHotkey} />
|
||||||
variant={deafenBind.listening ? 'Warning' : 'Secondary'}
|
{deafenHotkey && (
|
||||||
fill={deafenBind.listening ? 'Solid' : 'Soft'}
|
<Button
|
||||||
radii="300"
|
size="300"
|
||||||
outlined
|
variant={deafenBind.listening ? 'Warning' : 'Secondary'}
|
||||||
onClick={deafenBind.startListening}
|
fill={deafenBind.listening ? 'Solid' : 'Soft'}
|
||||||
style={{ minWidth: '90px' }}
|
radii="300"
|
||||||
>
|
outlined
|
||||||
<Text size="B300">{deafenBind.listening ? 'Press a key…' : keyLabel(deafenKey)}</Text>
|
onClick={deafenBind.startListening}
|
||||||
</Button>
|
style={{ minWidth: '90px' }}
|
||||||
|
>
|
||||||
|
<Text size="B300">
|
||||||
|
{deafenBind.listening ? 'Press a key…' : keyLabel(deafenKey)}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{isTauriEnv() && (
|
{isTauriEnv() && (
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { isDeafenKeyDown, isPttKeyDown, shouldActOnGlobalHotkey } from './useCallHotkeys';
|
import {
|
||||||
|
isDeafenKeyDown,
|
||||||
|
isPttKeyDown,
|
||||||
|
isSafeGlobalToggleKey,
|
||||||
|
shouldActOnGlobalHotkey,
|
||||||
|
} from './useCallHotkeys';
|
||||||
|
|
||||||
const key = (
|
const key = (
|
||||||
code: string,
|
code: string,
|
||||||
@@ -50,3 +55,36 @@ test('shouldActOnGlobalHotkey ignores Ctrl/Alt/Meta chords', () => {
|
|||||||
assert.equal(shouldActOnGlobalHotkey(ev({ alt: true })), false);
|
assert.equal(shouldActOnGlobalHotkey(ev({ alt: true })), false);
|
||||||
assert.equal(shouldActOnGlobalHotkey(ev({ meta: true })), false);
|
assert.equal(shouldActOnGlobalHotkey(ev({ meta: true })), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isSafeGlobalToggleKey: only keys nobody types prose with may toggle deafen system-wide', () => {
|
||||||
|
// typed-in-other-apps keys — never global
|
||||||
|
for (const code of [
|
||||||
|
'KeyM',
|
||||||
|
'KeyA',
|
||||||
|
'Digit1',
|
||||||
|
'Space',
|
||||||
|
'Enter',
|
||||||
|
'Backspace',
|
||||||
|
'Tab',
|
||||||
|
'Comma',
|
||||||
|
'Slash',
|
||||||
|
'ShiftLeft',
|
||||||
|
'Escape',
|
||||||
|
]) {
|
||||||
|
assert.equal(isSafeGlobalToggleKey(code), false, code);
|
||||||
|
}
|
||||||
|
// dedicated keys — fine
|
||||||
|
for (const code of [
|
||||||
|
'F5',
|
||||||
|
'F13',
|
||||||
|
'Numpad0',
|
||||||
|
'NumpadAdd',
|
||||||
|
'ScrollLock',
|
||||||
|
'Pause',
|
||||||
|
'Insert',
|
||||||
|
'PageDown',
|
||||||
|
'CapsLock',
|
||||||
|
]) {
|
||||||
|
assert.equal(isSafeGlobalToggleKey(code), true, code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -57,6 +57,22 @@ export const isPttKeyDown = (e: KeyLike, pttKey: string): boolean =>
|
|||||||
export const isDeafenKeyDown = (e: KeyLike, deafenKey: string): boolean =>
|
export const isDeafenKeyDown = (e: KeyLike, deafenKey: string): boolean =>
|
||||||
e.code === deafenKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey;
|
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
|
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
|
||||||
const isEditable = (el: HTMLElement): boolean => {
|
const isEditable = (el: HTMLElement): boolean => {
|
||||||
const tag = el.tagName;
|
const tag = el.tagName;
|
||||||
@@ -127,6 +143,7 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
|
const [deafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
|
||||||
const [globalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
|
const [globalCallHotkeys] = useSetting(settingsAtom, 'globalCallHotkeys');
|
||||||
const { microphone } = useCallControlState(embed?.control);
|
const { microphone } = useCallControlState(embed?.control);
|
||||||
const setPttActive = useSetAtom(pttActiveAtom);
|
const setPttActive = useSetAtom(pttActiveAtom);
|
||||||
@@ -225,13 +242,17 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
if (!isTauri() || !embed || !globalCallHotkeys) return undefined;
|
if (!isTauri() || !embed || !globalCallHotkeys) return undefined;
|
||||||
const bindings = [
|
const bindings = [
|
||||||
...(pttMode ? [{ id: 'ptt', code: pttKey }] : []),
|
...(pttMode ? [{ id: 'ptt', code: pttKey }] : []),
|
||||||
{ id: 'deafen', code: deafenKey },
|
// 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 });
|
invokeTauri('set_global_hotkeys', { bindings });
|
||||||
return () => {
|
return () => {
|
||||||
invokeTauri('set_global_hotkeys', { bindings: [] });
|
invokeTauri('set_global_hotkeys', { bindings: [] });
|
||||||
};
|
};
|
||||||
}, [embed, globalCallHotkeys, pttMode, pttKey, deafenKey]);
|
}, [embed, globalCallHotkeys, pttMode, pttKey, deafenKey, deafenHotkey]);
|
||||||
useTauriEvent<GlobalHotkeyEvent>('lotus-global-hotkey', (detail) => {
|
useTauriEvent<GlobalHotkeyEvent>('lotus-global-hotkey', (detail) => {
|
||||||
const current = embedRef.current;
|
const current = embedRef.current;
|
||||||
if (!current || !globalCallHotkeys) return;
|
if (!current || !globalCallHotkeys) return;
|
||||||
@@ -258,10 +279,23 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!embed) return undefined;
|
if (!embed || !deafenHotkey) return undefined;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isDeafenKeyDown(e, deafenKey)) return;
|
if (!isDeafenKeyDown(e, deafenKey)) return;
|
||||||
if (isEditable(e.target as HTMLElement)) 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();
|
e.preventDefault();
|
||||||
embed.control.toggleSound();
|
embed.control.toggleSound();
|
||||||
};
|
};
|
||||||
@@ -276,5 +310,5 @@ export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean
|
|||||||
window.removeEventListener('keydown', onKeyDown);
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
unbindIframe();
|
unbindIframe();
|
||||||
};
|
};
|
||||||
}, [embed, deafenKey]);
|
}, [embed, deafenKey, deafenHotkey]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ export interface Settings {
|
|||||||
glassmorphismSidebar: boolean;
|
glassmorphismSidebar: boolean;
|
||||||
|
|
||||||
deafenKey: string;
|
deafenKey: string;
|
||||||
|
/** Master switch for the push-to-deafen key (both in-window and system-wide). */
|
||||||
|
deafenHotkey: boolean;
|
||||||
|
|
||||||
warnOnUnverifiedDevices: boolean;
|
warnOnUnverifiedDevices: boolean;
|
||||||
|
|
||||||
@@ -381,6 +383,7 @@ const defaultSettings: Settings = {
|
|||||||
glassmorphismSidebar: false,
|
glassmorphismSidebar: false,
|
||||||
|
|
||||||
deafenKey: 'KeyM',
|
deafenKey: 'KeyM',
|
||||||
|
deafenHotkey: true,
|
||||||
|
|
||||||
warnOnUnverifiedDevices: false,
|
warnOnUnverifiedDevices: false,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user