diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index fb85767c6..8ee389ccb 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -42,7 +42,7 @@ import { CallEmbed, useCallControlState } from '../plugins/call'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { useMatrixClient } from '../hooks/useMatrixClient'; -import { previewRingtone, startRingtone } from '../utils/ringtones'; +import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones'; import { useCallMembersChange, useCallSession } from '../hooks/useCall'; import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds'; import { useCallQuality } from '../hooks/useCallQuality'; @@ -703,6 +703,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control); + // C-L3 — prime the ringtone AudioContext on the first user gesture of the + // session so the first incoming-call ring isn't silent (a fresh context stays + // suspended until a gesture, and an incoming ring has none of its own). + useEffect(() => { + const prime = () => { + unlockRingtoneAudio(); + window.removeEventListener('pointerdown', prime); + window.removeEventListener('keydown', prime); + }; + window.addEventListener('pointerdown', prime, { once: true, passive: true }); + window.addEventListener('keydown', prime, { once: true, passive: true }); + return () => { + window.removeEventListener('pointerdown', prime); + window.removeEventListener('keydown', prime); + }; + }, []); + // Sync pip mode into CallControl so it can adjust behavior accordingly useEffect(() => { if (!callEmbed) return; diff --git a/src/app/hooks/useCallSpeakers.ts b/src/app/hooks/useCallSpeakers.ts index e12fca1f8..7412a147d 100644 --- a/src/app/hooks/useCallSpeakers.ts +++ b/src/app/hooks/useCallSpeakers.ts @@ -25,8 +25,14 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { const callMembers = useCallMembers(callSession); const joined = useCallJoined(callEmbed); + // C-L5 — depend on a STABLE boolean, not the callMembers array (whose identity + // changes on every membership change). The MutationObserver + io.lotus.call_state + // subscription below already track tiles joining/leaving live, so rebuilding + // them on each membership change is pure churn. + const hasCallMembers = callMembers.length > 0; + useEffect(() => { - if (!callMembers || !joined) { + if (!hasCallMembers || !joined) { setSpeakers(new Set()); return undefined; } @@ -126,7 +132,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set => { bodyWatcher?.disconnect(); unsubLotus(); }; - }, [callEmbed, callMembers, joined]); + }, [callEmbed, hasCallMembers, joined]); return speakers; }; diff --git a/src/app/hooks/useThreadNotifications.ts b/src/app/hooks/useThreadNotifications.ts index 8e81849c6..e0ef15da5 100644 --- a/src/app/hooks/useThreadNotifications.ts +++ b/src/app/hooks/useThreadNotifications.ts @@ -36,21 +36,19 @@ const getJoinedRoomIds = (mx: MatrixClient): Set => { return joined; }; -const writeThreadNotificationMode = async ( - mx: MatrixClient, +// Apply a single mode change to a base content object, returning a fresh clone +// (never mutates the input). +const applyThreadMode = ( + base: ThreadNotificationsContent, roomId: string, threadRootId: string, mode: ThreadNotificationMode, -): Promise => { - const current = readContent(mx); - const now = Date.now(); - - // Work on a mutable clone; prune produces a fresh object so the mutations - // below never touch the atom's/account-data's current content. + now: number, +): ThreadNotificationsContent => { const next: ThreadNotificationsContent = { - ...current, + ...base, rooms: Object.fromEntries( - Object.entries(current.rooms ?? {}).map(([rid, entries]) => [rid, { ...entries }]), + Object.entries(base.rooms ?? {}).map(([rid, entries]) => [rid, { ...entries }]), ), }; @@ -70,10 +68,46 @@ const writeThreadNotificationMode = async ( rooms[roomId][threadRootId] = { mode, ts: now }; } - // ALWAYS prune before persisting to keep account data bounded. - const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now); + return next; +}; - await setAccountData(mx, AccountDataEvent.LotusThreadNotifications, finalContent); +// T7 — serialize writes so rapid, overlapping mode changes don't lost-update +// each other. `setAccountData` is a bare PUT whose result doesn't reach the +// local store until the /sync echo, so back-to-back writes would otherwise all +// read the same stale base and clobber one another. Each queued write instead +// bases its mutation on the previous write's RESULT; once the queue drains the +// carried base is dropped so the next independent write re-reads fresh (possibly +// externally-changed) server state. +let writeChain: Promise = Promise.resolve(); +let pendingWrites = 0; +let carriedContent: ThreadNotificationsContent | null = null; + +const writeThreadNotificationMode = ( + mx: MatrixClient, + roomId: string, + threadRootId: string, + mode: ThreadNotificationMode, +): Promise => { + pendingWrites += 1; + const run = writeChain.then(async () => { + const now = Date.now(); + const base = carriedContent ?? readContent(mx); + const next = applyThreadMode(base, roomId, threadRootId, mode, now); + // ALWAYS prune before persisting to keep account data bounded. + const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now); + await setAccountData(mx, AccountDataEvent.LotusThreadNotifications, finalContent); + // Carry the result forward only on success, so a queued follow-up write bases + // on persisted content — never on a shape the server just rejected. + carriedContent = finalContent; + }); + // Keep the chain alive on error; drop the carried base once the queue drains. + writeChain = run + .catch(() => {}) + .finally(() => { + pendingWrites -= 1; + if (pendingWrites === 0) carriedContent = null; + }); + return run; }; export function useSetThreadNotificationMode( diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 2bafcffbb..d356a8a7f 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -644,13 +644,24 @@ function MessageNotifications() { const content = threadPrefs; const mode = getThreadNotificationMode(content, room.roomId, thread.id); const actions = mx.getPushActionsForEvent(mEvent); + // `hasCurrentUserParticipated` is derived from the server thread bundle, + // which lags a reply we just sent — so also treat any of our own events + // already in the thread timeline as participation (T5: avoid under-notify). + const myUserId = mx.getUserId(); + const participated = + thread.hasCurrentUserParticipated || + thread.timeline.some((e) => e.getSender() === myUserId); + const roomNotifType = getNotificationType(mx, room.roomId); const decision = shouldNotifyThreadReply({ mode, defaultBehavior: content.default ?? THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, - participated: thread.hasCurrentUserParticipated, + participated, highlight: !!actions?.tweaks?.highlight, notify: !!actions?.notify, - roomMuted: getNotificationType(mx, room.roomId) === NotificationType.Mute, + roomMuted: roomNotifType === NotificationType.Mute, + // T6: honor a room-level "Mentions & Keywords only" setting for Default + // threads instead of over-notifying every participated reply. + roomMentionsOnly: roomNotifType === NotificationType.MentionsAndKeywords, }); if (decision === 'none') return; diff --git a/src/app/utils/ringtones.ts b/src/app/utils/ringtones.ts index 1f2a71ccd..460688dab 100644 --- a/src/app/utils/ringtones.ts +++ b/src/app/utils/ringtones.ts @@ -29,6 +29,18 @@ const getCtx = (): AudioContext | undefined => { } }; +/** + * C-L3 — prime (create + resume) the shared ringtone AudioContext from within a + * user gesture. Browsers keep a fresh context suspended until a gesture, and an + * incoming-call ring fires with no gesture of its own, so the first ring after a + * cold page load could be silent (resume() is async and may not finish before + * the notes are scheduled). Call this on any early app gesture so a later ring + * plays through an already-running context. Mirrors `unlockCallSounds`. + */ +export const unlockRingtoneAudio = (): void => { + getCtx(); +}; + type Note = { freq: number; /** Offset from phrase start, in seconds */ @@ -173,6 +185,10 @@ const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => vo * silent. This matches the pre-existing behaviour of the classic ringtone. */ export const startRingtone = (id: RingtoneId, volume: number): (() => void) => { + // C-L2 — a real incoming ring supersedes any lingering Settings preview so the + // two don't overlap (the preview is otherwise only cleared by its own timer). + activePreviewStop?.(); + activePreviewStop = null; if (id === 'none') return () => undefined; if (id === 'classic') return startClassic(volume, true); return startSynth(id, volume, true); diff --git a/src/app/utils/threadNotifications.test.ts b/src/app/utils/threadNotifications.test.ts index 087e9807f..de0111ca9 100644 --- a/src/app/utils/threadNotifications.test.ts +++ b/src/app/utils/threadNotifications.test.ts @@ -23,6 +23,7 @@ const decide = ( highlight: false, notify: false, roomMuted: false, + roomMentionsOnly: false, ...overrides, }); @@ -53,6 +54,25 @@ describe('shouldNotifyThreadReply', () => { assert.equal(decide({ mode: ThreadNotificationMode.All, highlight: false }), 'notify'); }); + it('roomMentionsOnly: Default + participating + participated but no highlight => none', () => { + assert.equal(decide({ roomMentionsOnly: true, participated: true }), 'none'); + }); + + it('roomMentionsOnly: Default + highlight still notifies loudly', () => { + assert.equal(decide({ roomMentionsOnly: true, highlight: true }), 'loud'); + }); + + it('roomMentionsOnly does NOT suppress an explicit All override', () => { + assert.equal( + decide({ roomMentionsOnly: true, mode: ThreadNotificationMode.All, highlight: false }), + 'notify', + ); + }); + + it('roomMentionsOnly: Default + defaultBehavior all + no highlight => none', () => { + assert.equal(decide({ roomMentionsOnly: true, defaultBehavior: 'all' }), 'none'); + }); + it('mode MentionsOnly + highlight => loud', () => { assert.equal(decide({ mode: ThreadNotificationMode.MentionsOnly, highlight: true }), 'loud'); }); diff --git a/src/app/utils/threadNotifications.ts b/src/app/utils/threadNotifications.ts index 9b4ee2469..c5b9a3a12 100644 --- a/src/app/utils/threadNotifications.ts +++ b/src/app/utils/threadNotifications.ts @@ -106,8 +106,16 @@ export function shouldNotifyThreadReply(input: { highlight: boolean; notify: boolean; roomMuted: boolean; + /** + * The room is set to "Mentions & Keywords only" (room push rule, not the + * global default). When the thread mode is Default, this makes only + * highlights notify — honoring the room preference instead of the + * all/participating default (which otherwise over-notifies). An explicit + * per-thread All/MentionsOnly/Mute override still wins. + */ + roomMentionsOnly: boolean; }): ThreadNotifyDecision { - const { mode, defaultBehavior, participated, highlight, roomMuted } = input; + const { mode, defaultBehavior, participated, highlight, roomMuted, roomMentionsOnly } = input; if (roomMuted) return 'none'; if (mode === ThreadNotificationMode.Mute) return 'none'; @@ -120,13 +128,13 @@ export function shouldNotifyThreadReply(input: { return highlight ? 'loud' : 'none'; } - // ThreadNotificationMode.Default - if (defaultBehavior === 'all') { - return highlight ? 'loud' : 'notify'; - } - - // defaultBehavior === 'participating' + // ThreadNotificationMode.Default — highlights always notify loudly. if (highlight) return 'loud'; + // Room is "Mentions & Keywords only": a Default thread inherits that, so a + // non-highlight reply does not notify. + if (roomMentionsOnly) return 'none'; + if (defaultBehavior === 'all') return 'notify'; + // defaultBehavior === 'participating' return participated ? 'notify' : 'none'; } diff --git a/src/client/oidcTokenRefresher.ts b/src/client/oidcTokenRefresher.ts index fe5f74b72..3e7df256e 100644 --- a/src/client/oidcTokenRefresher.ts +++ b/src/client/oidcTokenRefresher.ts @@ -30,13 +30,23 @@ export class LotusOidcTokenRefresher extends OidcTokenRefresher { this.oidcRef = oidc; } + // F5 — persist the new expiry so the stored `expiresAt` stays fresh across + // reloads instead of going stale. The SDK invokes persistTokens synchronously + // inside the refresh and passes the freshly-refreshed `expiry` (a Date) on the + // tokens object at runtime, even though its published type omits it — so read + // it here directly (a doRefreshAccessToken override would run too late, since + // persistTokens is called before that method returns). protected async persistTokens(tokens: { accessToken: string; refreshToken?: string; + expiry?: Date; }): Promise { + const expiresInMs = + tokens.expiry instanceof Date ? Math.max(0, tokens.expiry.getTime() - Date.now()) : undefined; setFallbackSession(tokens.accessToken, this.deviceIdRef, this.userIdRef, this.baseUrlRef, { refreshToken: tokens.refreshToken, oidc: this.oidcRef, + expiresInMs, }); } }