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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user