feat: knock notifications for admins + AFK auto-mute in calls
CI / Build & Quality Checks (push) Successful in 10m26s
Trigger Desktop Build / trigger (push) Successful in 14s

P4-3 — Knock-to-join Notifications for Admins:
- usePendingKnocks() hook reactively tracks knocking members via
  RoomMemberEvent.Membership; returns empty array if user lacks invite power
- Members icon in RoomViewHeader shows a Warning badge with the knock count
  when there are pending requests; badge updates in real time without
  needing to open the drawer; aria-label updated to describe pending count

P5-11 — AFK Auto-Mute in Voice:
- useAfkAutoMute() hook opens a monitoring-only getUserMedia stream,
  connects it to an AnalyserNode, and polls RMS every 500ms
- If mic is on and RMS stays below threshold for the configured timeout,
  calls callEmbed.control.setMicrophone(false) and shows an in-app toast
- Hook is called inside CallControls so monitoring is only active during calls
- Settings: afkAutoMute toggle + afkTimeoutMinutes select (1/5/10/20/30 min,
  default 10) added to Settings → Calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 19:53:19 -04:00
parent f15c4caf97
commit 362f4943d4
6 changed files with 206 additions and 8 deletions
+3
View File
@@ -35,6 +35,7 @@ import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
type CallControlsProps = {
callEmbed: CallEmbed;
@@ -71,6 +72,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
useCallControlState(callEmbed.control);
useAfkAutoMute(callEmbed);
const [cords, setCords] = useState<RectCords>();
const [shareConfirm, setShareConfirm] = useState(false);
useEffect(() => {
+41 -8
View File
@@ -73,6 +73,7 @@ import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { MediaGallery } from './MediaGallery';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
type RoomMenuProps = {
room: Room;
@@ -433,6 +434,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useState(false);
const pendingKnocks = usePendingKnocks(room);
const handleSearchClick = () => {
const searchParams: _SearchPathSearchParams = {
@@ -682,14 +684,45 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
aria-label="Toggle member list"
>
<Icon size="400" src={Icons.User} />
</IconButton>
<div style={{ position: 'relative', display: 'inline-flex' }}>
<IconButton
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
aria-label={
pendingKnocks.length > 0
? `Toggle member list, ${pendingKnocks.length} pending join request${pendingKnocks.length > 1 ? 's' : ''}`
: 'Toggle member list'
}
>
<Icon size="400" src={Icons.User} />
</IconButton>
{pendingKnocks.length > 0 && (
<Badge
aria-hidden
variant="Warning"
fill="Solid"
radii="Pill"
size="200"
style={{
position: 'absolute',
top: '-2px',
right: '-2px',
pointerEvents: 'none',
fontSize: '9px',
minWidth: '14px',
height: '14px',
padding: '0 3px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 1,
}}
>
{pendingKnocks.length > 9 ? '9+' : pendingKnocks.length}
</Badge>
)}
</div>
)}
</TooltipProvider>
)}
@@ -1111,6 +1111,8 @@ function Calls() {
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
const pttBind = useKeyBind(setPttKey);
const deafenBind = useKeyBind(setDeafenKey);
@@ -1186,6 +1188,45 @@ function Calls() {
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="AFK Auto-Mute"
description="Automatically mute your microphone when you have been silent in a call."
after={<Switch variant="Primary" value={afkAutoMute} onChange={setAfkAutoMute} />}
/>
{afkAutoMute && (
<SettingTile
title="Idle Timeout"
description="How long to wait before auto-muting."
after={
<select
value={afkTimeoutMinutes}
onChange={(e) => setAfkTimeoutMinutes(Number(e.target.value))}
style={{
background: 'var(--bg-surface)',
color: 'inherit',
border: '1px solid var(--border-interactive-normal)',
borderRadius: '6px',
padding: '4px 8px',
fontSize: 'inherit',
cursor: 'pointer',
}}
>
<option value={1}>1 minute</option>
<option value={5}>5 minutes</option>
<option value={10}>10 minutes</option>
<option value={20}>20 minutes</option>
<option value={30}>30 minutes</option>
</select>
}
/>
)}
</SequenceCard>
</Box>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect } from 'react';
import { useSetAtom } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { toastQueueAtom } from '../state/toast';
const SILENCE_RMS_THRESHOLD = 0.008;
const CHECK_INTERVAL_MS = 500;
/**
* Monitors microphone audio while in a call. If the mic stays active but
* silent for longer than the configured timeout, the mic is muted and a
* toast is shown. Cleans up its own AudioContext and stream on unmount.
*/
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
const setToast = useSetAtom(toastQueueAtom);
useEffect(() => {
if (!callEmbed || !enabled) return;
let stream: MediaStream | undefined;
let audioCtx: AudioContext | undefined;
let intervalId: ReturnType<typeof setInterval> | undefined;
let silenceStart: number | null = null;
let active = true;
const timeoutMs = timeoutMinutes * 60 * 1000;
navigator.mediaDevices
.getUserMedia({ audio: true, video: false })
.then((s) => {
if (!active) {
s.getTracks().forEach((t) => t.stop());
return;
}
stream = s;
audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
const buffer = new Float32Array(analyser.fftSize);
intervalId = setInterval(() => {
if (!active) return;
analyser.getFloatTimeDomainData(buffer);
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
if (rms > SILENCE_RMS_THRESHOLD) {
// Audio detected — reset the silence timer
silenceStart = null;
} else if (callEmbed.control.microphone) {
// Mic is on but silent — start or advance the timer
if (silenceStart === null) silenceStart = Date.now();
else if (Date.now() - silenceStart >= timeoutMs) {
callEmbed.control.setMicrophone(false);
setToast({
id: `afk-mute-${Date.now()}`,
displayName: 'Lotus Chat',
body: 'Your microphone was muted after inactivity.',
roomName: 'Voice call',
roomId: callEmbed.roomId,
});
silenceStart = null;
}
} else {
// Mic is already muted — don't count silence
silenceStart = null;
}
}, CHECK_INTERVAL_MS);
})
.catch(() => undefined);
return () => {
active = false;
if (intervalId !== undefined) clearInterval(intervalId);
stream?.getTracks().forEach((t) => t.stop());
audioCtx?.close().catch(() => undefined);
};
}, [callEmbed, enabled, timeoutMinutes, setToast]);
}
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useReducer } from 'react';
import { MatrixEvent, Room, RoomMember, RoomMemberEvent } from 'matrix-js-sdk';
import { Membership } from '../../types/matrix/room';
import { useMatrixClient } from './useMatrixClient';
import { readPowerLevel, usePowerLevelsContext } from './usePowerLevels';
/**
* Returns the list of members currently knocking on the room, reactively.
* Returns an empty array if the current user lacks invite power level.
*/
export function usePendingKnocks(room: Room): RoomMember[] {
const mx = useMatrixClient();
const powerLevels = usePowerLevelsContext();
const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
useEffect(() => {
const handler = (_evt: MatrixEvent, member: RoomMember) => {
if (member.roomId === room.roomId) forceUpdate();
};
mx.on(RoomMemberEvent.Membership, handler);
return () => {
mx.removeListener(RoomMemberEvent.Membership, handler);
};
}, [mx, room.roomId]);
const myUserId = mx.getUserId();
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
const canApprove = myPowerLevel >= invitePowerLevel;
return canApprove ? room.getMembersWithMembership(Membership.Knock) : [];
}
+6
View File
@@ -128,6 +128,9 @@ export interface Settings {
mentionHighlightColor: string;
fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code';
afkAutoMute: boolean;
afkTimeoutMinutes: number;
}
const defaultSettings: Settings = {
@@ -198,6 +201,9 @@ const defaultSettings: Settings = {
mentionHighlightColor: '',
fontFamily: 'inter',
afkAutoMute: false,
afkTimeoutMinutes: 10,
};
export const getSettings = (): Settings => {