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:
@@ -5,6 +5,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { ImagePackContent } from './ImagePackContent';
|
||||
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../utils/room';
|
||||
import { useRoomImagePack } from '../../hooks/useImagePacks';
|
||||
import { randomStr } from '../../utils/common';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
@@ -45,12 +46,7 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
|
||||
const { address } = imagePack;
|
||||
if (!address) return;
|
||||
|
||||
await mx.sendStateEvent(
|
||||
address.roomId,
|
||||
StateEvent.PoniesRoomEmotes as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||
packContent as any,
|
||||
address.stateKey,
|
||||
);
|
||||
await sendStateEvent(mx, address.roomId, StateEvent.PoniesRoomEmotes, packContent, address.stateKey);
|
||||
},
|
||||
[mx, imagePack],
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { SoundboardPackEditor } from './SoundboardPackEditor';
|
||||
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../utils/room';
|
||||
import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks';
|
||||
import { PackAddress } from '../../plugins/custom-emoji/PackAddress';
|
||||
import { randomStr } from '../../utils/common';
|
||||
@@ -35,12 +36,7 @@ export function RoomSoundboardPack({ room, stateKey }: RoomSoundboardPackProps)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async (content: SoundboardContent) => {
|
||||
await mx.sendStateEvent(
|
||||
room.roomId,
|
||||
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||
content as never,
|
||||
stateKey,
|
||||
);
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.LotusSoundboardRoom, content, stateKey);
|
||||
},
|
||||
[mx, room.roomId, stateKey],
|
||||
);
|
||||
|
||||
@@ -344,9 +344,11 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
if (isFavorite) {
|
||||
mx.deleteRoomTag(room.roomId, 'm.favourite').catch(notifyTagFailure);
|
||||
} else {
|
||||
// Favourite and low-priority are mutually exclusive.
|
||||
if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority').catch(notifyTagFailure);
|
||||
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 }).catch(notifyTagFailure);
|
||||
// Favourite and low-priority are mutually exclusive. Batch both writes so a
|
||||
// failure surfaces a single toast, not one per operation.
|
||||
const ops: Promise<unknown>[] = [mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 })];
|
||||
if (isLowPriority) ops.push(mx.deleteRoomTag(room.roomId, 'm.lowpriority'));
|
||||
Promise.all(ops).catch(notifyTagFailure);
|
||||
}
|
||||
requestClose();
|
||||
};
|
||||
@@ -355,8 +357,11 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
if (isLowPriority) {
|
||||
mx.deleteRoomTag(room.roomId, 'm.lowpriority').catch(notifyTagFailure);
|
||||
} else {
|
||||
if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite').catch(notifyTagFailure);
|
||||
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 }).catch(notifyTagFailure);
|
||||
const ops: Promise<unknown>[] = [
|
||||
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 }),
|
||||
];
|
||||
if (isFavorite) ops.push(mx.deleteRoomTag(room.roomId, 'm.favourite'));
|
||||
Promise.all(ops).catch(notifyTagFailure);
|
||||
}
|
||||
requestClose();
|
||||
};
|
||||
|
||||
@@ -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') {
|
||||
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) {
|
||||
if (showNotifications && notificationPermission('granted')) {
|
||||
notify(invites.length - perviousInviteLen);
|
||||
}
|
||||
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