fix(dp): address TPVR review findings (DP2 redo, DP4 dedup, DP15 gap)

- DP2: the earlier seed fix was ineffective (allInvitesAtom populates post-mount,
  so first render is still empty). Rewrite to track invite room ids and stay
  'unarmed' until the initial sync settles (+3s grace), notifying only for ids that
  first appear after arming — robust to the async population race.
- DP4: batch the mutually-exclusive tag writes via Promise.all so a failure surfaces
  a single toast instead of one per operation.
- DP15: route the two remaining StateEvent writes (RoomSoundboardPack / RoomImagePack,
  which used an 'as unknown as keyof StateEvents' idiom the sweep missed) through the
  typed sendStateEvent helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 00:02:04 -04:00
co-authored by Claude Opus 4.8
parent 4fa4327a18
commit c2598d21cd
4 changed files with 78 additions and 39 deletions
+64 -22
View File
@@ -2,10 +2,13 @@ import { useAtomValue, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ClientEvent,
ClientEventHandlerMap,
MatrixEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
SyncState,
Thread,
ThreadEvent,
} from 'matrix-js-sdk';
@@ -22,7 +25,10 @@ import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { usePreviousValue } from '../../hooks/usePreviousValue';
// Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites.
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
import { mDirectAtom } from '../../state/mDirectList';
@@ -134,13 +140,15 @@ function FaviconUpdater() {
function InviteNotifications() {
const audioRef = useRef<HTMLAudioElement>(null);
const invites = useAtomValue(allInvitesAtom);
// Seed the baseline with the invite count present at mount, NOT 0. allInvitesAtom
// populates synchronously from cache on a warm reload while sync is already
// SYNCING, so a 0 seed made every pre-existing invite look "new" and re-fired the
// toast+sound on each reload. Starting from the current length means only a
// genuine INCREASE (a newly-arrived invite) notifies.
const perviousInviteLen = usePreviousValue(invites.length, invites.length);
const mx = useMatrixClient();
// Notify only for invites that ARRIVE while the app runs — never for invites
// already present at load. allInvitesAtom (a string[] of invited room ids)
// populates asynchronously post-mount from the initial/cached sync, so any count/
// seed baseline mis-fires on reload. Stay "unarmed" until the initial sync settles
// (keeping the id baseline synced to whatever loads), then notify only for room
// ids that first appear AFTER arming.
const armedRef = useRef(false);
const knownInviteIdsRef = useRef<Set<string>>(new Set());
const navigate = useNavigate();
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
@@ -204,26 +212,60 @@ function InviteNotifications() {
audioElement?.play();
}, []);
// Arm once the client's initial sync has settled (+ a short grace so the async
// atom population lands first). Until armed, the effect below only tracks the
// baseline of already-present invites without notifying.
useEffect(() => {
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
const quietActive =
focusAssistActive ||
manualDnd ||
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (!quietActive) {
if (showNotifications && notificationPermission('granted')) {
notify(invites.length - perviousInviteLen);
}
if (notificationSound && inviteSoundId !== 'none') {
playSound();
}
let timer: ReturnType<typeof setTimeout> | undefined;
const arm = () => {
timer = setTimeout(() => {
armedRef.current = true;
}, INVITE_NOTIFY_ARM_DELAY_MS);
};
if (mx.getSyncState() === SyncState.Syncing) {
arm();
return () => {
if (timer) clearTimeout(timer);
};
}
const onSync: ClientEventHandlerMap[ClientEvent.Sync] = (state) => {
if (state === SyncState.Syncing) {
mx.off(ClientEvent.Sync, onSync);
arm();
}
};
mx.on(ClientEvent.Sync, onSync);
return () => {
mx.off(ClientEvent.Sync, onSync);
if (timer) clearTimeout(timer);
};
}, [mx]);
useEffect(() => {
const currentIds = new Set(invites);
if (!armedRef.current) {
// Not yet armed: keep the baseline synced to whatever the initial sync loads.
knownInviteIdsRef.current = currentIds;
return;
}
const newCount = invites.filter((id) => !knownInviteIdsRef.current.has(id)).length;
knownInviteIdsRef.current = currentIds;
if (newCount <= 0) return;
const quietActive =
focusAssistActive ||
manualDnd ||
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (quietActive) return;
if (showNotifications && notificationPermission('granted')) {
notify(newCount);
}
if (notificationSound && inviteSoundId !== 'none') {
playSound();
}
}, [
mx,
invites,
perviousInviteLen,
showNotifications,
notificationSound,
notify,