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
This commit is contained in:
@@ -48,6 +48,7 @@ import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
|||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
||||||
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
||||||
|
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
||||||
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||||
import { SrOnly } from '../features/call-status/styles.css';
|
import { SrOnly } from '../features/call-status/styles.css';
|
||||||
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
||||||
@@ -744,6 +745,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|||||||
useCallJoinLeaveSounds(embed);
|
useCallJoinLeaveSounds(embed);
|
||||||
useCallPolicyRevokedToast(embed, joined);
|
useCallPolicyRevokedToast(embed, joined);
|
||||||
useCallAnnouncements(embed, joined);
|
useCallAnnouncements(embed, joined);
|
||||||
|
useMutedTalkWarning(embed, joined);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
useCallQuality(embed);
|
useCallQuality(embed);
|
||||||
useCallHangupEvent(
|
useCallHangupEvent(
|
||||||
|
|||||||
@@ -1648,6 +1648,7 @@ function Calls() {
|
|||||||
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
|
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 [mutedTalkWarning, setMutedTalkWarning] = useSetting(settingsAtom, 'mutedTalkWarning');
|
||||||
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||||
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
|
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
@@ -2012,6 +2013,13 @@ function Calls() {
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Warn me when I talk while muted"
|
||||||
|
description="Shows a one-time 'You're muted' nudge with an Unmute button when the mic hears you but is muted. Never while Push to Talk is on."
|
||||||
|
after={
|
||||||
|
<Switch variant="Primary" value={mutedTalkWarning} onChange={setMutedTalkWarning} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="AFK Auto-Mute"
|
title="AFK Auto-Mute"
|
||||||
description="Automatically mute your microphone when you have been silent in a call."
|
description="Automatically mute your microphone when you have been silent in a call."
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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]);
|
||||||
|
}
|
||||||
@@ -45,6 +45,8 @@ export interface LotusCallParticipant {
|
|||||||
speaking: boolean;
|
speaking: boolean;
|
||||||
audioEnabled: boolean;
|
audioEnabled: boolean;
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
|
/** [lotus #37] Local participant only: voice on the mic while it is muted. */
|
||||||
|
speakingWhileMuted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CallEmbed {
|
export class CallEmbed {
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ export interface Settings {
|
|||||||
fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code';
|
fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code';
|
||||||
|
|
||||||
afkAutoMute: boolean;
|
afkAutoMute: boolean;
|
||||||
|
/** [Gitea #117] 'You're muted' nudge when talking into a muted mic. */
|
||||||
|
mutedTalkWarning: boolean;
|
||||||
afkTimeoutMinutes: number;
|
afkTimeoutMinutes: number;
|
||||||
|
|
||||||
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
|
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
|
||||||
@@ -402,6 +404,7 @@ const defaultSettings: Settings = {
|
|||||||
fontFamily: 'inter',
|
fontFamily: 'inter',
|
||||||
|
|
||||||
afkAutoMute: false,
|
afkAutoMute: false,
|
||||||
|
mutedTalkWarning: true,
|
||||||
afkTimeoutMinutes: 10,
|
afkTimeoutMinutes: 10,
|
||||||
|
|
||||||
callJoinLeaveSound: 'chime',
|
callJoinLeaveSound: 'chime',
|
||||||
|
|||||||
Reference in New Issue
Block a user