diff --git a/src/app/hooks/useCallJoinLeaveSounds.ts b/src/app/hooks/useCallJoinLeaveSounds.ts index 0c54dc311..535802d84 100644 --- a/src/app/hooks/useCallJoinLeaveSounds.ts +++ b/src/app/hooks/useCallJoinLeaveSounds.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; -import { CallEmbed } from '../plugins/call'; +import { useSetAtom } from 'jotai'; +import { CallEmbed, useCallControlState } from '../plugins/call'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { useMatrixClient } from './useMatrixClient'; @@ -8,6 +9,9 @@ import { useCallMembersChange, useCallSession } from './useCall'; import { useCallJoined } from './useCallEmbed'; import { playCallJoinSound, playCallLeaveSound } from '../utils/callSounds'; import { createCallSoundDebouncer } from '../utils/callSoundDebounce'; +import { toastQueueAtom } from '../state/toast'; +import { DEAFEN_CATCHUP_MIN_MS, deafenCatchUpText } from '../utils/deafenCatchUp'; +import { getMemberName } from '../utils/room'; const membershipKey = (m: CallMembership): string => `${m.sender}|${m.deviceId}`; const userOfKey = (key: string): string => key.slice(0, key.indexOf('|')); @@ -30,6 +34,40 @@ export function useCallJoinLeaveSounds(embed: CallEmbed): void { const styleRef = useRef(style); styleRef.current = style; + // [Gitea #128] Undeafen catch-up: snapshot who is here when you deafen, diff + // when you undeafen, one toast if it changed and you were deafened ≥ 10 s. + // Rides the same membership stream as the sounds — no new subscriptions. + const { sound } = useCallControlState(embed.control); + const setToast = useSetAtom(toastQueueAtom); + const usersNow = useRef>(new Set()); + const deafenedAt = useRef<{ at: number; users: Set } | null>(null); + useEffect(() => { + if (!joined) { + deafenedAt.current = null; + return; + } + if (!sound) { + if (!deafenedAt.current) + deafenedAt.current = { at: Date.now(), users: new Set(usersNow.current) }; + return; + } + const snap = deafenedAt.current; + deafenedAt.current = null; + if (!snap || Date.now() - snap.at < DEAFEN_CATCHUP_MIN_MS) return; + const text = deafenCatchUpText(snap.users, usersNow.current, (u) => + getMemberName(embed.room, u), + ); + if (text) { + setToast({ + id: `deafen-catchup-${Date.now()}`, + displayName: 'Lotus Chat', + body: text, + roomName: embed.room.name ?? 'Voice call', + roomId: embed.roomId, + }); + } + }, [sound, joined, embed, setToast]); + // One debouncer per joined call, so pending leave cues die with the call. const debouncerRef = useRef | null>(null); useEffect(() => { @@ -62,6 +100,9 @@ export function useCallJoinLeaveSounds(embed: CallEmbed): void { const next = new Set(members.map(membershipKey)); const prev = prevKeysRef.current ?? next; prevKeysRef.current = next; + usersNow.current = new Set( + Array.from(next, userOfKey).filter((u) => u !== mx.getSafeUserId()), + ); const debouncer = debouncerRef.current; if (!joined || style === 'off' || !debouncer) return; diff --git a/src/app/utils/deafenCatchUp.test.ts b/src/app/utils/deafenCatchUp.test.ts new file mode 100644 index 000000000..33903ff72 --- /dev/null +++ b/src/app/utils/deafenCatchUp.test.ts @@ -0,0 +1,17 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { deafenCatchUpText } from './deafenCatchUp'; + +const name = (u: string) => u.slice(1, u.indexOf(':')); + +test('no change → nothing; joins and leaves are listed, capped at 3 + N more', () => { + assert.equal(deafenCatchUpText(new Set(['@a:x']), new Set(['@a:x']), name), null); + assert.equal( + deafenCatchUpText(new Set(['@a:x', '@c:x']), new Set(['@a:x', '@b:x']), name), + 'While you were deafened: b joined · c left', + ); + assert.equal( + deafenCatchUpText(new Set(), new Set(['@a:x', '@b:x', '@c:x', '@d:x', '@e:x']), name), + 'While you were deafened: a, b, c and 2 more joined', + ); +}); diff --git a/src/app/utils/deafenCatchUp.ts b/src/app/utils/deafenCatchUp.ts new file mode 100644 index 000000000..f0e5294bd --- /dev/null +++ b/src/app/utils/deafenCatchUp.ts @@ -0,0 +1,27 @@ +/** + * [Gitea #128] "While you were deafened: Alice, Bob joined · Cole left". + * Pure diff + wording; the hook snapshots the participant set on deafen and + * calls this on undeafen. + */ +export const DEAFEN_CATCHUP_MIN_MS = 10_000; +const MAX_NAMES = 3; + +const list = (names: string[]): string => { + const shown = names.slice(0, MAX_NAMES); + const more = names.length - shown.length; + return more > 0 ? `${shown.join(', ')} and ${more} more` : shown.join(', '); +}; + +export const deafenCatchUpText = ( + before: Set, + after: Set, + nameOf: (userId: string) => string, +): string | null => { + const joined = Array.from(after).filter((u) => !before.has(u)); + const left = Array.from(before).filter((u) => !after.has(u)); + if (joined.length === 0 && left.length === 0) return null; + const parts: string[] = []; + if (joined.length) parts.push(`${list(joined.map(nameOf))} joined`); + if (left.length) parts.push(`${list(left.map(nameOf))} left`); + return `While you were deafened: ${parts.join(' · ')}`; +};