diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index c6ce2c0ff..198686f31 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -52,7 +52,7 @@ import { ModalWide } from '../../../styles/Modal.css'; import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { useCapabilities } from '../../../hooks/useCapabilities'; -import { useUserPresence } from '../../../hooks/useUserPresence'; +import { Presence, useUserPresence } from '../../../hooks/useUserPresence'; import { ProfileDecoration } from './ProfileDecoration'; import { EmojiBoard } from '../../../components/emoji-board'; @@ -365,13 +365,26 @@ function ProfileStatus() { // Sync input when another device changes the status. // Skipped while the user has unsaved local edits to avoid clobbering - // mid-flight input (e.g. an emoji being inserted). + // mid-flight input (e.g. an emoji being inserted), and while presence data has + // not loaded yet (presence === undefined is "no info", NOT a clear). useEffect(() => { - if (!statusDirtyRef.current && presence?.status) { - setStatusMsg(presence.status); - localStorage.setItem(STATUS_MSG_KEY(userId), presence.status); + if (statusDirtyRef.current || !presence) return; + // An offline/invisible presence carries an empty status_msg by design (see + // usePresenceUpdater.setOffline), so ignore it — reading it as a clear would + // wipe the saved status on every invisible toggle. + if (presence.presence === Presence.Offline) return; + const remoteStatus = presence.status ?? ''; + if (remoteStatus) { + setStatusMsg(remoteStatus); + localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus); + } else { + // Another device CLEARED the status. Mirror it locally AND drop the stored + // value, otherwise usePresenceUpdater.readStatus() re-sends the stale + // message on the next presence heartbeat. + setStatusMsg(''); + localStorage.removeItem(STATUS_MSG_KEY(userId)); } - }, [presence?.status, userId]); + }, [presence, userId]); const [saveState, saveStatus] = useAsyncCallback( useCallback( diff --git a/src/app/hooks/useSoundboardPacks.ts b/src/app/hooks/useSoundboardPacks.ts index 2370241f4..0083ea0c5 100644 --- a/src/app/hooks/useSoundboardPacks.ts +++ b/src/app/hooks/useSoundboardPacks.ts @@ -1,5 +1,5 @@ import { Room } from 'matrix-js-sdk'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { AccountDataEvent } from '../../types/matrix/accountData'; import { StateEvent } from '../../types/matrix/room'; import { @@ -79,6 +79,13 @@ export const useRoomSoundboardPack = (room: Room, stateKey: string): SoundboardP const mx = useMatrixClient(); const [roomPack, setRoomPack] = useState(() => getRoomSoundboardPack(room, stateKey)); + // Resync when the room/stateKey arg changes — the useState initializer only + // runs on mount, so without this a re-target (e.g. a different room) kept + // showing the first room's pack. + useEffect(() => { + setRoomPack(getRoomSoundboardPack(room, stateKey)); + }, [room, stateKey]); + useStateEventCallback( mx, useCallback( @@ -102,6 +109,11 @@ export const useRoomSoundboardPacks = (room: Room): SoundboardPack[] => { const mx = useMatrixClient(); const [roomPacks, setRoomPacks] = useState(() => getRoomSoundboardPacks(room)); + // Resync on room change (see useRoomSoundboardPack). + useEffect(() => { + setRoomPacks(getRoomSoundboardPacks(room)); + }, [room]); + useStateEventCallback( mx, useCallback( @@ -124,6 +136,12 @@ export const useRoomsSoundboardPacks = (rooms: Room[]): SoundboardPack[] => { const mx = useMatrixClient(); const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomSoundboardPacks)); + // Resync when the room list changes (callers pass a memoized array, so this + // fires only on an actual change — see useRoomSoundboardPack). + useEffect(() => { + setRoomPacks(rooms.flatMap(getRoomSoundboardPacks)); + }, [rooms]); + useStateEventCallback( mx, useCallback( diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 2e3171d44..ccf88f5dc 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -134,7 +134,12 @@ function FaviconUpdater() { function InviteNotifications() { const audioRef = useRef(null); const invites = useAtomValue(allInvitesAtom); - const perviousInviteLen = usePreviousValue(invites.length, 0); + // 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(); const navigate = useNavigate();