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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user