diff --git a/src/app/components/image-pack-view/RoomImagePack.tsx b/src/app/components/image-pack-view/RoomImagePack.tsx index c810df13a..093b9aa17 100644 --- a/src/app/components/image-pack-view/RoomImagePack.tsx +++ b/src/app/components/image-pack-view/RoomImagePack.tsx @@ -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], ); diff --git a/src/app/components/soundboard-pack-view/RoomSoundboardPack.tsx b/src/app/components/soundboard-pack-view/RoomSoundboardPack.tsx index e60a7f418..42de83913 100644 --- a/src/app/components/soundboard-pack-view/RoomSoundboardPack.tsx +++ b/src/app/components/soundboard-pack-view/RoomSoundboardPack.tsx @@ -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], ); diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 371661662..dd14ebd99 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -344,9 +344,11 @@ const RoomNavItemMenu = forwardRef( 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[] = [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( 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[] = [ + 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(); }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index a2a581409..8630fdf2b 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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(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>(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 | 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,