diff --git a/.changeset/local-notification-inbox.md b/.changeset/local-notification-inbox.md new file mode 100644 index 0000000000..e431e8425d --- /dev/null +++ b/.changeset/local-notification-inbox.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +The notifications inbox is now built from push rules evaluated on this device instead of the server's `/notifications` endpoint, so mentions in encrypted rooms are detected correctly. diff --git a/src/app/hooks/useInboxNotificationCount.test.ts b/src/app/hooks/useInboxNotificationCount.test.ts new file mode 100644 index 0000000000..bcbc46c2a6 --- /dev/null +++ b/src/app/hooks/useInboxNotificationCount.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import type { Room } from '$types/matrix-sdk'; +import type { RoomToUnread } from '$types/matrix/room'; +import { countInboxNotifications } from './useInboxNotificationCount'; + +const room = (roomId: string): Room => + ({ + roomId, + isSpaceRoom: () => false, + getJoinedMemberCount: () => 3, + }) as unknown as Room; + +describe('countInboxNotifications', () => { + it('counts all DM notifications and only highlights elsewhere', () => { + const rooms = [room('!dm'), room('!room')]; + const unread = new Map([ + ['!dm', { total: 4, highlight: 1, from: null }], + ['!room', { total: 8, highlight: 2, from: null }], + ]) as RoomToUnread; + + expect(countInboxNotifications(rooms, unread, new Set(['!dm']))).toBe(6); + }); +}); diff --git a/src/app/hooks/useInboxNotificationCount.ts b/src/app/hooks/useInboxNotificationCount.ts new file mode 100644 index 0000000000..6daf25efba --- /dev/null +++ b/src/app/hooks/useInboxNotificationCount.ts @@ -0,0 +1,29 @@ +import { useAtomValue } from 'jotai'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { mDirectAtom } from '$state/mDirectList'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { roomToUnreadAtom } from '$state/room/roomToUnread'; +import { isDMRoom } from '$utils/room/unread'; +import type { Room } from '$types/matrix-sdk'; +import type { RoomToUnread } from '$types/matrix/room'; + +export const countInboxNotifications = ( + rooms: readonly Room[], + roomToUnread: RoomToUnread, + mDirects: Set +): number => + rooms.reduce((count, room) => { + const unread = roomToUnread.get(room.roomId); + if (!unread) return count; + return count + (isDMRoom(room, mDirects) ? unread.total : unread.highlight); + }, 0); + +export const useInboxNotificationCount = (): number => { + const mx = useMatrixClient(); + const roomIds = useAtomValue(allRoomsAtom); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const mDirects = useAtomValue(mDirectAtom); + + const rooms = roomIds.flatMap((roomId) => mx.getRoom(roomId) ?? []); + return countInboxNotifications(rooms, roomToUnread, mDirects); +}; diff --git a/src/app/hooks/useInterval.ts b/src/app/hooks/useInterval.ts deleted file mode 100644 index 161af3942a..0000000000 --- a/src/app/hooks/useInterval.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, useMemo } from 'react'; - -export type IntervalCallback = () => void; - -/** - * @param callback interval callback. - * @param ms interval time in milliseconds. negative value will stop the interval. - * @returns interval id or undefined if not running. - */ -export const useInterval = (callback: IntervalCallback, ms: number): number | undefined => { - const id = useMemo(() => { - if (ms < 0) return undefined; - return window.setInterval(callback, ms); - }, [callback, ms]); - - useEffect( - () => () => { - window.clearInterval(id); - }, - [id] - ); - - return id; -}; diff --git a/src/app/hooks/useLocalNotificationTimeline.ts b/src/app/hooks/useLocalNotificationTimeline.ts new file mode 100644 index 0000000000..0681ebe3e3 --- /dev/null +++ b/src/app/hooks/useLocalNotificationTimeline.ts @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAtomValue } from 'jotai'; +import { RoomEvent } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { roomToUnreadAtom } from '$state/room/roomToUnread'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { getLocalNotificationCache } from '$client/localNotificationCache'; +import { backfillLocalNotifications } from '$utils/localNotificationBackfill'; +import { + isStoredNotificationRead, + sliceNotificationPage, + type NotificationTab, + type StoredNotification, +} from '$utils/localNotifications'; + +export type NotificationQuery = { + tab: NotificationTab; + includeRead: boolean; + limit: number; +}; + +export type NotificationPage = { + items: StoredNotification[]; + canLoadOlder: boolean; +}; + +export const useLocalNotificationTimeline = (query: NotificationQuery) => { + const mx = useMatrixClient(); + const joinedRooms = useAtomValue(allRoomsAtom); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const [storeContent] = useSetting(settingsAtom, 'showMessageContentInNotifications'); + const [storeEncryptedContent] = useSetting( + settingsAtom, + 'showMessageContentInEncryptedNotifications' + ); + const allowedRooms = useMemo(() => new Set(joinedRooms), [joinedRooms]); + const cache = getLocalNotificationCache(mx.getSafeUserId()); + const loadedLimit = useRef(query.limit); + const [entries, setEntries] = useState(() => cache.getEntries()); + const [historyCutoff, setHistoryCutoff] = useState(() => cache.getHistoryCutoff(query.tab)); + const [loadingOlder, setLoadingOlder] = useState(false); + const [error, setError] = useState(); + const refresh = useCallback(() => { + setEntries(cache.getEntries()); + setHistoryCutoff(cache.getHistoryCutoff(query.tab)); + }, [cache, query.tab]); + + useEffect(() => { + loadedLimit.current = query.limit; + refresh(); + }, [query, refresh]); + + useEffect(() => { + refresh(); + const unsubscribe = cache.subscribe(refresh); + mx.on(RoomEvent.Receipt, refresh); + return () => { + unsubscribe(); + mx.off(RoomEvent.Receipt, refresh); + }; + }, [cache, mx, refresh]); + + useEffect(() => { + const controller = new AbortController(); + void backfillLocalNotifications( + mx, + mx.getSafeUserId(), + { + storeContent, + storeEncryptedContent: storeContent && storeEncryptedContent, + }, + { signal: controller.signal, tab: query.tab } + ).catch((reason: unknown) => { + if (!controller.signal.aborted) { + setError(reason instanceof Error ? reason : new Error(String(reason))); + } + }); + return () => controller.abort(); + }, [mx, query.tab, roomToUnread, storeContent, storeEncryptedContent]); + + const page = useMemo(() => { + const visibleEntries = entries.filter((entry) => allowedRooms.has(entry.room_id)); + const unreadRemaining = new Map(); + const unreadIds = new Set(); + for (const entry of visibleEntries) { + if (query.tab === 'dms' && !entry.isDM) continue; + if (query.tab === 'mentions' && !entry.highlight) continue; + const room = mx.getRoom(entry.room_id); + if (!room || isStoredNotificationRead(room, mx.getSafeUserId(), entry)) continue; + const remaining = + unreadRemaining.get(entry.room_id) ?? + (query.tab === 'mentions' + ? roomToUnread.get(entry.room_id)?.highlight + : roomToUnread.get(entry.room_id)?.total) ?? + 0; + unreadRemaining.set(entry.room_id, Math.max(0, remaining - 1)); + if (remaining > 0) unreadIds.add(entry.event.event_id); + } + const orderedEntries = visibleEntries.filter( + (entry) => + unreadIds.has(entry.event.event_id) || + (query.includeRead && historyCutoff !== undefined && entry.ts >= historyCutoff) + ); + const { page: items, nextToken } = sliceNotificationPage( + orderedEntries, + 0, + loadedLimit.current, + query.tab, + true, + () => false + ); + return { + items, + canLoadOlder: query.includeRead || nextToken !== undefined, + }; + }, [allowedRooms, entries, historyCutoff, mx, query, roomToUnread]); + + const loadOlder = useCallback(async () => { + if (loadingOlder) return; + setLoadingOlder(true); + setError(undefined); + try { + if (query.includeRead) { + await backfillLocalNotifications( + mx, + mx.getSafeUserId(), + { + storeContent, + storeEncryptedContent: storeContent && storeEncryptedContent, + }, + { includeRead: true, tab: query.tab } + ); + } + loadedLimit.current += query.limit; + refresh(); + } catch (reason) { + setError(reason instanceof Error ? reason : new Error(String(reason))); + } finally { + setLoadingOlder(false); + } + }, [ + loadingOlder, + mx, + query.includeRead, + query.limit, + query.tab, + refresh, + storeContent, + storeEncryptedContent, + ]); + + return { page, loadingOlder, error, refresh, loadOlder }; +}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 969accd5ab..e870e57dec 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -6,6 +6,7 @@ import { MatrixRTCSessionProvider } from '$hooks/useMatrixRTCSession'; import { BackgroundNotifications } from './BackgroundNotifications'; import { WebUpdater } from './WebUpdater'; import { NotificationTransportRuntimeFeature } from '$features/settings/notifications/NotificationTransportRuntimeFeature'; +import { NotificationRecorder } from './client-non-ui/notificationRecorder'; import { InviteNotifications, MessageNotifications, @@ -51,6 +52,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/app/pages/client/client-non-ui/notificationRecorder.tsx b/src/app/pages/client/client-non-ui/notificationRecorder.tsx new file mode 100644 index 0000000000..167d3ccbd5 --- /dev/null +++ b/src/app/pages/client/client-non-ui/notificationRecorder.tsx @@ -0,0 +1,58 @@ +import { useAtomValue } from 'jotai'; +import { useEffect, useRef } from 'react'; +import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; +import { ClientEvent, EventType, SyncState } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { mDirectAtom } from '$state/mDirectList'; +import { arePushRulesReady } from '$utils/localNotifications'; +import { runLiveTimelineScan } from '$utils/localNotificationBackfill'; + +const isReady = (state: SyncState | null): boolean => + state === SyncState.Prepared || state === SyncState.Syncing || state === SyncState.Catchup; + +export function NotificationRecorder() { + const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + const [storeContent] = useSetting(settingsAtom, 'showMessageContentInNotifications'); + const [storeEncryptedContent] = useSetting( + settingsAtom, + 'showMessageContentInEncryptedNotifications' + ); + const mDirectsRef = useRef(mDirects); + mDirectsRef.current = mDirects; + const contentRef = useRef({ storeContent, storeEncryptedContent }); + contentRef.current = { + storeContent, + storeEncryptedContent: storeContent && storeEncryptedContent, + }; + const startedFor = useRef(); + + useEffect(() => { + const start = () => { + if (startedFor.current === mx || !isReady(mx.getSyncState()) || !arePushRulesReady(mx)) { + return; + } + startedFor.current = mx; + const content = contentRef.current; + void runLiveTimelineScan(mx, mx.getSafeUserId(), mDirectsRef.current, content).catch( + () => undefined + ); + }; + const onSync = () => start(); + const onAccountData = (event: MatrixEvent) => { + if (event.getType() === (EventType.PushRules as string)) start(); + }; + mx.on(ClientEvent.Sync, onSync); + mx.on(ClientEvent.AccountData, onAccountData); + start(); + + return () => { + mx.off(ClientEvent.Sync, onSync); + mx.off(ClientEvent.AccountData, onAccountData); + }; + }, [mx]); + + return null; +} diff --git a/src/app/pages/client/client-non-ui/notifications.tsx b/src/app/pages/client/client-non-ui/notifications.tsx index e3b0b0bb3e..516bc9ea1d 100644 --- a/src/app/pages/client/client-non-ui/notifications.tsx +++ b/src/app/pages/client/client-non-ui/notifications.tsx @@ -35,12 +35,13 @@ import { nicknamesAtom } from '$state/nicknames'; import { mDirectAtom } from '$state/mDirectList'; import { allInvitesAtom } from '$state/room-list/inviteList'; import { markAsRead } from '$utils/notifications'; +import { evaluateNotification } from '$utils/localNotifications'; +import { getLocalNotificationCache } from '$client/localNotificationCache'; import { usePreviousValue } from '$hooks/usePreviousValue'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { getStateEvent } from '$utils/room/hierarchy'; -import { getNotificationType, isDMRoom, isNotificationEvent } from '$utils/room/unread'; +import { getNotificationType } from '$utils/room/unread'; import { getMemberDisplayName } from '$utils/room/display'; -import { NotificationType } from '$types/matrix/room'; import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '$hooks/router/useRouteSelected'; @@ -206,6 +207,7 @@ export function MessageNotifications() { useEffect(() => { const pushProcessor = mx.pushProcessor; + const notificationCache = getLocalNotificationCache(mx.getSafeUserId()); // Track encrypted events that should skip focus check when decrypted (because we // already checked focus when the encrypted event arrived, and want to use that // original state rather than re-checking after decryption completes). @@ -227,11 +229,6 @@ export function MessageNotifications() { if (eventId && !notifyTimerMap.has(eventId)) { notifyTimerMap.set(eventId, performance.now()); } - const shouldSkipFocusCheck = eventId && skipFocusCheckEvents.has(eventId); - if (!shouldSkipFocusCheck) { - if (isWindowFocused() && (selectedRoomId === room?.roomId || notificationSelected)) return; - } - // Older sliding sync proxies (e.g. matrix-sliding-sync) omit num_live, // which causes every event to arrive with fromCache=true and therefore // liveEvent=false — silently blocking all notifications. Fall back to an @@ -268,24 +265,32 @@ export function MessageNotifications() { return; } - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) { - return; - } + if (!room || !eventId || isHistoricalEvent) return; const notificationType = getNotificationType(mx, room.roomId); - if (notificationType === NotificationType.Mute) { + const stored = evaluateNotification(mx, room, mEvent, mDirectsRef.current, notificationType, { + storeContent: mEvent.isEncrypted() ? showEncryptedMessageContent : showMessageContent, + }); + if (!stored) return; + notificationCache.merge(stored); + + const shouldSkipFocusCheck = skipFocusCheckEvents.has(eventId); + if ( + !shouldSkipFocusCheck && + isWindowFocused() && + (selectedRoomId === room.roomId || notificationSelected) + ) { return; } const sender = mEvent.getSender(); - if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return; + if (!sender) return; // Deduplicate: don't show a second banner if this event fires twice // (e.g., decrypted events re-emitted by the SDK). if (notifiedEventsRef.current.has(eventId)) return; - // Check if this is a DM using multiple signals for robustness - const isDM = isDMRoom(room, mDirectsRef.current); + const { isDM } = stored; // Measure total notification delivery latency (includes decryption wait for E2EE events) const arrivalMs = notifyTimerMap.get(eventId); @@ -304,18 +309,8 @@ export function MessageNotifications() { } const pushActions = pushProcessor.actionsForEvent(mEvent); - // For DMs with "All Messages" or "Default" notification settings: - // Always notify even if push rules fail to match due to sliding sync limitations. - // For "Mention & Keywords": respect the push rule (only notify if it matches). - const shouldForceDMNotification = - isDM && notificationType !== NotificationType.MentionsAndKeywords; - const shouldNotify = pushActions?.notify || shouldForceDMNotification; - - // If we shouldn't notify based on rules/settings, skip everything - if (!shouldNotify) return; - const loudByRule = Boolean(pushActions.tweaks?.sound); - const isHighlightByRule = Boolean(pushActions.tweaks?.highlight); + const isHighlightByRule = stored.highlight; // With sliding sync we only load m.room.member/$ME in required_state, so // PushProcessor cannot evaluate the room_member_count == 2 condition on @@ -373,7 +368,10 @@ export function MessageNotifications() { eventId, }); if (isNativeNotificationTauri()) { - const extra: Record = { type: mEvent.getType(), room_id: room.roomId }; + const extra: Record = { + type: mEvent.getType(), + room_id: room.roomId, + }; if (eventId) extra.event_id = eventId; const userId = mx.getUserId(); if (userId) extra.user_id = userId; @@ -815,7 +813,10 @@ export function NativeNotificationActionRouting() { if (inFlight.has(item.key)) return clearExpiryTimer; setInFlight((previous: Set) => new Set(previous).add(item.key)); void mx - .sendMessage(item.roomId, null, { msgtype: MsgType.Text, body: item.text }) + .sendMessage(item.roomId, null, { + msgtype: MsgType.Text, + body: item.text, + }) // Replying is reading; otherwise the room stays unread and its // notification lingers while later pushes stack onto it. .then(() => markAsRead(mx, item.roomId, hideReads).catch(() => undefined)) diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index a826c25340..aa3ee6bc3a 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -17,6 +17,7 @@ import { PageNavContent, PageNavHeader } from '$components/page'; import { PageNavShell } from '$components/page/PageNavShell'; import { useSidebarWidth } from '$hooks/useSidebarWidth'; import { useInviteCount } from '$hooks/useInviteCount'; +import { useInboxNotificationCount } from '$hooks/useInboxNotificationCount'; import { BookmarkIcon } from '@phosphor-icons/react'; function InvitesNavItem({ hideText }: { hideText?: boolean }) { @@ -55,9 +56,44 @@ function InvitesNavItem({ hideText }: { hideText?: boolean }) { ); } +function NotificationsNavItem({ hideText }: { hideText?: boolean }) { + const notificationsSelected = useInboxNotificationsSelected(); + const notificationCount = useInboxNotificationCount(); + + return ( + 0} + aria-selected={notificationsSelected} + > + + + + + {sizedIcon(ChatCircleDots, '100', { filled: notificationsSelected })} + + {!hideText && ( + + + Notifications + + + )} + {notificationCount > 0 && } + + + + + ); +} + export function Inbox() { useNavToActivePathMapper('inbox'); - const notificationsSelected = useInboxNotificationsSelected(); const bookmarksSelected = useInboxBookmarksSelected(); const { @@ -104,28 +140,7 @@ export function Inbox() { - - - - - - {sizedIcon(ChatCircleDots, '100', { filled: notificationsSelected })} - - {!hideText && ( - - - Notifications - - - )} - - - - + diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 1b969f8d33..8e5ea94b1d 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -1,8 +1,11 @@ import type { MouseEventHandler } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Avatar, Box, Chip, Header, IconButton, Scroll, Text, config, toRem } from 'folds'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Avatar, Badge, Box, Chip, Header, IconButton, Scroll, Spinner, Text, config } from 'folds'; +import { useSearchParams } from 'react-router-dom'; +import { Virtualizer } from 'virtua'; import { ArrowLeft, + CaretDown, CaretUp, ChatCircle, Check, @@ -10,151 +13,48 @@ import { composerIcon, sizedIcon, } from '$components/icons/phosphor'; -import { useSearchParams } from 'react-router-dom'; -import type { INotification, INotificationsResponse, Room } from '$types/matrix-sdk'; -import { EventType, JoinRule, MatrixEvent, Method } from '$types/matrix-sdk'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { useAtomValue } from 'jotai'; +import { JoinRule, MatrixEvent } from '$types/matrix-sdk'; +import type { Room } from '$types/matrix-sdk'; import { Page, PageContent, PageContentCenter, PageHeader } from '$components/page'; -import { useMatrixClient } from '$hooks/useMatrixClient'; -import type { InboxNotificationsPathSearchParams } from '$pages/paths'; -import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { SequenceCard } from '$components/sequence-card'; import { RoomAvatar, RoomIcon } from '$components/room-avatar'; -import { getRoomAvatarUrl } from '$utils/room/display'; import { ScrollTopContainer } from '$components/scroll-top-container'; -import { useInterval } from '$hooks/useInterval'; +import { ContainerColor } from '$styles/ContainerColor.css'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { showToast } from '$state/toast'; +import { markAsRead } from '$utils/notifications'; +import { getRoomAvatarUrl } from '$utils/room/display'; import { useRoomUnread } from '$state/hooks/unread'; import { roomToUnreadAtom } from '$state/room/roomToUnread'; -import { markAsRead } from '$utils/notifications'; -import { ContainerColor } from '$styles/ContainerColor.css'; -import { VirtualTile } from '$components/virtualizer'; +import { useLocalNotificationTimeline } from '$hooks/useLocalNotificationTimeline'; +import { + isStoredNotificationRead, + type NotificationTab, + type StoredNotification, +} from '$utils/localNotifications'; import { MessagePreview, useRoomMessagePreviewRenderer } from '$components/message-preview'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { BackRouteHandler } from '$components/BackRouteHandler'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { allRoomsAtom } from '$state/room-list/roomList'; - -type RoomNotificationsGroup = { - roomId: string; - notifications: INotification[]; -}; -type NotificationTimeline = { - nextToken?: string; - groups: RoomNotificationsGroup[]; -}; -type LoadTimeline = (from?: string) => Promise; -type SilentReloadTimeline = () => Promise; - -const groupNotifications = ( - notifications: INotification[], - allowRooms: Set -): RoomNotificationsGroup[] => { - const groups: RoomNotificationsGroup[] = []; - notifications.forEach((notification) => { - if (notification.event.type === (EventType.RoomMember as string)) return; - if (!allowRooms.has(notification.room_id)) return; - - const groupIndex = groups.length - 1; - const lastAddedGroup: RoomNotificationsGroup | undefined = groups[groupIndex]; - if (notification.room_id === lastAddedGroup?.roomId) { - lastAddedGroup.notifications.push(notification); - return; - } - groups.push({ - roomId: notification.room_id, - notifications: [notification], - }); - }); - return groups; -}; - -const useNotificationTimeline = ( - paginationLimit: number, - onlyHighlight?: boolean -): [NotificationTimeline, LoadTimeline, SilentReloadTimeline] => { - const mx = useMatrixClient(); - const allRooms = useAtomValue(allRoomsAtom); - const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); - - const [notificationTimeline, setNotificationTimeline] = useState({ - groups: [], - }); - - const fetchNotifications = useCallback( - (from?: string, limit?: number, only?: 'highlight') => { - const queryParams = { from, limit, only }; - return mx.http.authedRequest( - Method.Get, - '/notifications', - queryParams - ); - }, - [mx] - ); - - const loadTimeline: LoadTimeline = useCallback( - async (from) => { - if (!from) { - setNotificationTimeline({ groups: [] }); - } - const data = await fetchNotifications( - from, - paginationLimit, - onlyHighlight ? 'highlight' : undefined - ); - const groups = groupNotifications(data.notifications, allJoinedRooms); - - setNotificationTimeline((currentTimeline) => { - if (currentTimeline.nextToken === from) { - return { - nextToken: data.next_token, - groups: from ? currentTimeline.groups.concat(groups) : groups, - }; - } - return currentTimeline; - }); - }, - [paginationLimit, onlyHighlight, fetchNotifications, allJoinedRooms] - ); - /** - * Reload timeline silently i.e without setting to default - * before fetching notifications from start - */ - const silentReloadTimeline: SilentReloadTimeline = useCallback(async () => { - const data = await fetchNotifications( - undefined, - paginationLimit, - onlyHighlight ? 'highlight' : undefined - ); - const groups = groupNotifications(data.notifications, allJoinedRooms); - setNotificationTimeline({ - nextToken: data.next_token, - groups, - }); - }, [paginationLimit, onlyHighlight, fetchNotifications, allJoinedRooms]); - - return [notificationTimeline, loadTimeline, silentReloadTimeline]; +type NotificationRow = { + notification: StoredNotification; + showHeader: boolean; }; -type RoomNotificationsGroupProps = { - room: Room; - appBaseUrl: string; - notifications: INotification[]; - hideReads: boolean; - onOpen: (roomId: string, eventId: string) => void; - hour24Clock: boolean; - dateFormatString: string; -}; +const notificationRows = (items: StoredNotification[]): NotificationRow[] => + items.map((notification, index) => ({ + notification, + showHeader: items[index - 1]?.room_id !== notification.room_id, + })); type NotificationItemProps = { room: Room; - notification: INotification; + notification: StoredNotification; renderContent: ReturnType; onOpen: (roomId: string, eventId: string) => void; hour24Clock: boolean; @@ -169,11 +69,35 @@ function NotificationItem({ hour24Clock, dateFormatString, }: NotificationItemProps) { - const event = useMemo(() => new MatrixEvent(notification.event), [notification.event]); + const mx = useMatrixClient(); + const liveEvent = useMemo( + () => room.findEventById(notification.event.event_id), + [room, notification.event.event_id] + ); + const event = useMemo( + () => liveEvent ?? new MatrixEvent(notification.event), + [liveEvent, notification.event] + ); + const [decryptedEvent, setDecryptedEvent] = useState(); + + useEffect(() => { + setDecryptedEvent(undefined); + if (liveEvent || !event.isEncrypted()) return undefined; + let mounted = true; + void mx + .decryptEventIfNeeded(event) + .then(() => mounted && setDecryptedEvent(event)) + .catch(() => undefined); + return () => { + mounted = false; + }; + }, [event, liveEvent, mx]); + const handleOpen: MouseEventHandler = (evt) => { evt.stopPropagation(); onOpen(room.roomId, notification.event.event_id); }; + const read = isStoredNotificationRead(room, mx.getSafeUserId(), notification); return ( - Open - + + {!read && ( + + )} + + Open + + } onOpen={handleOpen} hour24Clock={hour24Clock} @@ -198,88 +127,84 @@ function NotificationItem({ ); } -function RoomNotificationsGroupComp({ +function NotificationRowItem({ room, appBaseUrl, - notifications, + row, hideReads, onOpen, + onMarkRead, hour24Clock, dateFormatString, -}: Readonly) { +}: { + room: Room; + appBaseUrl: string; + row: NotificationRow; + hideReads: boolean; + onOpen: (roomId: string, eventId: string) => void; + onMarkRead: () => void; + hour24Clock: boolean; + dateFormatString: string; +}) { const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); - const renderContent = useRoomMessagePreviewRenderer(room, { settingsLinkBaseUrl: appBaseUrl }); - const handleMarkAsRead = () => { - markAsRead(mx, room.roomId, hideReads); - }; + const useAuthentication = useMediaAuthentication(); + const renderContent = useRoomMessagePreviewRenderer(room, { + settingsLinkBaseUrl: appBaseUrl, + }); return ( -
- - - ( - - )} - /> - - - {room.name} - - - - {unread && ( + {row.showHeader && ( +
+ + + ( + + )} + /> + + + {room.name} + + + {unread && (unread.total > 0 || unread.highlight > 0) && ( { + void markAsRead(mx, room.roomId, hideReads) + .then(onMarkRead) + .catch(() => showToast('Unable to mark this room as read.')); + }} before={sizedIcon(Checks, '100')} > Mark as Read )} - -
- - {notifications.map((notification) => ( - - ))} - +
+ )} +
); } -const useNotificationsSearchParams = ( - searchParams: URLSearchParams -): InboxNotificationsPathSearchParams => - useMemo( - () => ({ - only: searchParams.get('only') ?? undefined, - }), - [searchParams] - ); - -const FAST_REFRESH_MS = 2500; - export function Notifications() { const mx = useMatrixClient(); const [hideReads] = useSetting(settingsAtom, 'hideReads'); @@ -287,62 +212,40 @@ export function Notifications() { const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const screenSize = useScreenSizeContext(); const appBaseUrl = useSettingsLinkBaseUrl(); - const { navigateRoom } = useRoomNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const notificationsSearchParams = useNotificationsSearchParams(searchParams); const scrollRef = useRef(null); const scrollTopAnchorRef = useRef(null); + const virtualStartRef = useRef(null); + const [virtualStartMargin, setVirtualStartMargin] = useState(0); - const onlyHighlight = notificationsSearchParams.only === 'highlight'; - const setOnlyHighlighted = (highlight: boolean) => { - if (highlight) { - setSearchParams( - new URLSearchParams({ - only: 'highlight', - }) - ); - return; - } - setSearchParams(); - }; - - const [notificationTimeline, loadTimelineRaw, silentReloadTimeline] = useNotificationTimeline( - 24, - onlyHighlight - ); - const [timelineState, loadTimeline] = useAsyncCallback(loadTimelineRaw); - - const virtualizer = useVirtualizer({ - count: notificationTimeline.groups.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 40, - overscan: 4, - }); - const vItems = virtualizer.getVirtualItems(); - - useInterval( - useCallback(() => { - silentReloadTimeline(); - }, [silentReloadTimeline]), - FAST_REFRESH_MS - ); + const tabValue = searchParams.get('tab'); + const tab: NotificationTab = tabValue === 'dms' || tabValue === 'mentions' ? tabValue : 'all'; + const includeRead = searchParams.get('read') === '1'; + const query = useMemo(() => ({ tab, includeRead, limit: 24 }), [includeRead, tab]); + const { page, loadingOlder, error, refresh, loadOlder } = useLocalNotificationTimeline(query); + const rows = useMemo(() => notificationRows(page.items), [page.items]); - useEffect(() => { - loadTimeline(); - }, [loadTimeline]); + useLayoutEffect(() => { + const scroll = scrollRef.current; + const start = virtualStartRef.current; + if (!scroll || !start) return undefined; + const updateMargin = () => { + const margin = + start.getBoundingClientRect().top - scroll.getBoundingClientRect().top + scroll.scrollTop; + setVirtualStartMargin((current) => (current === margin ? current : margin)); + }; + updateMargin(); + window.addEventListener('resize', updateMargin); + return () => window.removeEventListener('resize', updateMargin); + }, [includeRead, rows.length, tab]); - const lastVItem = vItems.at(-1); - const lastVItemIndex: number | undefined = lastVItem?.index; - useEffect(() => { - if ( - timelineState.status === AsyncStatus.Success && - notificationTimeline.groups.length - 1 === lastVItemIndex && - notificationTimeline.nextToken - ) { - loadTimeline(notificationTimeline.nextToken); - } - }, [timelineState, notificationTimeline, lastVItemIndex, loadTimeline]); + const setFilter = (name: string, value?: string) => { + const next = new URLSearchParams(searchParams); + if (value === undefined) next.delete(name); + else next.set(name, value); + setSearchParams(next); + }; return ( @@ -373,30 +276,36 @@ export function Notifications() { Filter - - setOnlyHighlighted(false)} - variant={onlyHighlight ? 'Surface' : 'Success'} - aria-pressed={!onlyHighlight} - before={!onlyHighlight && sizedIcon(Check, '100')} - outlined - > - All Notifications - + + {(['dms', 'mentions', 'all'] as NotificationTab[]).map((value) => ( + setFilter('tab', value === 'all' ? undefined : value)} + variant={tab === value ? 'Success' : 'Surface'} + aria-pressed={tab === value} + before={tab === value && sizedIcon(Check, '100')} + outlined + > + + {value === 'dms' ? 'DMs' : value[0]!.toUpperCase() + value.slice(1)} + + + ))} setOnlyHighlighted(true)} - variant={onlyHighlight ? 'Success' : 'Surface'} - aria-pressed={onlyHighlight} - before={onlyHighlight && sizedIcon(Check, '100')} + onClick={() => setFilter('read', includeRead ? undefined : '1')} + variant={includeRead ? 'Success' : 'Surface'} + aria-pressed={includeRead} + before={includeRead && sizedIcon(Check, '100')} outlined > - Highlighted + Include read + virtualizer.scrollToOffset(0)} + onClick={() => scrollRef.current?.scrollTo({ top: 0 })} variant="SurfaceVariant" radii="Pill" outlined @@ -406,82 +315,90 @@ export function Notifications() { {composerIcon(CaretUp)} -
- {vItems.map((vItem) => { - const group = notificationTimeline.groups[vItem.index]; - if (!group) return null; - const groupRoom = mx.getRoom(group.roomId); - if (!groupRoom) return null; - return ( - - - - ); - })} +
+ + data={rows} + scrollRef={scrollRef} + startMargin={virtualStartMargin} + bufferSize={800} + > + {(row) => { + const room = mx.getRoom(row.notification.room_id); + if (!room) return
; + return ( +
+ +
+ ); + }} +
- {timelineState.status === AsyncStatus.Success && - notificationTimeline.groups.length === 0 && ( - - No Notifications - - You don't have any new notifications to display yet. - - - )} - - {timelineState.status === AsyncStatus.Loading && ( - - {Array.from({ length: 8 }).map(() => ( - - ))} + {page.items.length === 0 && ( + + No Notifications + + You don't have any notifications matching these filters. + )} - {timelineState.status === AsyncStatus.Error && ( + {error && ( - {(timelineState.error as Error).name} - {(timelineState.error as Error).message} + {error.message} + + )} + {page.canLoadOlder && ( + + void loadOlder()} + disabled={loadingOlder} + variant="SurfaceVariant" + radii="Pill" + outlined + size="400" + aria-label={ + error ? 'Retry loading older notifications' : 'Load older notifications' + } + > + {loadingOlder ? ( + + ) : ( + composerIcon(CaretDown) + )} + )} diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx index aee67e56bd..a27c0e46c8 100644 --- a/src/app/pages/client/sidebar/InboxTab.tsx +++ b/src/app/pages/client/sidebar/InboxTab.tsx @@ -21,6 +21,7 @@ import { import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useNavToActivePathAtom } from '$state/hooks/navToActivePath'; import { useInviteCount } from '$hooks/useInviteCount'; +import { useInboxNotificationCount } from '$hooks/useInboxNotificationCount'; import { Text, Box, color } from 'folds'; import { EnvelopeSimple, getPhosphorIconSize, Tray } from '$components/icons/phosphor'; import { BookmarkIcon, ChatCircleDotsIcon } from '@phosphor-icons/react'; @@ -32,6 +33,8 @@ export function InboxTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile? const navToActivePath = useAtomValue(useNavToActivePathAtom()); const inboxSelected = useInboxSelected(); const inviteCount = useInviteCount(); + const notificationCount = useInboxNotificationCount(); + const totalBadge = inviteCount + notificationCount; const opened = inboxSelected; const InboxIconSize = getPhosphorIconSize(isBottom ? 'inline' : 'toolbar'); @@ -107,7 +110,7 @@ export function InboxTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile? )} - {inviteCount > 0 && } + {totalBadge > 0 && } ); } diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index aed4cb2ebb..421de3be99 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -4,6 +4,7 @@ import { createLogger } from '$utils/debug'; import { atomWithLocalStorage, getLocalStorageItem, + setEssentialLocalStorageItem, setLocalStorageItem, } from './utils/atomWithLocalStorage'; @@ -192,7 +193,7 @@ export const updateSessionTokens = ( refreshToken: tokens.refreshToken ?? sessions[index]!.refreshToken, expiresInMs: tokens.expiresInMs ?? sessions[index]!.expiresInMs, }; - setLocalStorageItem(MATRIX_SESSIONS_KEY, sessions); + setEssentialLocalStorageItem(MATRIX_SESSIONS_KEY, sessions); window.dispatchEvent(new StorageEvent('storage', { key: MATRIX_SESSIONS_KEY })); notifySessionChanged(); }; diff --git a/src/app/state/utils/atomWithLocalStorage.ts b/src/app/state/utils/atomWithLocalStorage.ts index 84f1aeea42..f942414e4d 100644 --- a/src/app/state/utils/atomWithLocalStorage.ts +++ b/src/app/state/utils/atomWithLocalStorage.ts @@ -1,4 +1,8 @@ import { atom } from 'jotai'; +import { NOTIFICATION_CACHE_KEY_PREFIX } from '$client/localNotificationCache'; +import { SIDEBAR_CACHE_KEY_PREFIX } from '$client/slidingSyncSidebarCache'; + +const EVICTABLE_KEY_PREFIXES = [NOTIFICATION_CACHE_KEY_PREFIX, SIDEBAR_CACHE_KEY_PREFIX]; export const getLocalStorageItem = (key: string, defaultValue: T): T => { const item = localStorage.getItem(key); @@ -15,6 +19,23 @@ export const setLocalStorageItem = (key: string, value: unknown) => { localStorage.setItem(key, JSON.stringify(value)); }; +// Losing a rotated token leaves an already-invalidated one on disk, which logs +// the user out on next start. Evict non-essential caches and retry instead. +export const setEssentialLocalStorageItem = (key: string, value: unknown) => { + const serialized = JSON.stringify(value); + try { + localStorage.setItem(key, serialized); + return; + } catch { + for (const candidate of Object.keys(localStorage)) { + if (candidate !== key && EVICTABLE_KEY_PREFIXES.some((p) => candidate.startsWith(p))) { + localStorage.removeItem(candidate); + } + } + } + localStorage.setItem(key, serialized); +}; + export type GetLocalStorageItem = (key: string) => T; export type SetLocalStorageItem = (key: string, value: T) => void; diff --git a/src/app/utils/localNotificationBackfill.test.ts b/src/app/utils/localNotificationBackfill.test.ts new file mode 100644 index 0000000000..560411aff5 --- /dev/null +++ b/src/app/utils/localNotificationBackfill.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { MatrixClient, MatrixEvent, PushProcessor, Room } from '$types/matrix-sdk'; +import { Direction } from '$types/matrix-sdk'; +import { NotificationType } from '$types/matrix/room'; +import { + clearLocalNotificationCache, + getLocalNotificationCache, +} from '$client/localNotificationCache'; + +const state = vi.hoisted(() => ({ unread: 1, directRooms: new Set() })); + +vi.mock('$utils/room/hierarchy', () => ({ + getAccountData: () => ({ getContent: () => ({}) }), + getStateEvent: () => undefined, +})); + +vi.mock('$utils/room/unread', () => ({ + getMDirects: () => new Set(), + getNotificationType: () => NotificationType.AllMessages, + getUnreadInfo: () => ({ + roomId: '!room', + total: state.unread, + highlight: state.unread, + }), + isDMRoom: (room: Room) => state.directRooms.has(room.roomId), + isNotificationEvent: () => true, +})); + +import { backfillLocalNotifications } from './localNotificationBackfill'; + +const USER_ID = '@me:example.org'; + +const notificationEvent = { + getId: () => '$event', + getSender: () => '@alice:example.org', + getType: () => 'm.room.message', + getContent: () => ({ msgtype: 'm.text', body: 'hello' }), + getTs: () => 100, + getRelation: () => undefined, + isRedacted: () => false, + isSending: () => false, + isEncrypted: () => false, +} as unknown as MatrixEvent; + +const setup = () => { + let token: string | null = 'before'; + const timeline = { + getEvents: () => [notificationEvent], + getPaginationToken: (direction: Direction) => (direction === Direction.Backward ? token : null), + }; + const room = { + roomId: '!room', + isSpaceRoom: () => false, + getJoinedMemberCount: () => 3, + getLastActiveTimestamp: () => 100, + getLiveTimeline: () => timeline, + hasUserReadEvent: () => false, + } as unknown as Room; + const scrollback = vi.fn(async () => { + token = null; + return room; + }); + const mx = { + getRooms: () => [room], + getUserId: () => USER_ID, + getSafeUserId: () => USER_ID, + getRoomPushRule: () => undefined, + pushRules: { global: {} }, + pushProcessor: { + actionsForEvent: vi + .fn() + .mockReturnValue({ notify: true, tweaks: { highlight: true } }), + } as unknown as PushProcessor, + scrollback, + } as unknown as MatrixClient; + return { mx, scrollback }; +}; + +const makeHistoryRoom = (roomId: string, timestamp: number) => { + let token: string | null = 'before'; + let frontier = timestamp; + const roomEvent = { + getId: () => `$${roomId}`, + getSender: () => '@alice:example.org', + getType: () => 'm.room.message', + getContent: () => ({ msgtype: 'm.text', body: 'hello' }), + getTs: () => frontier, + getRelation: () => undefined, + isRedacted: () => false, + isSending: () => false, + isEncrypted: () => false, + } as unknown as MatrixEvent; + const timeline = { + getEvents: () => [roomEvent], + getPaginationToken: () => token, + }; + const room = { + roomId, + isSpaceRoom: () => false, + getJoinedMemberCount: () => 3, + getLastActiveTimestamp: () => timestamp, + getLiveTimeline: () => timeline, + hasUserReadEvent: () => false, + } as unknown as Room; + return { + room, + advance: (nextFrontier: number, nextToken: string | null) => { + frontier = nextFrontier; + token = nextToken; + }, + }; +}; + +afterEach(() => { + clearLocalNotificationCache(USER_ID); + localStorage.clear(); + state.unread = 1; + state.directRooms.clear(); +}); + +describe('backfillLocalNotifications', () => { + it('loads and records only when sync reports unread notifications', async () => { + const { mx, scrollback } = setup(); + await backfillLocalNotifications(mx, USER_ID, { + storeContent: true, + storeEncryptedContent: true, + }); + + expect(scrollback).toHaveBeenCalledOnce(); + expect(getLocalNotificationCache(USER_ID).getEntries()).toHaveLength(1); + }); + + it('does not paginate a read room', async () => { + state.unread = 0; + const { mx, scrollback } = setup(); + await backfillLocalNotifications(mx, USER_ID, { + storeContent: true, + storeEncryptedContent: true, + }); + + expect(scrollback).not.toHaveBeenCalled(); + }); + + it('advances the newest room frontier instead of exhausting one room', async () => { + const older = makeHistoryRoom('older', 100); + const newer = makeHistoryRoom('newer', 500); + const order: string[] = []; + let newerPages = 0; + const mx = Object.assign(setup().mx, { + getRooms: () => [older.room, newer.room], + scrollback: vi.fn(async (room) => { + order.push(room.roomId); + if (room === newer.room && newerPages === 0) { + newerPages += 1; + newer.advance(50, 'more'); + } else { + (room === newer.room ? newer : older).advance(0, null); + } + return room; + }), + }); + + await backfillLocalNotifications( + mx, + USER_ID, + { storeContent: true, storeEncryptedContent: true }, + { includeRead: true } + ); + + expect(order).toEqual(['newer', 'older', 'newer']); + }); + + it('scans only direct rooms for DM history', async () => { + const publicRoom = makeHistoryRoom('public', 500); + const directRoom = makeHistoryRoom('direct', 100); + state.directRooms.add('direct'); + const order: string[] = []; + const mx = Object.assign(setup().mx, { + getRooms: () => [publicRoom.room, directRoom.room], + scrollback: vi.fn(async (room) => { + order.push(room.roomId); + (room === directRoom.room ? directRoom : publicRoom).advance(0, null); + return room; + }), + }); + + await backfillLocalNotifications( + mx, + USER_ID, + { storeContent: true, storeEncryptedContent: true }, + { includeRead: true, tab: 'dms' } + ); + + expect(order).toEqual(['direct']); + }); +}); diff --git a/src/app/utils/localNotificationBackfill.ts b/src/app/utils/localNotificationBackfill.ts new file mode 100644 index 0000000000..7cdd93a648 --- /dev/null +++ b/src/app/utils/localNotificationBackfill.ts @@ -0,0 +1,185 @@ +import type { MatrixClient, Room } from '$types/matrix-sdk'; +import { Direction, EventType } from '$types/matrix-sdk'; +import { NotificationType } from '$types/matrix/room'; +import { getLocalNotificationCache } from '$client/localNotificationCache'; +import { getAccountData, getStateEvent } from '$utils/room/hierarchy'; +import { getMDirects, getNotificationType, getUnreadInfo, isDMRoom } from '$utils/room/unread'; +import { + evaluateNotification, + isAwaitingDecryption, + isStoredNotificationRead, + watchDecryption, + type NotificationTab, + type StoredNotification, +} from './localNotifications'; + +const PAGE_SIZE = 50; +const MAX_PAGES = 5; + +export type ScanContentOptions = { + storeContent: boolean; + storeEncryptedContent: boolean; +}; + +type BackfillOptions = { + includeRead?: boolean; + signal?: AbortSignal; + tab?: NotificationTab; +}; + +const storesContent = (room: Room, options: ScanContentOptions): boolean => + getStateEvent(room, EventType.RoomEncryption) + ? options.storeEncryptedContent + : options.storeContent; + +export const runLiveTimelineScan = async ( + mx: MatrixClient, + userId: string, + mDirects: Set, + content: ScanContentOptions +): Promise => { + const notifications: StoredNotification[] = []; + for (const room of mx.getRooms()) { + const notificationType = getNotificationType(mx, room.roomId); + if (room.isSpaceRoom() || notificationType === NotificationType.Mute) continue; + + for (const event of room.getLiveTimeline().getEvents()) { + const notification = evaluateNotification(mx, room, event, mDirects, notificationType, { + storeContent: storesContent(room, content), + }); + if (notification) notifications.push(notification); + } + // Keep initial rendering responsive with large accounts. + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 0)); + } + getLocalNotificationCache(userId).mergeMany(notifications); + return notifications.length; +}; + +const loadDirectRooms = (mx: MatrixClient): Set => { + const directEvent = getAccountData(mx, EventType.Direct); + return directEvent ? getMDirects(directEvent) : new Set(); +}; + +export const backfillLocalNotifications = async ( + mx: MatrixClient, + userId: string, + content: ScanContentOptions, + options: BackfillOptions = {} +): Promise => { + const { includeRead = false, signal, tab = 'all' } = options; + const cache = getLocalNotificationCache(userId); + const mDirects = loadDirectRooms(mx); + const rooms = mx + .getRooms() + .flatMap((room) => { + if ( + room.isSpaceRoom() || + getNotificationType(mx, room.roomId) === NotificationType.Mute || + (tab === 'dms' && !isDMRoom(room, mDirects)) + ) { + return []; + } + const unread = getUnreadInfo(room, { mDirects }); + const target = + tab === 'mentions' ? unread.highlight : Math.max(unread.total, unread.highlight); + return includeRead || target > 0 + ? [ + { + room, + target: includeRead ? Number.POSITIVE_INFINITY : target, + highlight: unread.highlight, + }, + ] + : []; + }) + .toSorted( + (a, b) => + b.highlight - a.highlight || + Number(isDMRoom(b.room, mDirects)) - Number(isDMRoom(a.room, mDirects)) || + b.room.getLastActiveTimestamp() - a.room.getLastActiveTimestamp() + ); + + const cached = cache.getEntries(); + const cachedIds = new Set(cached.map((entry) => entry.event.event_id)); + const stopWatching: Array<() => void> = []; + const states = rooms.map(({ room, target }) => { + const timeline = room.getLiveTimeline(); + const events = timeline.getEvents(); + const existing = cached.filter( + (entry) => entry.room_id === room.roomId && !isStoredNotificationRead(room, userId, entry) + ).length; + return { + room, + missing: Math.max(0, target - existing), + frontier: events[0]?.getTs() ?? room.getLastActiveTimestamp(), + exhausted: !timeline.getPaginationToken(Direction.Backward), + }; + }); + let pages = 0; + let recorded = 0; + + while (pages < MAX_PAGES) { + if (signal?.aborted) break; + const state = states + .filter((item) => item.missing > 0 && !item.exhausted) + .toSorted((a, b) => b.frontier - a.frontier)[0]; + if (!state) break; + const { room } = state; + const notificationType = getNotificationType(mx, room.roomId); + const timeline = room.getLiveTimeline(); + const previousToken = timeline.getPaginationToken(Direction.Backward); + // eslint-disable-next-line no-await-in-loop + await mx.scrollback(room, PAGE_SIZE); + pages += 1; + const pending: StoredNotification[] = []; + + for (const event of timeline.getEvents().toReversed()) { + const eventId = event.getId(); + if (!eventId || cachedIds.has(eventId)) continue; + cachedIds.add(eventId); + if (!includeRead && room.hasUserReadEvent(userId, eventId)) { + state.missing = 0; + break; + } + const evaluate = () => + evaluateNotification(mx, room, event, mDirects, notificationType, { + storeContent: storesContent(room, content), + }); + const notification = evaluate(); + if (notification) { + pending.push(notification); + state.missing -= 1; + recorded += 1; + } + if (isAwaitingDecryption(event)) { + stopWatching.push( + watchDecryption(event, () => { + const decrypted = evaluate(); + if (decrypted) cache.merge(decrypted); + }) + ); + } + if (state.missing === 0) break; + } + cache.mergeMany(pending); + const events = timeline.getEvents(); + state.frontier = events[0]?.getTs() ?? 0; + const nextToken = timeline.getPaginationToken(Direction.Backward); + state.exhausted = !nextToken || nextToken === previousToken; + } + + if (includeRead) { + const unresolved = states.filter((state) => !state.exhausted); + cache.extendHistoryTo( + tab, + unresolved.length > 0 ? Math.max(...unresolved.map((state) => state.frontier)) : 0 + ); + } + + const stop = () => stopWatching.splice(0).forEach((dispose) => dispose()); + if (signal?.aborted) stop(); + else signal?.addEventListener('abort', stop, { once: true }); + return recorded; +}; diff --git a/src/app/utils/localNotifications.test.ts b/src/app/utils/localNotifications.test.ts new file mode 100644 index 0000000000..d79335547f --- /dev/null +++ b/src/app/utils/localNotifications.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { MatrixClient, MatrixEvent, PushProcessor, Room } from '$types/matrix-sdk'; +import { NotificationType } from '$types/matrix/room'; +import { + evaluateNotification, + isStoredNotificationRead, + sliceNotificationPage, + type StoredNotification, +} from './localNotifications'; + +const ROOM_ID = '!room:example.org'; +const USER_ID = '@me:example.org'; + +const event = (overrides: Partial = {}): MatrixEvent => + ({ + getId: () => '$event', + getSender: () => '@alice:example.org', + getType: () => 'm.room.message', + getContent: () => ({ msgtype: 'm.text', body: 'Hello' }), + getTs: () => 100, + getRelation: () => undefined, + isRedacted: () => false, + isSending: () => false, + ...overrides, + }) as unknown as MatrixEvent; + +const room = (overrides: Partial = {}): Room => + ({ + roomId: ROOM_ID, + isSpaceRoom: () => false, + getJoinedMemberCount: () => 3, + ...overrides, + }) as unknown as Room; + +const client = (actions = { notify: true, tweaks: {} }): MatrixClient => + ({ + getSafeUserId: () => USER_ID, + getUserId: () => USER_ID, + pushRules: { global: {} }, + pushProcessor: { + actionsForEvent: vi.fn().mockReturnValue(actions), + } as unknown as PushProcessor, + }) as unknown as MatrixClient; + +describe('evaluateNotification', () => { + it('records notifying events and their highlight/DM classification', () => { + const result = evaluateNotification( + client({ notify: true, tweaks: { highlight: true } }), + room(), + event(), + new Set([ROOM_ID]), + NotificationType.AllMessages + ); + + expect(result).toMatchObject({ + room_id: ROOM_ID, + highlight: true, + isDM: true, + }); + }); + + it('applies the DM policy when push rules do not notify', () => { + const result = evaluateNotification( + client({ notify: false, tweaks: {} }), + room(), + event(), + new Set([ROOM_ID]), + NotificationType.AllMessages + ); + + expect(result?.isDM).toBe(true); + }); + + it.each([ + ['muted room', room(), event(), NotificationType.Mute], + ['space', room({ isSpaceRoom: () => true }), event(), NotificationType.AllMessages], + ['self event', room(), event({ getSender: () => USER_ID }), NotificationType.AllMessages], + ])('rejects a %s', (_name, targetRoom, targetEvent, notificationType) => { + expect( + evaluateNotification( + client(), + targetRoom as Room, + targetEvent as MatrixEvent, + new Set(), + notificationType as NotificationType + ) + ).toBeUndefined(); + }); +}); + +const stored = ( + id: string, + ts: number, + options: Partial = {} +): StoredNotification => + ({ + room_id: ROOM_ID, + event: { event_id: id, type: 'm.room.message' }, + ts, + highlight: false, + isDM: false, + ...options, + }) as StoredNotification; + +describe('sliceNotificationPage', () => { + const entries = [ + stored('$dm', 3, { isDM: true }), + stored('$mention', 2, { highlight: true }), + stored('$other', 1), + ]; + + it.each([ + ['dms', ['$dm']], + ['mentions', ['$mention']], + ['all', ['$dm', '$mention', '$other']], + ] as const)('implements the %s tab', (tab, ids) => { + const result = sliceNotificationPage(entries, 0, 10, tab, true, () => false); + expect(result.page.map((entry) => entry.event.event_id)).toEqual(ids); + }); + + it('filters read entries and paginates newest first', () => { + const result = sliceNotificationPage(entries, 0, 1, 'all', false, (entry) => entry.ts === 3); + expect(result.page[0]?.event.event_id).toBe('$mention'); + expect(result.nextToken).toBe('1'); + }); +}); + +describe('isStoredNotificationRead', () => { + it('uses the SDK read relation when the event is loaded', () => { + const targetRoom = room({ + findEventById: () => event(), + hasUserReadEvent: () => true, + }); + expect(isStoredNotificationRead(targetRoom, USER_ID, stored('$event', 100))).toBe(true); + }); + + it('falls back to receipt timestamps for cached events', () => { + const targetRoom = room({ + findEventById: () => undefined, + getReadReceiptForUserId: () => ({ data: { ts: 100 } }) as never, + }); + expect(isStoredNotificationRead(targetRoom, USER_ID, stored('$event', 100))).toBe(true); + }); +}); diff --git a/src/app/utils/localNotifications.ts b/src/app/utils/localNotifications.ts new file mode 100644 index 0000000000..4942241771 --- /dev/null +++ b/src/app/utils/localNotifications.ts @@ -0,0 +1,184 @@ +import type { IContent, IEvent, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; +import { EventType, MatrixEventEvent, ReceiptType } from '$types/matrix-sdk'; +import { NotificationType } from '$types/matrix/room'; +import { isDMRoom, isNotificationEvent } from './room/unread'; + +export type StoredNotification = { + room_id: string; + event: IEvent; + ts: number; + highlight: boolean; + isDM: boolean; +}; + +export type NotificationTab = 'dms' | 'mentions' | 'all'; + +export const MAX_BODY_LENGTH = 120; + +const truncateContent = (content: IContent, storeContent: boolean): IContent => { + if (content.ciphertext !== undefined) { + return { ...content }; + } + + if (!storeContent) { + return typeof content.msgtype === 'string' ? { msgtype: content.msgtype } : {}; + } + + const body = typeof content.body === 'string' ? content.body : undefined; + const formattedBody = + typeof content.formatted_body === 'string' ? content.formatted_body : undefined; + + const bodyTooLong = body !== undefined && body.length > MAX_BODY_LENGTH; + const formattedTooLong = formattedBody !== undefined && formattedBody.length > MAX_BODY_LENGTH; + + const truncated: IContent = { ...content }; + if (!bodyTooLong && !formattedTooLong) return truncated; + + if (bodyTooLong) truncated.body = `${body.slice(0, MAX_BODY_LENGTH)}…`; + delete truncated.formatted_body; + delete truncated.format; + return truncated; +}; + +const latestReceiptTs = (room: Room, userId: string): number | undefined => { + const timestamps = [ReceiptType.Read, ReceiptType.ReadPrivate] + .map((type) => room.getReadReceiptForUserId(userId, false, type)?.data?.ts) + .filter((ts): ts is number => typeof ts === 'number'); + + return timestamps.length === 0 ? undefined : Math.max(...timestamps); +}; + +export const isStoredNotificationRead = ( + room: Room, + userId: string, + entry: StoredNotification +): boolean => { + if (room.findEventById(entry.event.event_id)) { + return room.hasUserReadEvent(userId, entry.event.event_id); + } + + const receiptTs = latestReceiptTs(room, userId); + if (receiptTs === undefined) return false; + return entry.ts <= receiptTs; +}; + +export const arePushRulesReady = (mx: MatrixClient): boolean => mx.pushRules?.global !== undefined; + +const DM_OVERRIDE_EXCLUDED = new Set(['m.reaction', 'm.room.create']); + +export type EvaluateOptions = { + /** False when the user opted out of persisting message content. */ + storeContent?: boolean; +}; + +export const evaluateNotification = ( + mx: MatrixClient, + room: Room, + mEvent: MatrixEvent, + mDirects: Set, + notificationType: NotificationType, + options?: EvaluateOptions +): StoredNotification | undefined => { + if (!arePushRulesReady(mx)) { + return undefined; + } + + if (notificationType === NotificationType.Mute) { + return undefined; + } + + if (room.isSpaceRoom()) { + return undefined; + } + + const userId = mx.getSafeUserId() ?? mx.getUserId() ?? ''; + if (!isNotificationEvent(mEvent, room, userId)) { + return undefined; + } + + if (mEvent.getSender() === userId) { + return undefined; + } + + if (mEvent.isSending()) { + return undefined; + } + + const pushProcessor = mx.pushProcessor; + const actions = pushProcessor.actionsForEvent(mEvent); + let notify = actions.notify; + const highlight = actions.tweaks?.highlight === true; + + const isDM = isDMRoom(room, mDirects); + notify = + notify || + (isDM && + notificationType !== NotificationType.MentionsAndKeywords && + !DM_OVERRIDE_EXCLUDED.has(mEvent.getType())); + + if (!notify) { + return undefined; + } + + const event: IEvent = { + event_id: mEvent.getId()!, + type: mEvent.getType(), + content: truncateContent(mEvent.getContent(), options?.storeContent !== false), + sender: mEvent.getSender()!, + origin_server_ts: mEvent.getTs(), + room_id: room.roomId, + unsigned: {}, + }; + + return { + room_id: room.roomId, + event, + ts: mEvent.getTs(), + highlight, + isDM, + }; +}; + +export const DECRYPT_WAIT_MS = 30_000; + +export const isAwaitingDecryption = (mEvent: MatrixEvent): boolean => + mEvent.getType() === (EventType.RoomMessageEncrypted as string) && mEvent.isEncrypted(); + +export const watchDecryption = ( + mEvent: MatrixEvent, + onDecrypted: () => void, + onGiveUp?: () => void +): (() => void) => { + const listener = () => { + if (mEvent.isDecryptionFailure()) return; + onDecrypted(); + }; + mEvent.on(MatrixEventEvent.Decrypted, listener); + const giveUpId = onGiveUp && setTimeout(onGiveUp, DECRYPT_WAIT_MS); + + return () => { + if (giveUpId !== undefined) clearTimeout(giveUpId); + mEvent.off(MatrixEventEvent.Decrypted, listener); + }; +}; + +export const sliceNotificationPage = ( + all: StoredNotification[], + offset: number, + limit: number, + tab: NotificationTab, + includeRead: boolean, + isRead: (entry: StoredNotification) => boolean +): { page: StoredNotification[]; nextToken?: string } => { + const sorted = all + .filter((entry) => { + if (tab === 'dms' && !entry.isDM) return false; + if (tab === 'mentions' && !entry.highlight) return false; + return includeRead || !isRead(entry); + }) + .toSorted((a, b) => b.ts - a.ts); + const page = sorted.slice(offset, offset + limit); + const nextOffset = offset + limit; + const nextToken = nextOffset < sorted.length ? String(nextOffset) : undefined; + return { page, nextToken }; +}; diff --git a/src/app/utils/notifications.test.ts b/src/app/utils/notifications.test.ts index ded5ab1beb..c9228b04da 100644 --- a/src/app/utils/notifications.test.ts +++ b/src/app/utils/notifications.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; -import { ReceiptType } from '$types/matrix-sdk'; +import { NotificationCountType } from '$types/matrix-sdk'; import { markAsRead } from './notifications'; vi.mock('@tauri-apps/api/core', () => ({ isTauri: () => false })); @@ -25,12 +25,11 @@ const makeMx = (events: MatrixEvent[], readUpTo: string | null) => { ) => Promise >() .mockResolvedValue(); - const sendReadReceipt = vi - .fn<(event: MatrixEvent, receiptType: ReceiptType) => Promise>() - .mockResolvedValue(); + const setUnreadNotificationCount = vi.fn<(type: NotificationCountType, count: number) => void>(); const room = { getLiveTimeline: () => ({ getEvents: () => events }), getEventReadUpTo: () => readUpTo, + setUnreadNotificationCount, }; return { @@ -38,10 +37,9 @@ const makeMx = (events: MatrixEvent[], readUpTo: string | null) => { getRoom: (id: string) => (id === roomId ? room : null), getUserId: () => userId, setRoomReadMarkers, - sendReadReceipt, } as unknown as MatrixClient, setRoomReadMarkers, - sendReadReceipt, + setUnreadNotificationCount, }; }; @@ -49,7 +47,7 @@ const makeMx = (events: MatrixEvent[], readUpTo: string | null) => { // defects belong in the sliding-sync layer, not here. describe('markAsRead', () => { it('marks read up to the last event in the timeline', async () => { - const { mx, setRoomReadMarkers, sendReadReceipt } = makeMx( + const { mx, setRoomReadMarkers, setUnreadNotificationCount } = makeMx( [event('$older'), event('$newest')], null ); @@ -57,12 +55,12 @@ describe('markAsRead', () => { await markAsRead(mx, roomId, false); expect(setRoomReadMarkers).toHaveBeenCalledWith(roomId, '$newest', expect.anything()); - expect(sendReadReceipt).toHaveBeenCalledWith(expect.anything(), ReceiptType.Read); - expect(sendReadReceipt.mock.calls[0]?.[0].getId()).toBe('$newest'); + expect(setUnreadNotificationCount).toHaveBeenCalledWith(NotificationCountType.Total, 0); + expect(setUnreadNotificationCount).toHaveBeenCalledWith(NotificationCountType.Highlight, 0); }); it('does nothing when the last event is already read', async () => { - const { mx, setRoomReadMarkers, sendReadReceipt } = makeMx( + const { mx, setRoomReadMarkers, setUnreadNotificationCount } = makeMx( [event('$older'), event('$newest')], '$newest' ); @@ -70,19 +68,19 @@ describe('markAsRead', () => { await markAsRead(mx, roomId, false); expect(setRoomReadMarkers).not.toHaveBeenCalled(); - expect(sendReadReceipt).not.toHaveBeenCalled(); + expect(setUnreadNotificationCount).not.toHaveBeenCalled(); }); it('ignores events that are still sending', async () => { - const { mx, sendReadReceipt } = makeMx([event('$confirmed'), event('$local', true)], null); + const { mx, setRoomReadMarkers } = makeMx([event('$confirmed'), event('$local', true)], null); await markAsRead(mx, roomId, false); - expect(sendReadReceipt.mock.calls[0]?.[0].getId()).toBe('$confirmed'); + expect(setRoomReadMarkers.mock.calls[0]?.[1]).toBe('$confirmed'); }); it('sends a private receipt when reads are hidden', async () => { - const { mx, setRoomReadMarkers, sendReadReceipt } = makeMx([event('$newest')], null); + const { mx, setRoomReadMarkers } = makeMx([event('$newest')], null); await markAsRead(mx, roomId, true); @@ -92,15 +90,14 @@ describe('markAsRead', () => { undefined, expect.anything() ); - expect(sendReadReceipt).toHaveBeenCalledWith(expect.anything(), ReceiptType.ReadPrivate); }); it('does nothing for an empty timeline', async () => { - const { mx, setRoomReadMarkers, sendReadReceipt } = makeMx([], null); + const { mx, setRoomReadMarkers, setUnreadNotificationCount } = makeMx([], null); await markAsRead(mx, roomId, false); expect(setRoomReadMarkers).not.toHaveBeenCalled(); - expect(sendReadReceipt).not.toHaveBeenCalled(); + expect(setUnreadNotificationCount).not.toHaveBeenCalled(); }); }); diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts index 788a68ad6b..a497ffbe8a 100644 --- a/src/app/utils/notifications.ts +++ b/src/app/utils/notifications.ts @@ -1,5 +1,5 @@ import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; -import { ReceiptType } from '$types/matrix-sdk'; +import { NotificationCountType } from '$types/matrix-sdk'; import { isTauri } from '@tauri-apps/api/core'; export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { @@ -25,19 +25,13 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip const latestEventId = latestEvent.getId(); if (!latestEventId) return; - // Update both read receipt and fully-read marker so unread state clears reliably - // across clients and bridge-heavy rooms where hidden events may exist. if (privateReceipt) { await mx.setRoomReadMarkers(roomId, latestEventId, undefined, latestEvent); } else { await mx.setRoomReadMarkers(roomId, latestEventId, latestEvent); } - - // Keep legacy receipt path as a safety fallback for homeservers with partial support. - await mx.sendReadReceipt( - latestEvent, - privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read - ); + room.setUnreadNotificationCount(NotificationCountType.Total, 0); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); // On Android (Tauri), dismiss the room's OS notification immediately so // it stays in sync with the read state instead of lingering until the diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 05734821ea..338e65b70d 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -42,6 +42,10 @@ import { import { PresenceSyncManager } from './presenceSync'; import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache'; import { clearCachedUserProfiles } from './userProfileCache'; +import { + clearLocalNotificationCache, + destroyLocalNotificationCache, +} from './localNotificationCache'; import { primeVersionsFromCache, revalidateVersionsCache, @@ -652,6 +656,8 @@ export const logoutClient = async (mx: MatrixClient, session?: Session) => { clearCachedVersions(session.baseUrl, session.userId); clearCachedUserProfiles(session.userId); clearSecretStorageKeys(); + destroyLocalNotificationCache(session.userId); + clearLocalNotificationCache(session.userId); const storeName: SessionStoreName = getSessionStoreName(session); await mx.clearStores({ cryptoDatabasePrefix: storeName.rustCryptoPrefix }); await deleteDatabase(storeName.sync); diff --git a/src/client/localNotificationCache.test.ts b/src/client/localNotificationCache.test.ts new file mode 100644 index 0000000000..1d088d7d1b --- /dev/null +++ b/src/client/localNotificationCache.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { StoredNotification } from '$utils/localNotifications'; +import { LocalNotificationCache } from './localNotificationCache'; + +const USER_ID = '@me:example.org'; +const caches: LocalNotificationCache[] = []; + +const entry = (id: string, ts: number): StoredNotification => + ({ + room_id: '!room:example.org', + event: { event_id: id, type: 'm.room.message' }, + ts, + highlight: false, + isDM: false, + }) as StoredNotification; + +const open = () => { + const cache = new LocalNotificationCache(USER_ID); + caches.push(cache); + return cache; +}; + +beforeEach(() => localStorage.clear()); +afterEach(() => { + caches.splice(0).forEach((cache) => cache.destroy()); + localStorage.clear(); +}); + +describe('LocalNotificationCache', () => { + it('upserts by event id and sorts newest first', () => { + const cache = open(); + cache.mergeMany([entry('$a', 1), entry('$b', 3), entry('$a', 2)]); + expect(cache.getEntries().map((item) => [item.event.event_id, item.ts])).toEqual([ + ['$b', 3], + ['$a', 2], + ]); + }); + + it('persists entries', () => { + const cache = open(); + cache.merge(entry('$a', 1)); + cache.destroy(); + + const restored = open(); + expect(restored.getEntries()[0]?.event.event_id).toBe('$a'); + }); + + it('only extends the complete history boundary backward', () => { + const cache = open(); + cache.extendHistoryTo('dms', 100); + cache.extendHistoryTo('dms', 200); + cache.extendHistoryTo('dms', 50); + expect(cache.getHistoryCutoff('dms')).toBe(50); + expect(cache.getHistoryCutoff('all')).toBeUndefined(); + }); + + it('retains only the newest 5,000 entries', () => { + const cache = open(); + cache.mergeMany(Array.from({ length: 5_010 }, (_, index) => entry(`$${index}`, index))); + expect(cache.getEntries()).toHaveLength(5_000); + expect(cache.getEntries().at(-1)?.ts).toBe(10); + }); +}); diff --git a/src/client/localNotificationCache.ts b/src/client/localNotificationCache.ts new file mode 100644 index 0000000000..d046ad2eab --- /dev/null +++ b/src/client/localNotificationCache.ts @@ -0,0 +1,134 @@ +import type { NotificationTab, StoredNotification } from '$utils/localNotifications'; + +export const NOTIFICATION_CACHE_KEY_PREFIX = 'sable.notificationCache.'; + +const CACHE_VERSION = 4; +const MAX_ENTRIES = 5_000; + +type CacheData = { + version: number; + entries: StoredNotification[]; + historyCutoffs?: Partial>; +}; + +const storageKeyFor = (userId: string): string => + `${NOTIFICATION_CACHE_KEY_PREFIX}v${CACHE_VERSION}.${encodeURIComponent(userId)}`; + +const emptyCache = (): CacheData => ({ version: CACHE_VERSION, entries: [] }); + +const readCache = (key: string): CacheData => { + try { + const value = globalThis.localStorage?.getItem(key); + if (!value) return emptyCache(); + const data = JSON.parse(value) as Partial; + return data.version === CACHE_VERSION && Array.isArray(data.entries) + ? { + version: CACHE_VERSION, + entries: data.entries, + historyCutoffs: data.historyCutoffs, + } + : emptyCache(); + } catch { + return emptyCache(); + } +}; + +export class LocalNotificationCache { + private data: CacheData; + private readonly key: string; + private readonly listeners = new Set<() => void>(); + + constructor(readonly userId: string) { + this.key = storageKeyFor(userId); + this.data = readCache(this.key); + globalThis.addEventListener?.('storage', this.onStorage); + } + + merge(entry: StoredNotification): void { + this.mergeMany([entry]); + } + + mergeMany(entries: StoredNotification[]): void { + if (entries.length === 0) return; + const merged = new Map( + [...this.data.entries, ...entries].map((entry) => [entry.event.event_id, entry]) + ); + this.data.entries = [...merged.values()].toSorted((a, b) => b.ts - a.ts).slice(0, MAX_ENTRIES); + this.write(); + this.notify(); + } + + getEntries(): StoredNotification[] { + return this.data.entries.map((entry) => ({ ...entry })); + } + + getHistoryCutoff(tab: NotificationTab): number | undefined { + return this.data.historyCutoffs?.[tab]; + } + + extendHistoryTo(tab: NotificationTab, timestamp: number): void { + const current = this.data.historyCutoffs?.[tab]; + if (current !== undefined && timestamp >= current) return; + this.data.historyCutoffs = { ...this.data.historyCutoffs, [tab]: timestamp }; + this.write(); + this.notify(); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + destroy(): void { + globalThis.removeEventListener?.('storage', this.onStorage); + this.listeners.clear(); + } + + private write(): void { + let entries = this.data.entries; + for (;;) { + try { + globalThis.localStorage?.setItem(this.key, JSON.stringify({ ...this.data, entries })); + this.data.entries = entries; + return; + } catch { + if (entries.length === 0) return; + entries = entries.slice(0, Math.floor(entries.length / 2)); + } + } + } + + private notify(): void { + this.listeners.forEach((listener) => listener()); + } + + private onStorage = (event: StorageEvent): void => { + if (event.key !== this.key) return; + this.data = readCache(this.key); + this.notify(); + }; +} + +const instances = new Map(); + +export const getLocalNotificationCache = (userId: string): LocalNotificationCache => { + const existing = instances.get(userId); + if (existing) return existing; + const cache = new LocalNotificationCache(userId); + instances.set(userId, cache); + return cache; +}; + +export const destroyLocalNotificationCache = (userId: string): void => { + instances.get(userId)?.destroy(); + instances.delete(userId); +}; + +export const clearLocalNotificationCache = (userId: string): void => { + destroyLocalNotificationCache(userId); + try { + globalThis.localStorage?.removeItem(storageKeyFor(userId)); + } catch { + // Storage may be disabled; logout should still finish. + } +}; diff --git a/src/client/slidingSyncSidebarCache.ts b/src/client/slidingSyncSidebarCache.ts index 8bdcff1d8e..d75f0ca347 100644 --- a/src/client/slidingSyncSidebarCache.ts +++ b/src/client/slidingSyncSidebarCache.ts @@ -10,7 +10,7 @@ import { CustomAccountDataEvent } from '$types/matrix/accountData'; import { CustomStateEvent } from '$types/matrix/room'; const CACHE_VERSION = 1; -const CACHE_KEY_PREFIX = 'sable.slidingSyncSidebarCache.'; +export const SIDEBAR_CACHE_KEY_PREFIX = 'sable.slidingSyncSidebarCache.'; const CACHE_WRITE_DELAY_MS = 500; const MAX_CACHED_ROOMS = 2000; const HYDRATION_BATCH_SIZE = 50; @@ -169,7 +169,9 @@ const hydrateRoomBatch = async ( export class SlidingSyncSidebarCache { public static clear(userId: string): void { try { - globalThis.localStorage?.removeItem(`${CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`); + globalThis.localStorage?.removeItem( + `${SIDEBAR_CACHE_KEY_PREFIX}${encodeURIComponent(userId)}` + ); } catch { // Storage can be disabled for this origin. } @@ -184,7 +186,7 @@ export class SlidingSyncSidebarCache { private idleWriteHandle: number | undefined; public constructor(private readonly userId: string) { - this.storageKey = `${CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`; + this.storageKey = `${SIDEBAR_CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`; let stored: string | null = null; try { stored = globalThis.localStorage?.getItem(this.storageKey) ?? null;