feat: knock notifications for admins + AFK auto-mute in calls
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:
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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) : [];
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user