Files
cinny/src/app/hooks/useMutedTalkWarning.ts
T
jaredandClaude Opus 5 73b1d1e3a7 feat(calls): 'You're muted' nudge when talking into a muted mic (#117)
Host half of the talking-while-muted feature. The fork (0.25.0-lotus.9)
reports speakingWhileMuted for the local participant in io.lotus.call_state —
a level tap on the muted track, so cinny captures no audio. useMutedTalkWarning
polls that flag while muted; after 1.5 s of it being true it shows one sticky
toast, "You're muted — click to unmute", whose click unmutes; it fires at most
once per mute session (re-arms after unmute → mute), is retired the moment the
mic comes back on by any means, and never runs in Push-to-Talk mode. Setting
'Warn me when I talk while muted' (default on) sits next to AFK Auto-Mute.

Verified headless with a tone mic: no toast at +1 s, toast at +4 s, click
unmutes and clears it, second mute re-arms, unmute via the button clears it,
PTT mode → nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 22:50:32 -04:00

71 lines
2.7 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { useSetAtom } from 'jotai';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { dismissToastAtom, toastQueueAtom } from '../state/toast';
import { useMatrixClient } from './useMatrixClient';
const HOLD_MS = 1500;
const CHECK_MS = 250;
/**
* [Gitea #117] "You're muted" — the fork reports `speakingWhileMuted` for the
* local participant in io.lotus.call_state (a level tap on the muted track;
* cinny captures no audio). Once it has been true for ≥ 1.5 s, one sticky
* toast with an Unmute action, at most once per mute session (re-arms after
* unmute → mute). Never in Push-to-Talk mode — being muted between holds is
* the point.
*/
export function useMutedTalkWarning(embed: CallEmbed | undefined, joined: boolean): void {
const mx = useMatrixClient();
const [enabled] = useSetting(settingsAtom, 'mutedTalkWarning');
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const { microphone } = useCallControlState(embed?.control);
const setToast = useSetAtom(toastQueueAtom);
const dismissToast = useSetAtom(dismissToastAtom);
const warnedThisMute = useRef(false);
const toastIdRef = useRef<string | null>(null);
// Unmuting (by any means) re-arms the warning and retires the toast.
useEffect(() => {
if (!microphone) return;
warnedThisMute.current = false;
if (toastIdRef.current) {
dismissToast(toastIdRef.current);
toastIdRef.current = null;
}
}, [microphone, dismissToast]);
useEffect(() => {
if (!embed || !joined || !enabled || pttMode || microphone) return undefined;
const me = mx.getSafeUserId();
let since: number | null = null;
const id = window.setInterval(() => {
if (warnedThisMute.current) return;
const local = embed.getLotusParticipants()?.find((p) => p.userId === me);
if (!local?.speakingWhileMuted) {
since = null;
return;
}
if (since === null) since = Date.now();
if (Date.now() - since < HOLD_MS) return;
warnedThisMute.current = true;
const toastId = `muted-talk-${Date.now()}`;
toastIdRef.current = toastId;
setToast({
id: toastId,
displayName: 'Lotus Chat',
body: "You're muted — click to unmute",
roomName: embed.room.name ?? 'Voice call',
roomId: embed.roomId,
sticky: true,
onClick: () => {
embed.control.setMicrophone(true);
},
});
}, CHECK_MS);
return () => window.clearInterval(id);
}, [embed, joined, enabled, pttMode, microphone, mx, setToast]);
}