diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index 1b9e73f5d..be29dcf94 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -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(); const [shareConfirm, setShareConfirm] = useState(false); useEffect(() => { diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 33e5cfe72..183fee06a 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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) => ( - - - +
+ 0 + ? `Toggle member list, ${pendingKnocks.length} pending join request${pendingKnocks.length > 1 ? 's' : ''}` + : 'Toggle member list' + } + > + + + {pendingKnocks.length > 0 && ( + + {pendingKnocks.length > 9 ? '9+' : pendingKnocks.length} + + )} +
)} )} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 166ae44be..d4155682e 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -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() { } /> + + } + /> + {afkAutoMute && ( + 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', + }} + > + + + + + + + } + /> + )} + ); } diff --git a/src/app/hooks/useAfkAutoMute.ts b/src/app/hooks/useAfkAutoMute.ts new file mode 100644 index 000000000..073c8ba05 --- /dev/null +++ b/src/app/hooks/useAfkAutoMute.ts @@ -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 | 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]); +} diff --git a/src/app/hooks/usePendingKnocks.ts b/src/app/hooks/usePendingKnocks.ts new file mode 100644 index 000000000..da815eb45 --- /dev/null +++ b/src/app/hooks/usePendingKnocks.ts @@ -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) : []; +} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 9d7881908..487dc02fb 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -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 => {