From 73f84c13f9388352ff8fb7e369b72cc4f20e9ea6 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 26 Jul 2026 13:11:18 +0200 Subject: [PATCH 1/6] feat(notifications): build the inbox from locally evaluated push rules --- .changeset/local-notification-inbox.md | 5 + .../hooks/useInboxNotificationCount.test.tsx | 187 +++++ src/app/hooks/useInboxNotificationCount.ts | 115 +++ src/app/hooks/useInterval.ts | 24 - ...eLocalNotificationTimeline.render.test.tsx | 133 ++++ .../useLocalNotificationTimeline.test.ts | 80 +++ src/app/hooks/useLocalNotificationTimeline.ts | 137 ++++ src/app/pages/client/ClientNonUIFeatures.tsx | 2 + .../client/client-non-ui/notifications.tsx | 214 ++++++ src/app/pages/client/inbox/Inbox.tsx | 61 +- src/app/pages/client/inbox/Notifications.tsx | 251 +++---- src/app/state/sessions.ts | 3 +- src/app/state/utils/atomWithLocalStorage.ts | 19 + src/app/utils/groupNotifications.ts | 34 + .../utils/localNotificationBackfill.test.ts | 669 ++++++++++++++++++ src/app/utils/localNotificationBackfill.ts | 199 ++++++ src/app/utils/localNotifications.test.ts | 582 +++++++++++++++ src/app/utils/localNotifications.ts | 201 ++++++ src/client/initMatrix.ts | 6 + src/client/localNotificationCache.test.ts | 321 +++++++++ src/client/localNotificationCache.ts | 325 +++++++++ 21 files changed, 3372 insertions(+), 196 deletions(-) create mode 100644 .changeset/local-notification-inbox.md create mode 100644 src/app/hooks/useInboxNotificationCount.test.tsx create mode 100644 src/app/hooks/useInboxNotificationCount.ts delete mode 100644 src/app/hooks/useInterval.ts create mode 100644 src/app/hooks/useLocalNotificationTimeline.render.test.tsx create mode 100644 src/app/hooks/useLocalNotificationTimeline.test.ts create mode 100644 src/app/hooks/useLocalNotificationTimeline.ts create mode 100644 src/app/utils/groupNotifications.ts create mode 100644 src/app/utils/localNotificationBackfill.test.ts create mode 100644 src/app/utils/localNotificationBackfill.ts create mode 100644 src/app/utils/localNotifications.test.ts create mode 100644 src/app/utils/localNotifications.ts create mode 100644 src/client/localNotificationCache.test.ts create mode 100644 src/client/localNotificationCache.ts diff --git a/.changeset/local-notification-inbox.md b/.changeset/local-notification-inbox.md new file mode 100644 index 0000000000..1371a1e691 --- /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. Notifications can be dismissed individually, the inbox defaults to mentions and DMs, and returning after time away backfills what was missed. diff --git a/src/app/hooks/useInboxNotificationCount.test.tsx b/src/app/hooks/useInboxNotificationCount.test.tsx new file mode 100644 index 0000000000..02920a818e --- /dev/null +++ b/src/app/hooks/useInboxNotificationCount.test.tsx @@ -0,0 +1,187 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TypedEventEmitter } from 'matrix-js-sdk/lib/models/typed-event-emitter'; +import type { MatrixClient, Room } from '$types/matrix-sdk'; +import { RoomEvent } from '$types/matrix-sdk'; +import type { StoredNotification } from '$utils/localNotifications'; +import { + clearLocalNotificationCache, + destroyLocalNotificationCache, + getLocalNotificationCache, +} from '$client/localNotificationCache'; +import { useInboxNotificationCount } from './useInboxNotificationCount'; + +const USER_ID = '@user:example.com'; +const ROOM_ID = '!room:example.com'; + +type RoomBehaviour = { + read?: boolean; + known?: boolean; + receiptTs?: number; +}; + +const createRoom = ({ read = false, known = true, receiptTs }: RoomBehaviour = {}): Room => + ({ + roomId: ROOM_ID, + findEventById: (id: string) => + known && !id.startsWith('$receipt') ? { getTs: () => 1 } : undefined, + hasUserReadEvent: () => read, + getReadReceiptForUserId: () => + receiptTs === undefined ? null : { eventId: '$receipt', data: { ts: receiptTs } }, + }) as unknown as Room; + +let currentRoom: Room | undefined = createRoom(); + +const emitter = new TypedEventEmitter void>>(); +const makeClient = () => + Object.assign(Object.create(Object.getPrototypeOf(emitter) as object), emitter, { + getSafeUserId: () => USER_ID, + getRoom: () => currentRoom, + }) as unknown as MatrixClient; + +let mockClient = makeClient(); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockClient, +})); + +const entry = ( + eventId: string, + overrides: Partial = {} +): StoredNotification => ({ + room_id: ROOM_ID, + event: { + event_id: eventId, + type: 'm.room.message', + content: { body: eventId, msgtype: 'm.text' }, + sender: '@other:example.com', + origin_server_ts: 1000, + room_id: ROOM_ID, + unsigned: {}, + }, + ts: 1000, + highlight: true, + isDM: false, + ...overrides, +}); + +beforeEach(() => { + localStorage.clear(); + currentRoom = createRoom(); + emitter.removeAllListeners(); + mockClient = makeClient(); +}); + +afterEach(() => { + destroyLocalNotificationCache(USER_ID); + clearLocalNotificationCache(USER_ID); + localStorage.clear(); +}); + +describe('useInboxNotificationCount', () => { + it('counts an unread mention', async () => { + getLocalNotificationCache(USER_ID).merge(entry('$a')); + + const { result } = renderHook(() => useInboxNotificationCount()); + + await waitFor(() => expect(result.current).toBe(1)); + }); + + it('counts an unread DM even without a highlight', async () => { + getLocalNotificationCache(USER_ID).merge(entry('$a', { highlight: false, isDM: true })); + + const { result } = renderHook(() => useInboxNotificationCount()); + + await waitFor(() => expect(result.current).toBe(1)); + }); + + it('ignores an entry that is neither a highlight nor a DM', async () => { + getLocalNotificationCache(USER_ID).merge(entry('$a', { highlight: false, isDM: false })); + + const { result } = renderHook(() => useInboxNotificationCount()); + + await waitFor(() => expect(result.current).toBe(0)); + }); + + it('ignores a dismissed entry', async () => { + getLocalNotificationCache(USER_ID).merge(entry('$a', { dismissed: true })); + + const { result } = renderHook(() => useInboxNotificationCount()); + + await waitFor(() => expect(result.current).toBe(0)); + }); + + it('ignores an entry the user has already read', async () => { + currentRoom = createRoom({ read: true }); + getLocalNotificationCache(USER_ID).merge(entry('$a')); + + const { result } = renderHook(() => useInboxNotificationCount()); + + await waitFor(() => expect(result.current).toBe(0)); + }); + + // Under sliding sync an entry outside the loaded window is routine; it must + // still badge rather than being treated as read. + it('counts an entry whose event is outside the loaded timeline', async () => { + currentRoom = createRoom({ known: false }); + getLocalNotificationCache(USER_ID).merge(entry('$a')); + + const { result } = renderHook(() => useInboxNotificationCount()); + + await waitFor(() => expect(result.current).toBe(1)); + }); + + it('stops counting once a receipt covers an aged-out entry', async () => { + currentRoom = createRoom({ known: false, receiptTs: 5000 }); + getLocalNotificationCache(USER_ID).merge(entry('$a')); + + const { result } = renderHook(() => useInboxNotificationCount()); + + await waitFor(() => expect(result.current).toBe(0)); + }); + + it('picks up a notification recorded after mount', async () => { + const { result } = renderHook(() => useInboxNotificationCount()); + await waitFor(() => expect(result.current).toBe(0)); + + await act(async () => { + getLocalNotificationCache(USER_ID).merge(entry('$late')); + await new Promise((resolve) => setTimeout(resolve, 600)); + }); + + await waitFor(() => expect(result.current).toBe(1)); + }); + + it('shares one subscription across consumers', async () => { + getLocalNotificationCache(USER_ID).merge(entry('$a')); + + const first = renderHook(() => useInboxNotificationCount()); + const second = renderHook(() => useInboxNotificationCount()); + + await waitFor(() => expect(first.result.current).toBe(1)); + expect(second.result.current).toBe(1); + expect(emitter.listenerCount(RoomEvent.Receipt)).toBe(1); + + first.unmount(); + expect(emitter.listenerCount(RoomEvent.Receipt)).toBe(1); + + const third = renderHook(() => useInboxNotificationCount()); + expect(emitter.listenerCount(RoomEvent.Receipt)).toBe(1); + + second.unmount(); + third.unmount(); + }); + + it('rebuilds the store when the client is replaced', async () => { + getLocalNotificationCache(USER_ID).merge(entry('$a')); + + const first = renderHook(() => useInboxNotificationCount()); + await waitFor(() => expect(first.result.current).toBe(1)); + first.unmount(); + + mockClient = makeClient(); + const second = renderHook(() => useInboxNotificationCount()); + + await waitFor(() => expect(second.result.current).toBe(1)); + }); +}); diff --git a/src/app/hooks/useInboxNotificationCount.ts b/src/app/hooks/useInboxNotificationCount.ts new file mode 100644 index 0000000000..6a885f5f70 --- /dev/null +++ b/src/app/hooks/useInboxNotificationCount.ts @@ -0,0 +1,115 @@ +import { useSyncExternalStore } from 'react'; +import type { MatrixClient, RoomEventHandlerMap } from '$types/matrix-sdk'; +import { RoomEvent } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { getLocalNotificationCache } from '$client/localNotificationCache'; +import { isStoredNotificationRead, type StoredNotification } from '$utils/localNotifications'; + +const RECOMPUTE_THROTTLE_MS = 500; + +type ReceiptContent = Record>>; + +// Counting costs a room and timeline lookup per entry, so consumers share one +// subscription and one receipt listener, throttled. +class InboxCountStore { + private count = 0; + private readonly subscribers = new Set<() => void>(); + private trailing: ReturnType | undefined; + private lastRun = 0; + private detach: (() => void) | undefined; + + constructor( + readonly mx: MatrixClient, + private readonly userId: string + ) {} + + getSnapshot = (): number => this.count; + + subscribe = (onChange: () => void): (() => void) => { + this.subscribers.add(onChange); + if (this.subscribers.size === 1) this.attach(); + + return () => { + this.subscribers.delete(onChange); + if (this.subscribers.size === 0) this.teardown(); + }; + }; + + private counts = (entry: StoredNotification): boolean => { + if (entry.dismissed) return false; + if (!entry.highlight && !entry.isDM) return false; + + const room = this.mx.getRoom(entry.room_id); + if (!room) return false; + + return !isStoredNotificationRead(room, this.userId, entry); + }; + + private recompute = (): void => { + this.lastRun = Date.now(); + const next = getLocalNotificationCache(this.userId).countEntries(this.counts); + if (next === this.count) return; + this.count = next; + for (const onChange of this.subscribers) onChange(); + }; + + private schedule = (): void => { + const elapsed = Date.now() - this.lastRun; + if (elapsed >= RECOMPUTE_THROTTLE_MS) { + this.recompute(); + return; + } + if (this.trailing !== undefined) return; + this.trailing = setTimeout(() => { + this.trailing = undefined; + this.recompute(); + }, RECOMPUTE_THROTTLE_MS - elapsed); + }; + + private onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event) => { + const content = event.getContent(); + const readByUs = Object.values(content).some((byType) => + Object.values(byType).some((receipts) => this.userId in receipts) + ); + if (readByUs) this.schedule(); + }; + + private attach(): void { + const unsubscribe = getLocalNotificationCache(this.userId).subscribe(this.schedule); + this.mx.on(RoomEvent.Receipt, this.onReceipt); + this.detach = () => { + unsubscribe(); + this.mx.off(RoomEvent.Receipt, this.onReceipt); + }; + this.recompute(); + } + + private teardown(): void { + this.detach?.(); + this.detach = undefined; + if (this.trailing !== undefined) { + clearTimeout(this.trailing); + this.trailing = undefined; + } + stores.delete(this.userId); + } +} + +const stores = new Map(); + +const getStore = (mx: MatrixClient, userId: string): InboxCountStore => { + const existing = stores.get(userId); + // The old client's getRoom() answers null for everything, i.e. a zero count. + if (existing && existing.mx === mx) return existing; + + const store = new InboxCountStore(mx, userId); + stores.set(userId, store); + return store; +}; + +export const useInboxNotificationCount = (): number => { + const mx = useMatrixClient(); + const store = getStore(mx, mx.getSafeUserId()); + + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); +}; 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.render.test.tsx b/src/app/hooks/useLocalNotificationTimeline.render.test.tsx new file mode 100644 index 0000000000..400c04e925 --- /dev/null +++ b/src/app/hooks/useLocalNotificationTimeline.render.test.tsx @@ -0,0 +1,133 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TypedEventEmitter } from 'matrix-js-sdk/lib/models/typed-event-emitter'; +import type { StoredNotification } from '$utils/localNotifications'; +import { + clearLocalNotificationCache, + destroyLocalNotificationCache, + getLocalNotificationCache, +} from '$client/localNotificationCache'; +import { useLocalNotificationTimeline } from './useLocalNotificationTimeline'; + +const USER_ID = '@user:example.com'; +const ROOM_ID = '!room:example.com'; + +const emitter = new TypedEventEmitter void>>(); +const mockClient = Object.assign(emitter, { getSafeUserId: () => USER_ID }); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockClient, +})); + +vi.mock('$state/room-list/roomList', () => ({ + allRoomsAtom: { toString: () => 'allRoomsAtom' }, +})); + +vi.mock('jotai', () => ({ + useAtomValue: () => [ROOM_ID], +})); + +const entry = (eventId: string, ts: number): StoredNotification => ({ + room_id: ROOM_ID, + event: { + event_id: eventId, + type: 'm.room.message', + content: { body: eventId, msgtype: 'm.text' }, + sender: '@other:example.com', + origin_server_ts: ts, + room_id: ROOM_ID, + unsigned: {}, + }, + ts, + highlight: true, + isDM: false, +}); + +const seed = (count: number) => { + const cache = getLocalNotificationCache(USER_ID); + cache.mergeMany(Array.from({ length: count }, (_, i) => entry(`$e${i}`, 1000 + i))); + return cache; +}; + +beforeEach(() => { + localStorage.clear(); + emitter.removeAllListeners(); +}); + +afterEach(() => { + destroyLocalNotificationCache(USER_ID); + clearLocalNotificationCache(USER_ID); + localStorage.clear(); +}); + +describe('useLocalNotificationTimeline render behaviour', () => { + it('settles instead of re-rendering forever when the cache keeps changing', async () => { + const cache = seed(60); + let renders = 0; + + const { result } = renderHook(() => { + renders += 1; + return useLocalNotificationTimeline(24, 'all'); + }); + + await act(async () => { + await result.current[1](); + }); + const afterFirstLoad = renders; + + await act(async () => { + for (let i = 0; i < 10; i += 1) { + cache.merge(entry(`$new${i}`, 5000 + i)); + } + await new Promise((resolve) => setTimeout(resolve, 600)); + }); + + expect(renders - afterFirstLoad).toBeLessThan(10); + }); + + it('does not rewind the loaded window when the cache changes', async () => { + const cache = seed(60); + + const { result } = renderHook(() => useLocalNotificationTimeline(24, 'all')); + + await act(async () => { + await result.current[1](); + }); + const firstPage = result.current[0].groups[0]!.notifications.length; + expect(result.current[0].nextToken).toBe('24'); + + await act(async () => { + await result.current[1](result.current[0].nextToken); + }); + const secondPage = result.current[0].groups[0]!.notifications.length; + expect(secondPage).toBeGreaterThan(firstPage); + + // A silent reload used to reset back to a single page. + await act(async () => { + cache.merge(entry('$live', 9000)); + await new Promise((resolve) => setTimeout(resolve, 600)); + }); + + await waitFor(() => + expect(result.current[0].groups[0]!.notifications.length).toBeGreaterThanOrEqual(secondPage) + ); + }); + + it('keeps the same timeline object when nothing changed', async () => { + const cache = seed(5); + + const { result } = renderHook(() => useLocalNotificationTimeline(24, 'all')); + + await act(async () => { + await result.current[1](); + }); + const before = result.current[0]; + + await act(async () => { + cache.mergeMany([entry('$e0', 1000)]); + await new Promise((resolve) => setTimeout(resolve, 600)); + }); + + expect(result.current[0]).toBe(before); + }); +}); diff --git a/src/app/hooks/useLocalNotificationTimeline.test.ts b/src/app/hooks/useLocalNotificationTimeline.test.ts new file mode 100644 index 0000000000..6d0a174afa --- /dev/null +++ b/src/app/hooks/useLocalNotificationTimeline.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import type { StoredNotification } from '$utils/localNotifications'; +import { sameNotificationTimeline } from './useLocalNotificationTimeline'; + +const entry = (eventId: string, overrides: Partial = {}): StoredNotification => + ({ + room_id: '!room:example.com', + event: { event_id: eventId, type: 'm.room.message' }, + ts: 1000, + highlight: false, + isDM: false, + ...overrides, + }) as StoredNotification; + +const timeline = (nextToken: string | undefined, notifications: StoredNotification[]) => ({ + nextToken, + groups: notifications.length ? [{ roomId: '!room:example.com', notifications }] : [], +}); + +describe('sameNotificationTimeline', () => { + it('treats a freshly recomputed but identical timeline as unchanged', () => { + const a = timeline('24', [entry('$1'), entry('$2')]); + const b = timeline('24', [entry('$1'), entry('$2')]); + + expect(sameNotificationTimeline(a, b)).toBe(true); + }); + + it('detects a new notification', () => { + const a = timeline('24', [entry('$1')]); + const b = timeline('24', [entry('$1'), entry('$2')]); + + expect(sameNotificationTimeline(a, b)).toBe(false); + }); + + it('detects pagination advancing', () => { + const a = timeline('24', [entry('$1')]); + const b = timeline('48', [entry('$1')]); + + expect(sameNotificationTimeline(a, b)).toBe(false); + }); + + it('detects a notification being dismissed', () => { + const a = timeline('24', [entry('$1')]); + const b = timeline('24', [entry('$1', { dismissed: true })]); + + expect(sameNotificationTimeline(a, b)).toBe(false); + }); + + it('detects an encrypted snapshot being replaced by its decrypted one', () => { + const a = timeline('24', [ + entry('$1', { event: { event_id: '$1', type: 'm.room.encrypted' } as never }), + ]); + const b = timeline('24', [entry('$1')]); + + expect(sameNotificationTimeline(a, b)).toBe(false); + }); + + it('detects reordering across rooms', () => { + const a = { + nextToken: '24', + groups: [ + { roomId: '!a:example.com', notifications: [entry('$1')] }, + { roomId: '!b:example.com', notifications: [entry('$2')] }, + ], + }; + const b = { + nextToken: '24', + groups: [ + { roomId: '!b:example.com', notifications: [entry('$2')] }, + { roomId: '!a:example.com', notifications: [entry('$1')] }, + ], + }; + + expect(sameNotificationTimeline(a, b)).toBe(false); + }); + + it('treats two empty timelines as unchanged', () => { + expect(sameNotificationTimeline(timeline(undefined, []), timeline(undefined, []))).toBe(true); + }); +}); diff --git a/src/app/hooks/useLocalNotificationTimeline.ts b/src/app/hooks/useLocalNotificationTimeline.ts new file mode 100644 index 0000000000..3eef925e5f --- /dev/null +++ b/src/app/hooks/useLocalNotificationTimeline.ts @@ -0,0 +1,137 @@ +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 { getLocalNotificationCache } from '$client/localNotificationCache'; +import { sliceNotificationPage, type StoredNotification } from '$utils/localNotifications'; +import { groupNotifications } from '$utils/groupNotifications'; + +type RoomNotificationsGroup = { + roomId: string; + notifications: StoredNotification[]; +}; +type NotificationTimeline = { + nextToken?: string; + groups: RoomNotificationsGroup[]; +}; +const RELOAD_THROTTLE_MS = 500; + +type LoadTimeline = (from?: string) => Promise; +type SilentReloadTimeline = () => Promise; + +export const sameNotificationTimeline = ( + a: NotificationTimeline, + b: NotificationTimeline +): boolean => { + if (a.nextToken !== b.nextToken || a.groups.length !== b.groups.length) return false; + + return a.groups.every((group, i) => { + const other = b.groups[i]; + if (!other) return false; + if (group.roomId !== other.roomId) return false; + if (group.notifications.length !== other.notifications.length) return false; + + return group.notifications.every((notification, j) => { + const otherNotification = other.notifications[j]; + if (!otherNotification) return false; + return ( + notification.event.event_id === otherNotification.event.event_id && + // Changes when an encrypted snapshot is replaced by its decrypted one. + notification.event.type === otherNotification.event.type && + notification.dismissed === otherNotification.dismissed + ); + }); + }); +}; + +export const useLocalNotificationTimeline = ( + paginationLimit: number, + filterMode: 'all' | 'mentions' = 'mentions', + includeDone?: boolean +): [NotificationTimeline, LoadTimeline, SilentReloadTimeline] => { + const mx = useMatrixClient(); + const allRooms = useAtomValue(allRoomsAtom); + const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); + + const [notificationTimeline, setNotificationTimeline] = useState({ + groups: [], + }); + // Re-render on our own read receipts so the per-notification unread dots follow + // them; the timeline itself is unchanged, so this must not touch it. + const [, bumpReceiptVersion] = useState(0); + + const cache = getLocalNotificationCache(mx.getSafeUserId()); + const loadedLimitRef = useRef(paginationLimit); + + // Always recomputed from offset 0 over a growing window, so a reload cannot + // rewind pagination and re-trigger the caller's load-more effect. + const applyUpTo = useCallback( + (limit: number) => { + const allEntries = cache.getEntries().filter((entry) => allJoinedRooms.has(entry.room_id)); + const { page, nextToken } = sliceNotificationPage( + allEntries, + 0, + limit, + filterMode, + includeDone + ); + const next: NotificationTimeline = { + nextToken, + groups: groupNotifications(page, allJoinedRooms), + }; + setNotificationTimeline((current) => + sameNotificationTimeline(current, next) ? current : next + ); + }, + [cache, filterMode, includeDone, allJoinedRooms] + ); + + const loadTimeline: LoadTimeline = useCallback( + async (from) => { + const limit = from ? Number(from) + paginationLimit : paginationLimit; + loadedLimitRef.current = limit; + applyUpTo(limit); + }, + [applyUpTo, paginationLimit] + ); + + const silentReloadTimeline: SilentReloadTimeline = useCallback(async () => { + applyUpTo(loadedLimitRef.current); + }, [applyUpTo]); + + useEffect(() => { + let trailing: ReturnType | undefined; + let lastRun = 0; + + const run = () => { + lastRun = Date.now(); + applyUpTo(loadedLimitRef.current); + bumpReceiptVersion((v) => v + 1); + }; + + const reload = () => { + const elapsed = Date.now() - lastRun; + if (elapsed >= RELOAD_THROTTLE_MS) { + run(); + return; + } + if (trailing !== undefined) return; + trailing = setTimeout(() => { + trailing = undefined; + run(); + }, RELOAD_THROTTLE_MS - elapsed); + }; + + const unsubscribe = cache.subscribe(reload); + mx.on(RoomEvent.Receipt, reload); + + return () => { + unsubscribe(); + mx.off(RoomEvent.Receipt, reload); + clearTimeout(trailing); + }; + }, [mx, cache, applyUpTo]); + + return [notificationTimeline, loadTimeline, silentReloadTimeline]; +}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 969accd5ab..2f92e2344d 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -9,6 +9,7 @@ import { NotificationTransportRuntimeFeature } from '$features/settings/notifica import { InviteNotifications, MessageNotifications, + NotificationRecorder, HandleNotificationClick, SyncNotificationSettingsWithServiceWorker, HandleDecryptPushEvent, @@ -51,6 +52,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/app/pages/client/client-non-ui/notifications.tsx b/src/app/pages/client/client-non-ui/notifications.tsx index e3b0b0bb3e..b18bdab551 100644 --- a/src/app/pages/client/client-non-ui/notifications.tsx +++ b/src/app/pages/client/client-non-ui/notifications.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import type { RoomEventHandlerMap } from '$types/matrix-sdk'; import { + ClientEvent, MatrixEvent, MatrixEventEvent, MsgType, @@ -56,8 +57,15 @@ import { resolveNotificationPreviewText, } from '$utils/notificationStyle'; import { isMobileOrTablet } from '$utils/platform'; +import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { showToast } from '$state/toast'; +import { arePushRulesReady, evaluateNotification } from '$utils/localNotifications'; +import { getLocalNotificationCache } from '$client/localNotificationCache'; +import { + backfillLocalNotifications, + scheduleLiveTimelineScan, +} from '$utils/localNotificationBackfill'; import { nativeNotificationRepliesAtom, nativeNotificationReplyInFlightAtom, @@ -701,6 +709,212 @@ function registerNativeNotificationListener( }; } +const recorderLogger = createLogger('NotificationRecorder'); +const RECORDED_CAP = 300; +const HEARTBEAT_INTERVAL_MS = 60_000; +const DECRYPT_TIMEOUT_MS = 30_000; + +export function NotificationRecorder() { + const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + const mDirectsRef = useRef(mDirects); + mDirectsRef.current = mDirects; + + const recordedRef = useRef>(new Set()); + const decryptingRef = useRef>(new Set()); + const hasBackfilledRef = useRef(false); + const decryptTimeoutsRef = useRef>>(new Set()); + const backfillControllerRef = useRef(undefined); + const missedBeforePushRulesRef = useRef(false); + const decryptListenersRef = useRef void>>(new Map()); + const hasScannedRef = useRef(false); + const [storeContent] = useSetting(settingsAtom, 'showMessageContentInNotifications'); + const [storeEncryptedContent] = useSetting( + settingsAtom, + 'showMessageContentInEncryptedNotifications' + ); + const storeContentRef = useRef(storeContent); + storeContentRef.current = storeContent; + const storeEncryptedContentRef = useRef(storeContent && storeEncryptedContent); + storeEncryptedContentRef.current = storeContent && storeEncryptedContent; + const prevMxRef = useRef(mx); + if (prevMxRef.current !== mx) { + prevMxRef.current = mx; + recordedRef.current = new Set(); + decryptingRef.current = new Set(); + hasBackfilledRef.current = false; + missedBeforePushRulesRef.current = false; + hasScannedRef.current = false; + } + + useEffect(() => { + const userId = mx.getSafeUserId(); + const cache = getLocalNotificationCache(userId); + + const markRecorded = (eventId: string) => { + recordedRef.current.add(eventId); + if (recordedRef.current.size > RECORDED_CAP) { + const oldest = recordedRef.current.values().next().value; + if (oldest !== undefined) recordedRef.current.delete(oldest); + } + }; + + const handler: RoomEventHandlerMap[RoomEvent.Timeline] = ( + mEvent, + room, + toStartOfTimeline, + removed + ) => { + if (toStartOfTimeline || removed) return; + if (!room) return; + const eventId = mEvent.getId(); + if (!eventId) return; + + if (recordedRef.current.has(eventId)) return; + + // Leave unrecorded so the rescan picks it up once push rules arrive. + if (!arePushRulesReady(mx)) { + missedBeforePushRulesRef.current = true; + return; + } + + if (mEvent.getType() === 'm.room.encrypted' && mEvent.isEncrypted()) { + if (decryptingRef.current.has(eventId)) return; + decryptingRef.current.add(eventId); + markRecorded(eventId); + + const stored = evaluateNotification( + mx, + room, + mEvent, + mDirectsRef.current, + getNotificationType(mx, room.roomId), + { storeContent: storeEncryptedContentRef.current } + ); + if (stored) { + cache.merge(stored); + } + + // Not `once`: Decrypted also fires for a decryption FAILURE, whose clear + // event is an m.bad.encrypted placeholder. Staying subscribed lets the + // SDK's later retry replace it. + const handleDecrypted = () => { + if (mEvent.isDecryptionFailure()) return; + decryptingRef.current.delete(eventId); + const upgraded = evaluateNotification( + mx, + room, + mEvent, + mDirectsRef.current, + getNotificationType(mx, room.roomId), + { storeContent: storeEncryptedContentRef.current } + ); + if (upgraded) cache.merge(upgraded); + mEvent.off(MatrixEventEvent.Decrypted, handleDecrypted); + decryptListenersRef.current.delete(mEvent); + }; + mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted); + decryptListenersRef.current.set(mEvent, handleDecrypted); + + // Stop waiting, but keep the placeholder: a megolm key can still arrive + // later, and deleting the entry loses the notification for good. + const timeoutId = setTimeout(() => { + decryptTimeoutsRef.current.delete(timeoutId); + decryptingRef.current.delete(eventId); + }, DECRYPT_TIMEOUT_MS); + decryptTimeoutsRef.current.add(timeoutId); + return; + } + + const stored = evaluateNotification( + mx, + room, + mEvent, + mDirectsRef.current, + getNotificationType(mx, room.roomId), + { storeContent: storeContentRef.current } + ); + markRecorded(eventId); + if (stored) cache.merge(stored); + }; + + mx.on(RoomEvent.Timeline, handler); + + // Only advance the watermark while syncing, so an outage isn't treated as "nothing missed". + const beat = () => { + if (mx.getSyncState() === SyncState.Syncing) cache.updateLastSeenTs(Date.now()); + }; + const heartbeatInterval = setInterval(beat, HEARTBEAT_INTERVAL_MS); + + // SlidingSyncSdk assigns client.pushRules without emitting AccountData, so + // this cannot wait on that event. Runs every start because shouldBackfill + // declines whenever the heartbeat kept the gap under its threshold. + const scanOnce = () => { + if (hasScannedRef.current || !arePushRulesReady(mx)) return; + hasScannedRef.current = true; + missedBeforePushRulesRef.current = false; + void scheduleLiveTimelineScan(mx, userId, mDirectsRef.current, { + storeContent: storeContentRef.current, + storeEncryptedContent: storeEncryptedContentRef.current, + }).catch((err: unknown) => { + recorderLogger.warn('live timeline scan failed', err); + }); + }; + + const onSync = (state: SyncState) => { + if ( + state !== SyncState.Prepared && + state !== SyncState.Syncing && + state !== SyncState.Catchup + ) { + return; + } + scanOnce(); + + if (hasBackfilledRef.current) return; + hasBackfilledRef.current = true; + const controller = new AbortController(); + backfillControllerRef.current = controller; + void backfillLocalNotifications(mx, userId, Date.now(), controller.signal).catch( + (err: unknown) => { + recorderLogger.warn('backfill failed', err); + } + ); + }; + mx.on(ClientEvent.Sync, onSync); + const currentState = mx.getSyncState(); + if (currentState) onSync(currentState); + + // Covers a later push-rule change. + const onPushRules = (event: MatrixEvent) => { + if (event.getType() !== (EventType.PushRules as string)) return; + scanOnce(); + }; + mx.on(ClientEvent.AccountData, onPushRules); + + const decryptTimeouts = decryptTimeoutsRef.current; + const decryptListeners = decryptListenersRef.current; + return () => { + mx.off(RoomEvent.Timeline, handler); + mx.off(ClientEvent.Sync, onSync); + mx.off(ClientEvent.AccountData, onPushRules); + clearInterval(heartbeatInterval); + for (const id of decryptTimeouts) clearTimeout(id); + decryptTimeouts.clear(); + // These hold mx, room and the previous account's cache through their closure. + for (const [event, listener] of decryptListeners) { + event.off(MatrixEventEvent.Decrypted, listener); + } + decryptListeners.clear(); + backfillControllerRef.current?.abort(); + backfillControllerRef.current = undefined; + beat(); + }; + }, [mx]); + + return null; +} + // Routes taps on native plugin notifications (desktop + iOS) using the `extra` // payload attached in sendNativeTauriNotification. export function NativeNotificationClickRouting() { 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..093cf4676a 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -1,5 +1,5 @@ import type { MouseEventHandler } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Avatar, Box, Chip, Header, IconButton, Scroll, Text, config, toRem } from 'folds'; import { ArrowLeft, @@ -11,10 +11,9 @@ import { 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 type { Room } from '$types/matrix-sdk'; +import { JoinRule, MatrixEvent } from '$types/matrix-sdk'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { useAtomValue } from 'jotai'; import { Page, PageContent, PageContentCenter, PageHeader } from '$components/page'; import { useMatrixClient } from '$hooks/useMatrixClient'; import type { InboxNotificationsPathSearchParams } from '$pages/paths'; @@ -23,7 +22,9 @@ 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 { useLocalNotificationTimeline } from '$hooks/useLocalNotificationTimeline'; +import { isStoredNotificationRead, type StoredNotification } from '$utils/localNotifications'; +import { getLocalNotificationCache } from '$client/localNotificationCache'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; @@ -37,124 +38,22 @@ import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUr 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 RoomNotificationsGroupProps = { room: Room; appBaseUrl: string; - notifications: INotification[]; + notifications: StoredNotification[]; hideReads: boolean; onOpen: (roomId: string, eventId: string) => void; hour24Clock: boolean; dateFormatString: string; + expanded: boolean; + onToggleExpanded: (roomId: string, expanded: boolean) => void; }; type NotificationItemProps = { room: Room; - notification: INotification; + notification: StoredNotification; renderContent: ReturnType; onOpen: (roomId: string, eventId: string) => void; hour24Clock: boolean; @@ -169,11 +68,23 @@ 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 handleOpen: MouseEventHandler = (evt) => { evt.stopPropagation(); onOpen(room.roomId, notification.event.event_id); }; + const handleDismiss = () => { + getLocalNotificationCache(mx.getSafeUserId()).dismiss(notification.event.event_id); + }; + const isRead = isStoredNotificationRead(room, mx.getSafeUserId(), notification); return ( - Open - + <> + {!isRead && ( + + )} + + Open + + + Done + + } onOpen={handleOpen} hour24Clock={hour24Clock} @@ -206,6 +134,8 @@ function RoomNotificationsGroupComp({ onOpen, hour24Clock, dateFormatString, + expanded, + onToggleExpanded, }: Readonly) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -214,6 +144,12 @@ function RoomNotificationsGroupComp({ const handleMarkAsRead = () => { markAsRead(mx, room.roomId, hideReads); }; + const handleDismissAll = () => { + getLocalNotificationCache(mx.getSafeUserId()).dismissAllInRoom(room.roomId); + }; + const MAX_VISIBLE = 5; + const visible = expanded ? notifications : notifications.slice(0, MAX_VISIBLE); + const hiddenCount = notifications.length - visible.length; return ( @@ -238,7 +174,17 @@ function RoomNotificationsGroupComp({ {room.name} - + + {notifications.length > 0 && ( + + Dismiss all + + )} {unread && ( - {notifications.map((notification) => ( + {visible.map((notification) => ( ))} + {hiddenCount > 0 && ( + onToggleExpanded(room.roomId, true)}> + {hiddenCount} more + + )} ); @@ -278,8 +229,6 @@ const useNotificationsSearchParams = ( [searchParams] ); -const FAST_REFRESH_MS = 2500; - export function Notifications() { const mx = useMatrixClient(); const [hideReads] = useSetting(settingsAtom, 'hideReads'); @@ -294,22 +243,24 @@ export function Notifications() { const scrollRef = useRef(null); const scrollTopAnchorRef = useRef(null); - const onlyHighlight = notificationsSearchParams.only === 'highlight'; - const setOnlyHighlighted = (highlight: boolean) => { - if (highlight) { - setSearchParams( - new URLSearchParams({ - only: 'highlight', - }) - ); - return; + const filterMode = notificationsSearchParams.only === 'all' ? 'all' : 'mentions'; + const setFilterMode = (mode: 'mentions' | 'all') => { + if (mode === 'all') { + setSearchParams(new URLSearchParams({ only: 'all' })); + } else { + setSearchParams(); } - setSearchParams(); + }; + const [includeDone, setIncludeDone] = useState(false); + const [expandedRooms, setExpandedRooms] = useState>({}); + const handleToggleExpanded = (roomId: string, expanded: boolean) => { + setExpandedRooms((prev) => ({ ...prev, [roomId]: expanded })); }; - const [notificationTimeline, loadTimelineRaw, silentReloadTimeline] = useNotificationTimeline( + const [notificationTimeline, loadTimelineRaw] = useLocalNotificationTimeline( 24, - onlyHighlight + filterMode, + includeDone ); const [timelineState, loadTimeline] = useAsyncCallback(loadTimelineRaw); @@ -321,13 +272,6 @@ export function Notifications() { }); const vItems = virtualizer.getVirtualItems(); - useInterval( - useCallback(() => { - silentReloadTimeline(); - }, [silentReloadTimeline]), - FAST_REFRESH_MS - ); - useEffect(() => { loadTimeline(); }, [loadTimeline]); @@ -375,22 +319,31 @@ export function Notifications() { Filter setOnlyHighlighted(false)} - variant={onlyHighlight ? 'Surface' : 'Success'} - aria-pressed={!onlyHighlight} - before={!onlyHighlight && sizedIcon(Check, '100')} + onClick={() => setFilterMode('mentions')} + variant={filterMode === 'mentions' ? 'Success' : 'Surface'} + aria-pressed={filterMode === 'mentions'} + before={filterMode === 'mentions' && sizedIcon(Check, '100')} + outlined + > + Mentions & DMs + + setFilterMode('all')} + variant={filterMode === 'all' ? 'Success' : 'Surface'} + aria-pressed={filterMode === 'all'} + before={filterMode === 'all' && sizedIcon(Check, '100')} outlined > - All Notifications + All setOnlyHighlighted(true)} - variant={onlyHighlight ? 'Success' : 'Surface'} - aria-pressed={onlyHighlight} - before={onlyHighlight && sizedIcon(Check, '100')} + onClick={() => setIncludeDone((v) => !v)} + variant={includeDone ? 'Success' : 'Surface'} + aria-pressed={includeDone} + before={includeDone && sizedIcon(Check, '100')} outlined > - Highlighted + Include done @@ -433,6 +386,8 @@ export function Notifications() { onOpen={navigateRoom} hour24Clock={hour24Clock} dateFormatString={dateFormatString} + expanded={expandedRooms[group.roomId] ?? false} + onToggleExpanded={handleToggleExpanded} /> ); 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..ceb795e268 100644 --- a/src/app/state/utils/atomWithLocalStorage.ts +++ b/src/app/state/utils/atomWithLocalStorage.ts @@ -1,5 +1,7 @@ import { atom } from 'jotai'; +const EVICTABLE_KEY_PREFIXES = ['sable.notificationCache.', 'sable.slidingSyncSidebar.']; + export const getLocalStorageItem = (key: string, defaultValue: T): T => { const item = localStorage.getItem(key); if (item === null) return defaultValue; @@ -15,6 +17,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/groupNotifications.ts b/src/app/utils/groupNotifications.ts new file mode 100644 index 0000000000..362292e398 --- /dev/null +++ b/src/app/utils/groupNotifications.ts @@ -0,0 +1,34 @@ +import { EventType } from '$types/matrix-sdk'; + +type NotificationEntry = { + event: { type: string }; + room_id: string; +}; + +type RoomNotificationsGroup = { + roomId: string; + notifications: N[]; +}; + +export const groupNotifications = ( + notifications: N[], + 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; +}; diff --git a/src/app/utils/localNotificationBackfill.test.ts b/src/app/utils/localNotificationBackfill.test.ts new file mode 100644 index 0000000000..8203aa9e36 --- /dev/null +++ b/src/app/utils/localNotificationBackfill.test.ts @@ -0,0 +1,669 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; +import { backfillLocalNotifications, scheduleLiveTimelineScan } from './localNotificationBackfill'; +import { + getLocalNotificationCache, + clearLocalNotificationCache, +} from '$client/localNotificationCache'; +import { MAX_BACKFILL_ROOMS } from './localNotifications'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ROOM_ID = '!active:example.com'; +const USER_ID = '@test:example.com'; + +type RoomOverrides = Omit, 'isSpaceRoom'> & { + lastActiveTs?: number; + isSpaceRoom?: boolean; + _events?: Partial[]; +}; + +const createRoom = (roomId: string, overrides: RoomOverrides = {}): Room => { + const { lastActiveTs, isSpaceRoom, _events, ...rest } = overrides; + return { + roomId, + getLastActiveTimestamp: () => lastActiveTs ?? Date.now(), + isSpaceRoom: isSpaceRoom ? () => true : () => false, + getJoinedMemberCount: () => 3, // not 2, so isDMRoom's heuristic doesn't fire + getLiveTimeline: () => ({ + getEvents: () => (_events ?? []) as MatrixEvent[], + // evaluateNotification reads m.room.encryption to decide whether the + // encrypted-content setting applies. + getState: () => ({ getStateEvents: () => undefined }), + }), + getAccountData: () => undefined, + ...rest, + } as unknown as Room; +}; + +const createEvent = (ts: number, id?: string): Partial => + ({ + getId: () => id ?? `$ev_${ts}`, + getTs: () => ts, + getSender: () => '@other:example.com', + getType: () => 'm.room.message', + getContent: () => ({ body: 'hello', msgtype: 'm.text' }), + isRedacted: () => false, + isSending: () => false, + getRelation: () => undefined, + }) as unknown as Partial; + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + clearLocalNotificationCache(USER_ID); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('backfillLocalNotifications', () => { + it('no watermark (new device) → no backfill', async () => { + const scrollback = vi + .fn() + .mockResolvedValue(undefined as unknown as Room); + + const mx = { + getRooms: () => [], + getRoom: () => undefined, + scrollback, + getAccountData: (type: unknown) => + type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: {} }), + }, + } as unknown as MatrixClient; + + const recorded = await backfillLocalNotifications(mx, USER_ID); + + expect(recorded).toBe(0); + expect(scrollback).not.toHaveBeenCalled(); + }); + + it('recent watermark (small gap) → no backfill', async () => { + const now = Date.now(); + const cache = getLocalNotificationCache(USER_ID); + cache.updateLastSeenTs(now - 1 * 60 * 1000); // 1 minute ago, below GAP_THRESHOLD_MS (5 min) + + const scrollback = vi + .fn() + .mockResolvedValue(undefined as unknown as Room); + + const mx = { + getRooms: () => [], + getRoom: () => undefined, + scrollback, + getAccountData: (type: unknown) => + type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: {} }), + }, + } as unknown as MatrixClient; + + const recorded = await backfillLocalNotifications(mx, USER_ID, now); + + expect(recorded).toBe(0); + expect(scrollback).not.toHaveBeenCalled(); + }); + + it('stale watermark (large gap) → backfills active rooms', async () => { + const now = Date.now(); + const twoHoursAgo = now - 2 * 60 * 60 * 1000; + + const cache = getLocalNotificationCache(USER_ID); + cache.updateLastSeenTs(twoHoursAgo); + + const activeEvents = [createEvent(now - 30 * 1000, '$active1')]; + const spaceEvents = [createEvent(now - 30 * 1000, '$space1')]; + + const activeRoom = createRoom('!active:example.com', { + lastActiveTs: now - 60 * 1000, + _events: activeEvents, + }); + const spaceRoom = createRoom('!space:example.com', { + isSpaceRoom: true, + lastActiveTs: now - 60 * 1000, + _events: spaceEvents, + }); + const mutedRoom = createRoom('!muted:example.com', { + lastActiveTs: now - 60 * 1000, + _events: [], + }); + + const scrollback = vi + .fn() + .mockImplementation(async (room: Room) => room as unknown as Room); + + const pushRulesForMuted = { + global: { + override: [{ rule_id: '!muted:example.com', enabled: true, actions: ['dont_notify'] }], + }, + }; + const getAccountData = vi + .fn<(type: unknown) => unknown>() + .mockImplementation((eventType: unknown) => { + if (eventType === 'm.direct') return { getContent: () => ({}) } as unknown; + if (eventType === 'm.push_rules') { + return { getContent: () => pushRulesForMuted } as unknown; + } + return undefined; + }); + + const mx = { + getRooms: () => [activeRoom, spaceRoom, mutedRoom], + getRoom: (roomId: string) => { + if (roomId === '!active:example.com') return activeRoom; + if (roomId === '!space:example.com') return spaceRoom; + if (roomId === '!muted:example.com') return mutedRoom; + return undefined; + }, + scrollback, + getAccountData, + getRoomPushRule: (_scope: string, roomId: string) => { + if (roomId === '!muted:example.com') throw new Error('no rule'); + throw new Error('no rule'); + }, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: {} }), + }, + } as unknown as MatrixClient; + + const recorded = await backfillLocalNotifications(mx, USER_ID, now); + + expect(recorded).toBeGreaterThanOrEqual(0); + // Only the active (non-space, non-muted) room should be scrolled + const scrollbackRoomIds = scrollback.mock.calls.map((call) => (call[0] as Room).roomId); + expect(scrollbackRoomIds).toContain('!active:example.com'); + expect(scrollbackRoomIds).not.toContain('!space:example.com'); + expect(scrollbackRoomIds).not.toContain('!muted:example.com'); + }); + + it('respects MAX_BACKFILL_ROOMS = 30', async () => { + const now = Date.now(); + const twoHoursAgo = now - 2 * 60 * 60 * 1000; + + const cache = getLocalNotificationCache(USER_ID); + cache.updateLastSeenTs(twoHoursAgo); + + const scrollback = vi + .fn() + .mockResolvedValue(undefined as unknown as Room); + + const rooms: Room[] = Array.from({ length: 40 }, (_, i) => + createRoom(`!room${i}:example.com`, { + lastActiveTs: now - 60 * 1000 + i * 1000, // each slightly more recent + _events: [], + }) + ); + + const mx = { + getRooms: () => rooms, + getRoom: (roomId: string) => rooms.find((r) => r.roomId === roomId), + scrollback, + getAccountData: (type: unknown) => + type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, + getRoomPushRule: () => { + throw new Error('no rule'); + }, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: {} }), + }, + } as unknown as MatrixClient; + + await backfillLocalNotifications(mx, USER_ID, now); + + // (30 rooms × up to 2 pages = up to 60 calls, but unique rooms ≤ 30.) + const scrollbackRoomIds = scrollback.mock.calls.map((call) => (call[0] as Room).roomId); + const uniqueRooms = new Set(scrollbackRoomIds); + expect(uniqueRooms.size).toBeLessThanOrEqual(MAX_BACKFILL_ROOMS); + }); + + it('early stop: events older than watermark → only 1 page', async () => { + const now = Date.now(); + const twoHoursAgo = now - 2 * 60 * 60 * 1000; + + const cache = getLocalNotificationCache(USER_ID); + cache.updateLastSeenTs(twoHoursAgo); + + const events = [ + createEvent(now - 10 * 1000, '$recent'), + createEvent(now - 60 * 1000, '$mid'), + createEvent(twoHoursAgo - 10 * 1000, '$old'), // older than watermark + ]; + + const room = createRoom(ROOM_ID, { + lastActiveTs: now - 60 * 1000, + _events: events, + }); + + const scrollback = vi + .fn() + .mockImplementation(async (r: Room) => r as unknown as Room); + + const mx = { + getRooms: () => [room], + getRoom: () => room, + scrollback, + getAccountData: (type: unknown) => + type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, + getRoomPushRule: () => { + throw new Error('no rule'); + }, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: {} }), + }, + } as unknown as MatrixClient; + + await backfillLocalNotifications(mx, USER_ID, now); + + expect(scrollback).toHaveBeenCalledTimes(1); + }); + + it('adaptive pages: small gap → 1 page, large gap → up to 2 pages', async () => { + const now = Date.now(); + const smallGapMs = 10 * 60 * 1000; // 10 minutes (< 30 min) + const largeGapMs = 2 * 60 * 60 * 1000; // 2 hours (> 30 min) + + { + const uid = `${USER_ID}_small`; + clearLocalNotificationCache(uid); + const cache = getLocalNotificationCache(uid); + cache.updateLastSeenTs(now - smallGapMs); + + const events = [createEvent(now - 5 * 1000, '$recent')]; + const room = createRoom('!small:example.com', { + lastActiveTs: now - 5 * 1000, + _events: events, + }); + + const scrollback = vi + .fn() + .mockImplementation(async (r: Room) => r as unknown as Room); + + const mx = { + getRooms: () => [room], + getRoom: () => room, + scrollback, + getAccountData: (type: unknown) => + type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, + getRoomPushRule: () => { + throw new Error('no rule'); + }, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: {} }), + }, + } as unknown as MatrixClient; + + await backfillLocalNotifications(mx, uid, now); + expect(scrollback).toHaveBeenCalledTimes(1); + clearLocalNotificationCache(uid); + } + + { + const uid = `${USER_ID}_large`; + clearLocalNotificationCache(uid); + const cache = getLocalNotificationCache(uid); + cache.updateLastSeenTs(now - largeGapMs); + + const events = [createEvent(now - 5 * 1000, '$recent')]; + const room = createRoom('!large:example.com', { + lastActiveTs: now - 5 * 1000, + _events: events, + }); + + const scrollback = vi + .fn() + .mockImplementation(async (r: Room) => r as unknown as Room); + + const mx = { + getRooms: () => [room], + getRoom: () => room, + scrollback, + getAccountData: (type: unknown) => + type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, + getRoomPushRule: () => { + throw new Error('no rule'); + }, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: {} }), + }, + } as unknown as MatrixClient; + + await backfillLocalNotifications(mx, uid, now); + expect(scrollback).toHaveBeenCalledTimes(2); + clearLocalNotificationCache(uid); + } + }); + + it('muted room skipped', async () => { + const now = Date.now(); + const twoHoursAgo = now - 2 * 60 * 60 * 1000; + + const cache = getLocalNotificationCache(USER_ID); + cache.updateLastSeenTs(twoHoursAgo); + + const mutedRoom = createRoom('!muted:example.com', { + lastActiveTs: now - 60 * 1000, + _events: [], + }); + + const scrollback = vi + .fn() + .mockResolvedValue(undefined as unknown as Room); + + // getAccountData: return undefined for m.direct, return a mute override for m.push_rules + let callCount = 0; + const getAccountData = vi.fn<(type: unknown) => unknown>().mockImplementation(() => { + callCount += 1; + if (callCount === 1) return undefined; // EventType.Direct + return { + getContent: () => ({ + global: { + override: [{ rule_id: '!muted:example.com', actions: ['dont_notify'] }], + }, + }), + }; + }); + + const mx = { + getRooms: () => [mutedRoom], + getRoom: () => mutedRoom, + scrollback, + getAccountData, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: {} }), + }, + } as unknown as MatrixClient; + + await backfillLocalNotifications(mx, USER_ID, now); + + expect(scrollback).not.toHaveBeenCalled(); + }); + + it('sequential, not parallel: only one scrollback in-flight at a time', async () => { + const now = Date.now(); + const twoHoursAgo = now - 2 * 60 * 60 * 1000; + + const cache = getLocalNotificationCache(USER_ID); + cache.updateLastSeenTs(twoHoursAgo); + + let inFlight = 0; + let maxInFlight = 0; + + const rooms: Room[] = ['!r1:example.com', '!r2:example.com', '!r3:example.com'].map((id) => + createRoom(id, { + lastActiveTs: now - 60 * 1000, + _events: [createEvent(now - 5 * 1000)], + }) + ); + + const scrollback = vi.fn().mockImplementation(async (r: Room) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + inFlight -= 1; + return r as unknown as Room; + }); + + const mx = { + getRooms: () => rooms, + getRoom: (roomId: string) => rooms.find((r) => r.roomId === roomId), + scrollback, + getAccountData: (type: unknown) => + type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, + getRoomPushRule: () => { + throw new Error('no rule'); + }, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: {} }), + }, + } as unknown as MatrixClient; + + await backfillLocalNotifications(mx, USER_ID, now); + + expect(maxInFlight).toBe(1); + }); + + it('returns count of recorded notifications', async () => { + const now = Date.now(); + const twoHoursAgo = now - 2 * 60 * 60 * 1000; + + const cache = getLocalNotificationCache(USER_ID); + cache.updateLastSeenTs(twoHoursAgo); + + const events = [ + createEvent(now - 10 * 1000, '$a'), + createEvent(now - 20 * 1000, '$b'), + createEvent(now - 30 * 1000, '$c'), + ]; + + const room = createRoom(ROOM_ID, { + lastActiveTs: now - 5 * 1000, + _events: events, + }); + + const scrollback = vi + .fn() + .mockImplementation(async (r: Room) => r as unknown as Room); + + const mx = { + getRooms: () => [room], + getRoom: () => room, + scrollback, + getAccountData: (type: unknown) => + type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, + getRoomPushRule: () => { + throw new Error('no rule'); + }, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: {} }), + }, + } as unknown as MatrixClient; + + const recorded = await backfillLocalNotifications(mx, USER_ID, now); + + expect(recorded).toBe(3); + }); + + it('records events newer than the watermark when the page also contains older ones', async () => { + // must not stop the page being recorded. + const now = Date.now(); + const twoHoursAgo = now - 2 * 60 * 60 * 1000; + + const cache = getLocalNotificationCache(USER_ID); + cache.updateLastSeenTs(twoHoursAgo); + + const events = [ + createEvent(twoHoursAgo - 10 * 1000, '$old'), // older than watermark + createEvent(now - 30 * 1000, '$recent'), // newer than watermark + ]; + + const room = createRoom(ROOM_ID, { + lastActiveTs: now - 5 * 1000, + _events: events, + }); + + const scrollback = vi + .fn() + .mockImplementation(async (r: Room) => r as unknown as Room); + + const mx = { + getRooms: () => [room], + getRoom: () => room, + scrollback, + getAccountData: (type: unknown) => + type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, + getRoomPushRule: () => { + throw new Error('no rule'); + }, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: {} }), + }, + } as unknown as MatrixClient; + + const recorded = await backfillLocalNotifications(mx, USER_ID, now); + + expect(recorded).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// recordLiveTimelines — recovers what was dropped before push rules synced +// --------------------------------------------------------------------------- + +describe('scheduleLiveTimelineScan', () => { + const CONTENT = { storeContent: true, storeEncryptedContent: true }; + + const clientWith = (rooms: Room[], withPushRules = true): MatrixClient => + ({ + getRooms: () => rooms, + getRoom: (roomId: string) => rooms.find((r) => r.roomId === roomId), + getSafeUserId: () => USER_ID, + getUserId: () => USER_ID, + pushRules: withPushRules + ? { global: { override: [], content: [], room: [], sender: [], underride: [] } } + : undefined, + getAccountData: () => undefined, + getRoomPushRule: () => undefined, + pushProcessor: { + actionsForEvent: vi + .fn<() => { notify: boolean; tweaks: Record }>() + .mockReturnValue({ notify: true, tweaks: { highlight: true } }), + }, + }) as unknown as MatrixClient; + + it('records events already sitting in the live timeline', async () => { + const room = createRoom(ROOM_ID, { + _events: [createEvent(1000, '$a'), createEvent(2000, '$b')], + }); + + const recorded = await scheduleLiveTimelineScan( + clientWith([room]), + USER_ID, + new Set(), + CONTENT + ); + + expect(recorded).toBe(2); + expect(getLocalNotificationCache(USER_ID).getEntries()).toHaveLength(2); + }); + + it('is idempotent, so a rescan cannot duplicate entries', async () => { + const room = createRoom(ROOM_ID, { _events: [createEvent(1000, '$a')] }); + const mx = clientWith([room]); + + await scheduleLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); + await scheduleLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); + + expect(getLocalNotificationCache(USER_ID).getEntries()).toHaveLength(1); + }); + + it('preserves a dismissal across a rescan', async () => { + const room = createRoom(ROOM_ID, { _events: [createEvent(1000, '$a')] }); + const mx = clientWith([room]); + const cache = getLocalNotificationCache(USER_ID); + + await scheduleLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); + cache.dismiss('$a'); + await scheduleLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); + + expect(cache.getEntries()[0]!.dismissed).toBe(true); + }); + + it('records nothing while push rules are still missing', async () => { + const room = createRoom(ROOM_ID, { _events: [createEvent(1000, '$a')] }); + + const recorded = await scheduleLiveTimelineScan( + clientWith([room], false), + USER_ID, + new Set(), + CONTENT + ); + + expect(recorded).toBe(0); + }); + + it('skips space rooms', async () => { + const space = createRoom('!space:example.com', { + isSpaceRoom: true, + _events: [createEvent(1000, '$a')], + }); + + expect(await scheduleLiveTimelineScan(clientWith([space]), USER_ID, new Set(), CONTENT)).toBe( + 0 + ); + }); + + it('omits the body when content must not be persisted', async () => { + const room = createRoom(ROOM_ID, { _events: [createEvent(1000, '$a')] }); + + await scheduleLiveTimelineScan(clientWith([room]), USER_ID, new Set(), { + storeContent: false, + storeEncryptedContent: false, + }); + + const entry = getLocalNotificationCache(USER_ID).getEntries()[0]!; + expect(entry.event.content.body).toBeUndefined(); + expect(entry.event.content.msgtype).toBe('m.text'); + }); +}); diff --git a/src/app/utils/localNotificationBackfill.ts b/src/app/utils/localNotificationBackfill.ts new file mode 100644 index 0000000000..c7bb2e821f --- /dev/null +++ b/src/app/utils/localNotificationBackfill.ts @@ -0,0 +1,199 @@ +import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; +import { ClientEvent, Direction, EventType, MatrixEventEvent } from '$types/matrix-sdk'; +import { NotificationType } from '$types/matrix/room'; +import { getLocalNotificationCache } from '$client/localNotificationCache'; +import { createLogger } from '$utils/debug'; +import { getAccountData } from '$utils/room/hierarchy'; +import { getMDirects, getNotificationType } from '$utils/room/unread'; +import { + evaluateNotification, + selectBackfillRooms, + shouldBackfill, + backfillPageCount, + type BackfillRoomInfo, + type StoredNotification, +} from './localNotifications'; + +const logger = createLogger('localNotificationBackfill'); +const SCROLLBACK_LIMIT = 50; + +const DECRYPT_TIMEOUT_MS = 30_000; +// Bounds the transient batch; the cache only keeps MAX_ENTRIES anyway. +const SCAN_FLUSH_BATCH = 200; + +export type ScanContentOptions = { + storeContent: boolean; + storeEncryptedContent: boolean; +}; + +const isEncryptedRoom = (room: Room): boolean => + room + .getLiveTimeline() + .getState(Direction.Forward) + ?.getStateEvents(EventType.RoomEncryption, '') !== null; + +// Recovers events that arrived before push rules synced. Reads only what the +// client already holds — no network — and yields between rooms so a large +// account cannot block the main thread through a whole scan. +export const scheduleLiveTimelineScan = async ( + mx: MatrixClient, + userId: string, + mDirects: Set, + content: ScanContentOptions +): Promise => { + const cache = getLocalNotificationCache(userId); + const pending: StoredNotification[] = []; + let recorded = 0; + + const flush = () => { + if (pending.length === 0) return; + cache.mergeMany(pending); + recorded += pending.length; + pending.length = 0; + }; + + for (const room of mx.getRooms()) { + if (room.isSpaceRoom()) continue; + const notificationType = getNotificationType(mx, room.roomId); + if (notificationType === NotificationType.Mute) continue; + + const storeContent = isEncryptedRoom(room) + ? content.storeEncryptedContent + : content.storeContent; + for (const mEvent of room.getLiveTimeline().getEvents()) { + const stored = evaluateNotification(mx, room, mEvent, mDirects, notificationType, { + storeContent, + }); + if (!stored) continue; + pending.push(stored); + if (pending.length >= SCAN_FLUSH_BATCH) flush(); + } + + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 0)); + } + flush(); + + logger.log('live timeline scan complete', { recorded }); + return recorded; +}; + +export const backfillLocalNotifications = async ( + mx: MatrixClient, + userId: string, + now: number = Date.now(), + signal?: AbortSignal +): Promise => { + const cache = getLocalNotificationCache(userId); + const lastSeenTs = cache.getLastSeenTs(); + const watermark = shouldBackfill(lastSeenTs, now); + if (watermark === undefined) { + logger.log('backfill skipped', { + reason: lastSeenTs === undefined ? 'new-device' : 'small-gap', + }); + return 0; + } + + // Build BackfillRoomInfo from all rooms the client knows about. + const allRooms = mx.getRooms(); + const roomInfos: BackfillRoomInfo[] = allRooms.map((room) => ({ + roomId: room.roomId, + lastActiveTs: room.getLastActiveTimestamp(), + isSpaceRoom: room.isSpaceRoom(), + isMuted: getNotificationType(mx, room.roomId) === NotificationType.Mute, + })); + const selectedRoomIds = selectBackfillRooms(roomInfos, watermark); + const pages = backfillPageCount(watermark, now); + + logger.log('backfill starting', { + rooms: selectedRoomIds.length, + pages, + gapMs: now - watermark, + }); + + // m.direct may not have arrived at SyncState.Prepared yet. + const mDirectEvent = getAccountData(mx, EventType.Direct); + let mDirects: Set; + if (mDirectEvent) { + mDirects = getMDirects(mDirectEvent); + } else { + mDirects = await new Promise>((resolve) => { + const handler = (event: MatrixEvent) => { + if (event.getType() === (EventType.Direct as string)) { + mx.off(ClientEvent.AccountData, handler); + resolve(getMDirects(event)); + } + }; + mx.on(ClientEvent.AccountData, handler); + setTimeout(() => { + mx.off(ClientEvent.AccountData, handler); + resolve(new Set()); + }, 5000); + }); + } + + let recorded = 0; + const processed = new Set(); + for (const roomId of selectedRoomIds) { + if (signal?.aborted) return recorded; + const room = mx.getRoom(roomId); + if (!room) continue; + const notificationType = getNotificationType(mx, roomId); + if (notificationType === NotificationType.Mute) continue; + + try { + for (let page = 0; page < pages; page += 1) { + if (signal?.aborted) return recorded; + // eslint-disable-next-line no-await-in-loop + await mx.scrollback(room, SCROLLBACK_LIMIT); + const events = room.getLiveTimeline().getEvents(); + + for (const mEvent of events.toReversed()) { + if (mEvent.getTs() <= watermark) break; + if (signal?.aborted) return recorded; + const eventId = mEvent.getId(); + if (!eventId || processed.has(eventId)) continue; + processed.add(eventId); + const stored = evaluateNotification(mx, room, mEvent, mDirects, notificationType); + if (stored) { + cache.merge(stored); + recorded += 1; + if (mEvent.getType() === 'm.room.encrypted' && mEvent.isEncrypted()) { + const handleDecrypted = () => { + const upgraded = evaluateNotification(mx, room, mEvent, mDirects, notificationType); + if (upgraded) cache.merge(upgraded); + else cache.remove(eventId); + }; + mEvent.once(MatrixEventEvent.Decrypted, handleDecrypted); + const timeoutId = setTimeout(() => { + mEvent.off(MatrixEventEvent.Decrypted, handleDecrypted); + }, DECRYPT_TIMEOUT_MS); + if (signal) { + if (signal.aborted) mEvent.off(MatrixEventEvent.Decrypted, handleDecrypted); + else + signal.addEventListener( + 'abort', + () => { + clearTimeout(timeoutId); + mEvent.off(MatrixEventEvent.Decrypted, handleDecrypted); + }, + { once: true } + ); + } + } + } + } + + const hasOlder = events.some((e) => e.getTs() < watermark); + if (hasOlder) break; + } + } catch (err) { + logger.warn('backfill room failed', { roomId, err }); + } + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, 0)); + } + + logger.log('backfill complete', { recorded }); + return recorded; +}; diff --git a/src/app/utils/localNotifications.test.ts b/src/app/utils/localNotifications.test.ts new file mode 100644 index 0000000000..d4cdd37d7e --- /dev/null +++ b/src/app/utils/localNotifications.test.ts @@ -0,0 +1,582 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; +import type { PushProcessor } from '$types/matrix-sdk'; +import { EventType } from '$types/matrix-sdk'; +import { NotificationType } from '$types/matrix/room'; +import { + MAX_BODY_LENGTH, + arePushRulesReady, + evaluateNotification, + isStoredNotificationRead, + sliceNotificationPage, +} from './localNotifications'; +import type { StoredNotification } from './localNotifications'; + +const ROOM_ID = '!test:example.com'; +const USER_ID = '@user:example.com'; +const OTHER_USER = '@other:example.com'; +const EVENT_ID = '$event1'; + +// --------------------------------------------------------------------------- +// Mock helpers — follow the hand-rolled pattern from room.unread.test.ts +// --------------------------------------------------------------------------- + +const createEvent = (overrides: Partial = {}): MatrixEvent => + ({ + getId: () => EVENT_ID, + getSender: () => OTHER_USER, + getType: () => 'm.room.message', + getContent: (() => ({ body: 'Hello', msgtype: 'm.text' })) as MatrixEvent['getContent'], + isRedacted: () => false, + getRelation: () => undefined, + isSending: () => false, + getTs: () => 1000, + ...overrides, + }) as unknown as MatrixEvent; + +const createRoom = (overrides: Partial = {}, encrypted = false): Room => + ({ + roomId: ROOM_ID, + isSpaceRoom: () => false, + getJoinedMemberCount: () => 3, // not 2, so isDMRoom's heuristic doesn't fire by default + getLiveTimeline: () => ({ + getState: () => ({ + getStateEvents: (type: string) => + encrypted && type === EventType.RoomEncryption ? ({} as MatrixEvent) : undefined, + }), + }), + ...overrides, + }) as unknown as Room; + +const createClient = ( + rooms: Record = {}, + pushActionsOverride: ReturnType = { notify: true, tweaks: {} }, + getAccountDataFn?: MatrixClient['getAccountData'], + getSafeUserIdFn?: MatrixClient['getSafeUserId'] +): MatrixClient => + ({ + getUserId: () => USER_ID, + getSafeUserId: getSafeUserIdFn ?? (() => USER_ID), + // evaluateNotification refuses to classify until push rules have synced. + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getRoom: (roomId: string) => rooms[roomId], + pushProcessor: { + actionsForEvent: vi + .fn() + .mockReturnValue(pushActionsOverride), + } as unknown as PushProcessor, + getAccountData: getAccountDataFn ?? (() => undefined), + getRoomPushRule: () => { + throw new Error('no rule'); + }, + }) as unknown as MatrixClient; + +// --------------------------------------------------------------------------- +// evaluateNotification exclusions +// --------------------------------------------------------------------------- + +describe('arePushRulesReady', () => { + it('is false before push rules have synced', () => { + const mx = { pushRules: undefined } as unknown as MatrixClient; + + expect(arePushRulesReady(mx)).toBe(false); + }); + + it('is false when the ruleset has no global scope', () => { + const mx = { pushRules: {} } as unknown as MatrixClient; + + expect(arePushRulesReady(mx)).toBe(false); + }); + + it('is true once a global ruleset is present', () => { + expect(arePushRulesReady(createClient())).toBe(true); + }); +}); + +describe('evaluateNotification without push rules', () => { + // actionsForEvent yields {} before rules sync, so notify is undefined and a + // would-be mention looks identical to "do not notify". + const clientWithoutRules = (room: Room): MatrixClient => + ({ + ...(createClient({ [ROOM_ID]: room }) as unknown as Record), + pushRules: undefined, + }) as unknown as MatrixClient; + + it('declines to classify rather than deciding not to notify', () => { + const room = createRoom(); + const mx = clientWithoutRules(room); + const event = createEvent(); + + expect( + evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages) + ).toBeUndefined(); + }); + + it('declines even for an event the DM override would otherwise notify on', () => { + const room = createRoom(); + const mx = clientWithoutRules(room); + const event = createEvent(); + + const result = evaluateNotification( + mx, + room, + event, + new Set([ROOM_ID]), + NotificationType.AllMessages + ); + + expect(result).toBeUndefined(); + }); + + it('records the same event once rules are present', () => { + const room = createRoom(); + const event = createEvent(); + + expect( + evaluateNotification( + createClient({ [ROOM_ID]: room }), + room, + event, + new Set(), + NotificationType.AllMessages + ) + ).toBeDefined(); + }); +}); + +describe('evaluateNotification exclusions', () => { + it('returns undefined for muted room', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent(); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.Mute); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for space room', () => { + const room = createRoom({ isSpaceRoom: () => true }); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent(); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for self-sender event', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent({ getSender: () => USER_ID }); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for m.room.member event', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent({ getType: () => 'm.room.member' }); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for redacted event', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent({ isRedacted: () => true }); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for m.replace edit event', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent({ + getRelation: () => ({ rel_type: 'm.replace', event_id: '$orig' }), + }); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when isSending() is true', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent({ isSending: () => true }); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// evaluateNotification inclusions +// --------------------------------------------------------------------------- + +describe('evaluateNotification inclusions', () => { + it('returns StoredNotification for normal message with notify=true', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent(); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result).toBeDefined(); + expect(result!.room_id).toBe(ROOM_ID); + expect(result!.event.event_id).toBe(EVENT_ID); + expect(result!.event.type).toBe('m.room.message'); + expect(result!.event.content.body).toBe('Hello'); + expect(result!.highlight).toBe(false); + }); + + it('returns StoredNotification with highlight=true when tweaks.highlight is set', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }, { notify: true, tweaks: { highlight: true } }); + const event = createEvent(); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result).toBeDefined(); + expect(result!.highlight).toBe(true); + }); + + it('DM force-override: notify=false but isDM + not MentionsAndKeywords → returns snapshot', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }, { notify: false, tweaks: {} }); + const event = createEvent(); + const mDirects = new Set([ROOM_ID]); + + const result = evaluateNotification(mx, room, event, mDirects, NotificationType.AllMessages); + + expect(result).toBeDefined(); + expect(result!.room_id).toBe(ROOM_ID); + }); + + it('DM force-override: notify=false, isDM, but MentionsAndKeywords → returns undefined (override does NOT apply)', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }, { notify: false, tweaks: {} }); + const event = createEvent(); + const mDirects = new Set([ROOM_ID]); + + const result = evaluateNotification( + mx, + room, + event, + mDirects, + NotificationType.MentionsAndKeywords + ); + + expect(result).toBeUndefined(); + }); + + it('truncates body to MAX_BODY_LENGTH', () => { + const longBody = 'x'.repeat(1000); + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent({ + getContent: (() => ({ body: longBody, msgtype: 'm.text' })) as MatrixEvent['getContent'], + }); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result).toBeDefined(); + expect(result!.event.content.body).toBe(`${longBody.slice(0, MAX_BODY_LENGTH)}…`); + expect(result!.event.content.msgtype).toBe('m.text'); + }); + + it('drops html when the message is too long, so the preview matches what is stored', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent({ + getContent: (() => ({ + body: 'x'.repeat(1000), + formatted_body: `${'x'.repeat(1000)}`, + format: 'org.matrix.custom.html', + msgtype: 'm.text', + })) as MatrixEvent['getContent'], + }); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result!.event.content.formatted_body).toBeUndefined(); + expect(result!.event.content.format).toBeUndefined(); + expect(result!.event.content.body).toHaveLength(MAX_BODY_LENGTH + 1); + }); + + it('keeps html for short messages', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent({ + getContent: (() => ({ + body: 'hi', + formatted_body: 'hi', + format: 'org.matrix.custom.html', + msgtype: 'm.text', + })) as MatrixEvent['getContent'], + }); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result!.event.content.formatted_body).toBe('hi'); + expect(result!.event.content.body).toBe('hi'); + }); + + it('drops oversized html even when the plain body is short', () => { + const room = createRoom(); + const mx = createClient({ [ROOM_ID]: room }); + const event = createEvent({ + getContent: (() => ({ + body: 'short', + formatted_body: `${'x'.repeat(1000)}`, + format: 'org.matrix.custom.html', + msgtype: 'm.text', + })) as MatrixEvent['getContent'], + }); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result!.event.content.formatted_body).toBeUndefined(); + expect(result!.event.content.body).toBe('short'); + }); +}); + +// --------------------------------------------------------------------------- +// sliceNotificationPage +// --------------------------------------------------------------------------- + +describe('sliceNotificationPage', () => { + const makeItem = ( + ts: number, + highlight = false, + extra: Partial = {} + ): StoredNotification => + ({ + room_id: ROOM_ID, + event: { + event_id: `$e${ts}`, + type: 'm.room.message', + content: {}, + sender: OTHER_USER, + origin_server_ts: ts, + room_id: ROOM_ID, + unsigned: {}, + }, + ts, + highlight, + ...extra, + }) as StoredNotification; + + it('first page: 30 items, offset=0, limit=24 → 24 items, nextToken="24"', () => { + const all = Array.from({ length: 30 }, (_, i) => makeItem(3000 - i)); + + const { page, nextToken } = sliceNotificationPage(all, 0, 24, 'all'); + + expect(page).toHaveLength(24); + expect(nextToken).toBe('24'); + }); + + it('second page: offset=24, limit=24 → 6 items, nextToken=undefined', () => { + const all = Array.from({ length: 30 }, (_, i) => makeItem(3000 - i)); + + const { page, nextToken } = sliceNotificationPage(all, 24, 24, 'all'); + + expect(page).toHaveLength(6); + expect(nextToken).toBeUndefined(); + }); + + it('last partial page: 50 items, offset=48, limit=24 → 2 items, nextToken=undefined', () => { + const all = Array.from({ length: 50 }, (_, i) => makeItem(5000 - i)); + + const { page, nextToken } = sliceNotificationPage(all, 48, 24, 'all'); + + expect(page).toHaveLength(2); + expect(nextToken).toBeUndefined(); + }); + + it('onlyHighlight=true filters non-highlight items', () => { + const all = [ + makeItem(5, false), + makeItem(4, true), + makeItem(3, false), + makeItem(2, true), + makeItem(1, false), + ]; + + const { page } = sliceNotificationPage(all, 0, 5, 'mentions'); + + expect(page).toHaveLength(2); + expect(page.every((n) => n.highlight)).toBe(true); + }); + + it("'mentions' keeps DMs that are not highlights", () => { + // The default filter is highlights *and* DMs; a plain DM message has no + // highlight tweak and must still appear. + const all = [ + makeItem(3, false, { isDM: true }), + makeItem(2, true), + makeItem(1, false, { isDM: false }), + ]; + + const { page } = sliceNotificationPage(all, 0, 5, 'mentions'); + + expect(page.map((n) => n.ts)).toEqual([3, 2]); + }); + + it('hides dismissed entries unless includeDone is set', () => { + const all = [makeItem(2, true, { dismissed: true }), makeItem(1, true)]; + + expect(sliceNotificationPage(all, 0, 5, 'mentions').page.map((n) => n.ts)).toEqual([1]); + expect(sliceNotificationPage(all, 0, 5, 'mentions', true).page.map((n) => n.ts)).toEqual([ + 2, 1, + ]); + }); + + it('returns items newest-first (sorted by ts descending)', () => { + const all = [makeItem(10), makeItem(5), makeItem(20), makeItem(15)]; + + const { page } = sliceNotificationPage(all, 0, 4, 'all'); + + expect(page.map((n) => n.ts)).toEqual([20, 15, 10, 5]); + }); +}); + +// --------------------------------------------------------------------------- +// isStoredNotificationRead +// --------------------------------------------------------------------------- + +describe('isStoredNotificationRead', () => { + const entry = (ts = 1000): StoredNotification => + ({ room_id: ROOM_ID, event: { event_id: EVENT_ID }, ts }) as StoredNotification; + + const readStateRoom = (overrides: Partial): Room => + createRoom({ + findEventById: () => undefined, + getEventReadUpTo: () => null, + getReadReceiptForUserId: () => null, + hasUserReadEvent: () => false, + ...overrides, + } as Partial); + + it('defers to hasUserReadEvent while the event is in the timeline', () => { + const room = readStateRoom({ + findEventById: ((id: string) => + id === EVENT_ID ? createEvent() : undefined) as Room['findEventById'], + hasUserReadEvent: (() => true) as Room['hasUserReadEvent'], + }); + + expect(isStoredNotificationRead(room, USER_ID, entry())).toBe(true); + }); + + it('reports unread while the event is in the timeline and unread', () => { + const room = readStateRoom({ + findEventById: (() => createEvent()) as Room['findEventById'], + hasUserReadEvent: (() => false) as Room['hasUserReadEvent'], + }); + + expect(isStoredNotificationRead(room, USER_ID, entry())).toBe(false); + }); + + it('falls back to the receipt timestamp once the event has aged out', () => { + const room = readStateRoom({ + getReadReceiptForUserId: (() => ({ + eventId: '$readUpTo', + data: { ts: 5000 }, + })) as unknown as Room['getReadReceiptForUserId'], + }); + + expect(isStoredNotificationRead(room, USER_ID, entry(1000))).toBe(true); + expect(isStoredNotificationRead(room, USER_ID, entry(9000))).toBe(false); + }); + + // Under sliding sync an entry outside the loaded window is the normal case, + // so it must not be hidden just because nothing resolves. + it('treats an unresolvable entry as unread', () => { + const room = readStateRoom({}); + + expect(isStoredNotificationRead(room, USER_ID, entry())).toBe(false); + }); + + it('honours a private receipt from a manual mark-as-read', () => { + const room = readStateRoom({ + getReadReceiptForUserId: ((_userId: string, _ignore?: boolean, type?: string) => + type === 'm.read.private' + ? { eventId: '$private', data: { ts: 5000 } } + : null) as unknown as Room['getReadReceiptForUserId'], + }); + + expect(isStoredNotificationRead(room, USER_ID, entry(1000))).toBe(true); + expect(isStoredNotificationRead(room, USER_ID, entry(9000))).toBe(false); + }); + + it('takes the newest of the public and private receipts', () => { + const room = readStateRoom({ + getReadReceiptForUserId: ((_userId: string, _ignore?: boolean, type?: string) => + type === 'm.read.private' + ? { eventId: '$private', data: { ts: 8000 } } + : { + eventId: '$public', + data: { ts: 2000 }, + }) as unknown as Room['getReadReceiptForUserId'], + }); + + expect(isStoredNotificationRead(room, USER_ID, entry(5000))).toBe(true); + }); + + it('honours a mark-as-read while the event is still loaded', () => { + const room = readStateRoom({ + findEventById: (() => createEvent()) as Room['findEventById'], + hasUserReadEvent: (() => true) as Room['hasUserReadEvent'], + getReadReceiptForUserId: (() => null) as unknown as Room['getReadReceiptForUserId'], + }); + + expect(isStoredNotificationRead(room, USER_ID, entry())).toBe(true); + }); +}); + +describe('evaluateNotification storage footprint', () => { + const encryptedEvent = (content: Record) => + createEvent({ + getType: () => 'm.room.encrypted', + getContent: (() => content) as MatrixEvent['getContent'], + }); + + it('drops megolm ciphertext and keeps only the algorithm', () => { + const room = createRoom({}, true); + const mx = createClient({ [ROOM_ID]: room }); + const event = encryptedEvent({ + algorithm: 'm.megolm.v1.aes-sha2', + ciphertext: 'A'.repeat(4096), + sender_key: 'curve25519:abc', + session_id: 'session', + }); + + const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); + + expect(result!.event.type).toBe('m.room.encrypted'); + expect(result!.event.content).toEqual({ algorithm: 'm.megolm.v1.aes-sha2' }); + }); + + it('drops the payload even when the algorithm is absent', () => { + const room = createRoom({}, true); + const mx = createClient({ [ROOM_ID]: room }); + + const result = evaluateNotification( + mx, + room, + encryptedEvent({ ciphertext: 'B'.repeat(2048) }), + new Set(), + NotificationType.AllMessages + ); + + expect(result!.event.content).toEqual({}); + }); +}); diff --git a/src/app/utils/localNotifications.ts b/src/app/utils/localNotifications.ts new file mode 100644 index 0000000000..3b357bfc2c --- /dev/null +++ b/src/app/utils/localNotifications.ts @@ -0,0 +1,201 @@ +import type { IContent, IEvent, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; +import { 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; + dismissed?: boolean; +}; + +export const MAX_BODY_LENGTH = 500; + +// HTML cannot be sliced without breaking tags, so oversized messages lose it. +// Ciphertext is dropped: multi-KB, shares the session's localStorage budget, and +// is never rendered. Always copies — the SDK mutates `content` in place. +const truncateContent = (content: IContent, storeContent: boolean): IContent => { + if (content.ciphertext !== undefined) { + return typeof content.algorithm === 'string' ? { algorithm: content.algorithm } : {}; + } + + 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; +}; + +// Defaults to public receipts only, and markAsRead sends a private one when +// hideReads is on. +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 => { + // hasUserReadEvent warns for events missing from the timeline, so only + // consult it while the event is known. + if (room.findEventById(entry.event.event_id)) { + return room.hasUserReadEvent(userId, entry.event.event_id); + } + + // Outside the loaded window, which under sliding sync is routine. Compare + // against the receipt timestamp and default to UNREAD, so a notification is + // never hidden just because its event is not in memory. + const receiptTs = latestReceiptTs(room, userId); + if (receiptTs === undefined) return false; + return entry.ts <= receiptTs; +}; + +// actionsForEvent returns {} rather than throwing before push rules sync, which +// reads as "do not notify". +export const arePushRulesReady = (mx: MatrixClient): boolean => mx.pushRules?.global !== undefined; + +// The DM override below force-notifies anything in a DM. These types are +// explicitly dont_notify by push rule and must not be resurrected by it. +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 sliceNotificationPage = ( + all: StoredNotification[], + offset: number, + limit: number, + filterMode: 'all' | 'mentions', + includeDone?: boolean +): { page: StoredNotification[]; nextToken?: string } => { + let filtered = all; + if (filterMode === 'mentions') filtered = filtered.filter((n) => n.highlight || n.isDM); + if (!includeDone) filtered = filtered.filter((n) => !n.dismissed); + const sorted = [...filtered].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 }; +}; + +// --------------------------------------------------------------------------- +// Gap backfill — pure decision logic. Testable without network. +// --------------------------------------------------------------------------- + +export const GAP_THRESHOLD_MS = 5 * 60 * 1000; +export const MAX_BACKFILL_ROOMS = 30; +const GAP_PAGES_MULTIPLIER_MS = 30 * 60 * 1000; + +export type BackfillRoomInfo = { + roomId: string; + lastActiveTs: number; + isSpaceRoom: boolean; + isMuted: boolean; +}; + +export const shouldBackfill = (lastSeenTs: number | undefined, now: number): number | undefined => { + if (lastSeenTs === undefined) return undefined; + if (now - lastSeenTs < GAP_THRESHOLD_MS) return undefined; + return lastSeenTs; +}; + +export const selectBackfillRooms = (rooms: BackfillRoomInfo[], lastSeenTs: number): string[] => + rooms + .filter((r) => !r.isSpaceRoom && !r.isMuted && r.lastActiveTs > lastSeenTs) + .toSorted((a, b) => b.lastActiveTs - a.lastActiveTs) + .slice(0, MAX_BACKFILL_ROOMS) + .map((r) => r.roomId); + +export const backfillPageCount = (lastSeenTs: number, now: number): number => + now - lastSeenTs > GAP_PAGES_MULTIPLIER_MS ? 2 : 1; 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..bd8c1a05ec --- /dev/null +++ b/src/client/localNotificationCache.test.ts @@ -0,0 +1,321 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import type { StoredNotification } from '$utils/localNotifications'; +import { clearLocalNotificationCache, LocalNotificationCache } from './localNotificationCache'; + +const userId = '@user:example.com'; +const userId2 = '@other:example.com'; + +const makeEntry = ( + eventId: string, + roomId = '!room:example.com', + ts = Date.now(), + highlight = false, + dismissed?: boolean, + isDM = false +): StoredNotification => ({ + room_id: roomId, + event: { + event_id: eventId, + type: 'm.room.message', + content: { body: `test ${eventId}` }, + sender: '@user:example.com', + origin_server_ts: ts, + unsigned: {}, + }, + ts, + highlight, + isDM, + dismissed, +}); + +/** + * Every cache instance registers a `storage` listener and can hold a pending + * debounced write, both of which would bleed into later tests under the same + * storage key. Track them and tear them down. + */ +const caches: LocalNotificationCache[] = []; +const openCache = (id: string): LocalNotificationCache => { + const cache = new LocalNotificationCache(id); + caches.push(cache); + return cache; +}; + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + while (caches.length > 0) caches.pop()?.destroy(); + vi.restoreAllMocks(); + localStorage.clear(); +}); + +describe('LocalNotificationCache', () => { + it('dedup by event_id', () => { + const cache = openCache(userId); + const entry = makeEntry('$ev1'); + cache.merge(entry); + cache.merge(entry); + expect(cache.getEntries()).toHaveLength(1); + }); + + it('newest-first ordering', () => { + const cache = openCache(userId); + cache.merge(makeEntry('$ev1', '!room:example.com', 100)); + cache.merge(makeEntry('$ev2', '!room:example.com', 200)); + cache.merge(makeEntry('$ev3', '!room:example.com', 50)); + const tss = cache.getEntries().map((e) => e.ts); + expect(tss).toEqual([200, 100, 50]); + }); + + it('MAX_ENTRIES truncation (oldest dropped)', () => { + const cache = openCache(userId); + for (let i = 0; i < 310; i++) { + cache.merge(makeEntry(`$ev${i}`, '!room:example.com', i)); + } + const entries = cache.getEntries(); + expect(entries).toHaveLength(300); + expect(entries.at(0)?.ts).toBe(309); + expect(entries.at(-1)?.ts).toBe(10); + }); + + it('round-trip via destroy() flush', () => { + const cache = openCache(userId); + for (let i = 0; i < 5; i++) { + cache.merge(makeEntry(`$ev${i}`, '!room:example.com', i)); + } + cache.destroy(); + + const restored = openCache(userId); + expect(restored.getEntries()).toHaveLength(5); + expect(restored.getEntries().map((e) => e.event.event_id)).toEqual([ + '$ev4', + '$ev3', + '$ev2', + '$ev1', + '$ev0', + ]); + }); + + it('keeps removals out of storage', () => { + const cache = openCache(userId); + cache.merge(makeEntry('$ev1')); + cache.merge(makeEntry('$ev2')); + cache.destroy(); + + const reopened = openCache(userId); + reopened.remove('$ev1'); + reopened.destroy(); + + const restored = openCache(userId); + expect(restored.getEntries().map((e) => e.event.event_id)).toEqual(['$ev2']); + }); + + it('reinstates a removed entry when it is recorded again', () => { + const cache = openCache(userId); + cache.merge(makeEntry('$ev1')); + cache.remove('$ev1'); + cache.merge(makeEntry('$ev1')); + cache.destroy(); + + const restored = openCache(userId); + expect(restored.getEntries().map((e) => e.event.event_id)).toEqual(['$ev1']); + }); + + it('version mismatch returns empty', () => { + const key = `sable.notificationCache.v1.${encodeURIComponent(userId)}`; + localStorage.setItem(key, JSON.stringify({ version: 999, entries: [makeEntry('$ev1')] })); + const cache = openCache(userId); + expect(cache.getEntries()).toEqual([]); + }); + + it('corrupt JSON returns empty', () => { + const key = `sable.notificationCache.v1.${encodeURIComponent(userId)}`; + localStorage.setItem(key, '{not json'); + const cache = openCache(userId); + expect(cache.getEntries()).toEqual([]); + }); + + it('QuotaExceededError retry with halving', () => { + const cache = openCache(userId); + for (let i = 0; i < 100; i++) { + cache.merge(makeEntry(`$ev${i}`, '!room:example.com', i)); + } + + let calls = 0; + const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + calls++; + throw new DOMException('Quota exceeded', 'QuotaExceededError'); + }); + + cache.destroy(); + + spy.mockRestore(); + + expect(calls).toBeGreaterThanOrEqual(7); + }); + + it('clear(userId) touches only that key', () => { + const cacheA = openCache(userId); + const cacheB = openCache(userId2); + cacheA.merge(makeEntry('$evA')); + cacheB.merge(makeEntry('$evB')); + cacheA.destroy(); + cacheB.destroy(); + + clearLocalNotificationCache(userId); + + const restoredA = openCache(userId); + const restoredB = openCache(userId2); + expect(restoredA.getEntries()).toEqual([]); + expect(restoredB.getEntries()).toHaveLength(1); + }); + + it('cross-tab: two instances merging concurrently both survive', () => { + const cacheA = openCache(userId); + const cacheB = openCache(userId); + cacheA.merge(makeEntry('$evX', '!room:example.com', 100)); + cacheB.merge(makeEntry('$evY', '!room:example.com', 200)); + cacheA.destroy(); + cacheB.destroy(); + + const key = `sable.notificationCache.v1.${encodeURIComponent(userId)}`; + const raw = localStorage.getItem(key); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + const eventIds = parsed.entries.map((e: StoredNotification) => e.event.event_id); + expect(eventIds).toContain('$evX'); + expect(eventIds).toContain('$evY'); + }); + + it('dismissed flag preserved on re-record', () => { + const cache = openCache(userId); + const entry = makeEntry('$ev1'); + cache.merge(entry); + cache.dismiss('$ev1'); + cache.merge(makeEntry('$ev1')); + expect(cache.getEntries().at(0)?.dismissed).toBe(true); + }); + + it('dismissing one entry leaves siblings untouched', () => { + const cache = openCache(userId); + const x = makeEntry('$evX', '!room:example.com', 100, false); + const y = makeEntry('$evY', '!room:example.com', 200, false); + const z = makeEntry('$evZ', '!room:example.com', 300, false); + cache.merge(x); + cache.merge(y); + cache.merge(z); + cache.dismiss('$evY'); + + const entries = cache.getEntries(); + const find = (id: string) => entries.find((e) => e.event.event_id === id)!; + expect(find('$evX').dismissed).toBeFalsy(); + expect(find('$evY').dismissed).toBe(true); + expect(find('$evZ').dismissed).toBeFalsy(); + }); + + it('badge count excludes dismissed and non-highlights', () => { + const cache = openCache(userId); + cache.merge(makeEntry('$ev1', '!room:example.com', 100, true, false)); // highlight, undismissed + cache.merge(makeEntry('$ev2', '!room:example.com', 200, true, true)); // highlight, dismissed + cache.merge(makeEntry('$ev3', '!room:example.com', 300, false, false)); // non-highlight, undismissed + + const undismissedHighlights = cache.getEntries().filter((e) => e.highlight && !e.dismissed); + expect(undismissedHighlights).toHaveLength(1); + expect(undismissedHighlights.at(0)?.event.event_id).toBe('$ev1'); + }); + + it('lastSeenTs with 60s throttle', () => { + const cache = openCache(userId); + cache.updateLastSeenTs(1000); + expect(cache.getLastSeenTs()).toBe(1000); + + cache.updateLastSeenTs(1001); + expect(cache.getLastSeenTs()).toBe(1000); + + cache.updateLastSeenTs(61001); + expect(cache.getLastSeenTs()).toBe(61001); + }); + + it('subscribe notifies on merge', () => { + const cache = openCache(userId); + const listener = vi.fn<() => void>(); + cache.subscribe(listener); + cache.merge(makeEntry('$ev1')); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('persists lastSeenTs across a flush and reload', () => { + const cache = openCache(userId); + cache.updateLastSeenTs(12345); + cache.merge(makeEntry('$ev1')); + cache.destroy(); + + const restored = openCache(userId); + expect(restored.getLastSeenTs()).toBe(12345); + }); +}); + +// --------------------------------------------------------------------------- +// mergeMany / countEntries — a batched scan must not notify per entry +// --------------------------------------------------------------------------- + +describe('LocalNotificationCache batching', () => { + it('notifies once for a whole batch', () => { + const cache = openCache(userId); + const listener = vi.fn<() => void>(); + cache.subscribe(listener); + + cache.mergeMany([makeEntry('$a', '!r:e.com', 3), makeEntry('$b', '!r:e.com', 2)]); + + expect(listener).toHaveBeenCalledTimes(1); + expect(cache.getEntries()).toHaveLength(2); + }); + + it('does not notify for an empty batch', () => { + const cache = openCache(userId); + const listener = vi.fn<() => void>(); + cache.subscribe(listener); + + cache.mergeMany([]); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('keeps entries newest-first across a batch', () => { + const cache = openCache(userId); + + cache.mergeMany([ + makeEntry('$old', '!r:e.com', 100), + makeEntry('$new', '!r:e.com', 300), + makeEntry('$mid', '!r:e.com', 200), + ]); + + expect(cache.getEntries().map((e) => e.event.event_id)).toEqual(['$new', '$mid', '$old']); + }); + + it('dedupes within a batch and preserves an existing dismissal', () => { + const cache = openCache(userId); + cache.merge(makeEntry('$a', '!r:e.com', 100)); + cache.dismiss('$a'); + + cache.mergeMany([makeEntry('$a', '!r:e.com', 100), makeEntry('$a', '!r:e.com', 100)]); + + const entries = cache.getEntries(); + expect(entries).toHaveLength(1); + expect(entries[0]!.dismissed).toBe(true); + }); + + it('counts in place without copying entries', () => { + const cache = openCache(userId); + cache.mergeMany([ + makeEntry('$a', '!r:e.com', 300, true), + makeEntry('$b', '!r:e.com', 200, false), + makeEntry('$c', '!r:e.com', 100, true, true), + ]); + + expect(cache.countEntries((e) => e.highlight)).toBe(2); + expect(cache.countEntries((e) => e.highlight && !e.dismissed)).toBe(1); + expect(cache.countEntries(() => false)).toBe(0); + }); +}); diff --git a/src/client/localNotificationCache.ts b/src/client/localNotificationCache.ts new file mode 100644 index 0000000000..4cf858df29 --- /dev/null +++ b/src/client/localNotificationCache.ts @@ -0,0 +1,325 @@ +import type { StoredNotification } from '$utils/localNotifications'; + +const CACHE_VERSION = 1; +const MAX_ENTRIES = 300; +const CACHE_WRITE_DELAY_MS = 500; +const STORAGE_EVENT_DEBOUNCE_MS = 200; +const HEARTBEAT_THROTTLE_MS = 60_000; + +type CacheData = { + version: number; + entries: StoredNotification[]; + lastSeenTs?: number; +}; + +const emptyCache = (): CacheData => ({ version: CACHE_VERSION, entries: [] }); + +const parseCache = (value: string | null): CacheData => { + if (!value) return emptyCache(); + try { + const parsed = JSON.parse(value) as Partial; + if (parsed.version !== CACHE_VERSION || !Array.isArray(parsed.entries)) { + return emptyCache(); + } + return { version: CACHE_VERSION, entries: parsed.entries, lastSeenTs: parsed.lastSeenTs }; + } catch { + return emptyCache(); + } +}; + +const newestTs = (a: number | undefined, b: number | undefined): number | undefined => { + if (a === undefined) return b; + if (b === undefined) return a; + return Math.max(a, b); +}; + +type IdleWindow = Window & + typeof globalThis & { + requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; + cancelIdleCallback?: (handle: number) => void; + }; + +export class LocalNotificationCache { + private data: CacheData; + private dirty: Set = new Set(); + private removed: Set = new Set(); + readonly userId: string; + private readonly storageKey: string; + private listeners: Set<() => void> = new Set(); + private writeTimeoutId: ReturnType | undefined; + private idleCallbackId: number | undefined; + private storageDebounceId: ReturnType | undefined; + private lastHeartbeatTs: number | undefined; + private destroyed = false; + private quotaExhausted = false; + + constructor(userId: string) { + this.userId = userId; + this.storageKey = `sable.notificationCache.v1.${encodeURIComponent(userId)}`; + this.data = this.readStored(); + window.addEventListener('storage', this.onStorageEvent); + } + + merge(entry: StoredNotification): void { + this.mergeMany([entry]); + } + + /** One sort, one write and one notification for the whole batch. */ + mergeMany(entries: StoredNotification[]): void { + if (this.destroyed || entries.length === 0) return; + + const indexByEventId = new Map( + this.data.entries.map((entry, index) => [entry.event.event_id, index]) + ); + let reorder = false; + + for (const entry of entries) { + const eventId = entry.event.event_id; + this.removed.delete(eventId); + + const idx = indexByEventId.get(eventId); + if (idx === undefined) { + indexByEventId.set(eventId, this.data.entries.length); + this.data.entries.push(entry); + reorder = true; + } else { + const existing = this.data.entries[idx]!; + // A replacement can carry a different ts, which invalidates the order. + if (existing.ts !== entry.ts) reorder = true; + this.data.entries[idx] = { ...entry, dismissed: existing.dismissed || entry.dismissed }; + } + + this.dirty.add(eventId); + } + + if (reorder) { + this.data.entries.sort((a, b) => b.ts - a.ts); + if (this.data.entries.length > MAX_ENTRIES) { + this.data.entries.length = MAX_ENTRIES; + } + } + + this.scheduleWrite(); + this.notifyListeners(); + } + + getEntries(): StoredNotification[] { + return this.data.entries.map((entry) => ({ ...entry })); + } + + /** Counts in place — getEntries() would copy every entry just to discard it. */ + countEntries(predicate: (entry: StoredNotification) => boolean): number { + let count = 0; + for (const entry of this.data.entries) { + if (predicate(entry)) count += 1; + } + return count; + } + + remove(eventId: string): void { + if (this.destroyed) return; + const idx = this.data.entries.findIndex((e) => e.event.event_id === eventId); + if (idx !== -1) this.data.entries.splice(idx, 1); + // Tombstone even when absent here — another tab may still have it on disk. + this.dirty.delete(eventId); + this.removed.add(eventId); + this.scheduleWrite(); + this.notifyListeners(); + } + + getLastSeenTs(): number | undefined { + return this.data.lastSeenTs; + } + + updateLastSeenTs(ts: number): void { + if (this.destroyed) return; + if (this.lastHeartbeatTs !== undefined && ts - this.lastHeartbeatTs < HEARTBEAT_THROTTLE_MS) { + return; + } + this.lastHeartbeatTs = ts; + this.data = { ...this.data, lastSeenTs: ts }; + this.scheduleWrite(); + } + + dismiss(eventId: string): void { + if (this.destroyed) return; + const entry = this.data.entries.find((e) => e.event.event_id === eventId); + if (!entry) return; + entry.dismissed = true; + this.dirty.add(eventId); + this.scheduleWrite(); + this.notifyListeners(); + } + + dismissAllInRoom(roomId: string): void { + if (this.destroyed) return; + for (const entry of this.data.entries) { + if (entry.room_id === roomId) { + entry.dismissed = true; + this.dirty.add(entry.event.event_id); + } + } + this.scheduleWrite(); + this.notifyListeners(); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + destroy(): void { + if (this.writeTimeoutId !== undefined) { + clearTimeout(this.writeTimeoutId); + this.writeTimeoutId = undefined; + } + if (this.idleCallbackId !== undefined) { + (globalThis as IdleWindow).cancelIdleCallback?.(this.idleCallbackId); + this.idleCallbackId = undefined; + } + if (this.storageDebounceId !== undefined) { + clearTimeout(this.storageDebounceId); + this.storageDebounceId = undefined; + } + this.write(); + this.destroyed = true; + window.removeEventListener('storage', this.onStorageEvent); + this.listeners.clear(); + } + + private notifyListeners(): void { + for (const listener of this.listeners) listener(); + } + + private readStored(): CacheData { + try { + return parseCache(globalThis.localStorage?.getItem(this.storageKey) ?? null); + } catch { + // Storage can be disabled for this origin. + return emptyCache(); + } + } + + /** Replays unflushed local changes on top of a snapshot read from storage. */ + private applyPending(base: StoredNotification[]): StoredNotification[] { + const merged = base.filter((entry) => !this.removed.has(entry.event.event_id)); + + for (const eventId of this.dirty) { + const entry = this.data.entries.find((e) => e.event.event_id === eventId); + if (!entry) continue; + const idx = merged.findIndex((m) => m.event.event_id === eventId); + const existing = idx === -1 ? undefined : merged[idx]; + if (existing) { + merged[idx] = { ...entry, dismissed: existing.dismissed || entry.dismissed }; + } else { + merged.push(entry); + } + } + + merged.sort((a, b) => b.ts - a.ts); + return merged.slice(0, MAX_ENTRIES); + } + + private scheduleWrite(): void { + if (this.writeTimeoutId !== undefined || this.idleCallbackId !== undefined) return; + this.writeTimeoutId = setTimeout(() => { + this.writeTimeoutId = undefined; + const idleWindow = globalThis as IdleWindow; + if (typeof idleWindow.requestIdleCallback === 'function') { + this.idleCallbackId = idleWindow.requestIdleCallback( + () => { + this.idleCallbackId = undefined; + this.write(); + }, + { timeout: 2000 } + ); + return; + } + this.write(); + }, CACHE_WRITE_DELAY_MS); + } + + private write(): void { + // Latched after a total quota failure: without this every later write + // replays the whole halving loop, ~9 stringify+setItem attempts each time. + if (this.quotaExhausted) return; + + const stored = this.readStored(); + const lastSeenTs = newestTs(this.data.lastSeenTs, stored.lastSeenTs); + + let entries = this.applyPending(stored.entries); + for (;;) { + const nextData: CacheData = { version: CACHE_VERSION, entries, lastSeenTs }; + try { + globalThis.localStorage?.setItem(this.storageKey, JSON.stringify(nextData)); + this.commit(nextData); + return; + } catch { + if (entries.length <= 1) { + // Out of quota even at a single entry. Give the space back rather than + // competing with the session token write, which has no such fallback. + this.quotaExhausted = true; + try { + globalThis.localStorage?.removeItem(this.storageKey); + } catch { + // Nothing further we can do. + } + return; + } + entries = entries.slice(0, Math.floor(entries.length / 2)); + } + } + } + + private commit(next: CacheData): void { + this.data = next; + this.dirty.clear(); + this.removed.clear(); + } + + private onStorageEvent = (e: StorageEvent): void => { + if (e.key !== this.storageKey) return; + if (this.storageDebounceId !== undefined) { + clearTimeout(this.storageDebounceId); + } + this.storageDebounceId = setTimeout(() => { + this.storageDebounceId = undefined; + const disk = this.readStored(); + this.data = { + version: CACHE_VERSION, + entries: this.applyPending(disk.entries), + lastSeenTs: newestTs(this.data.lastSeenTs, disk.lastSeenTs), + }; + this.notifyListeners(); + }, STORAGE_EVENT_DEBOUNCE_MS); + }; +} + +export function clearLocalNotificationCache(userId: string): void { + try { + globalThis.localStorage?.removeItem(`sable.notificationCache.v1.${encodeURIComponent(userId)}`); + } catch { + // Storage can be disabled for this origin; logout must continue regardless. + } + instances.delete(userId); +} + +// Singleton keyed by userId so the recorder and the timeline hook share one instance. +const instances = new Map(); + +export const getLocalNotificationCache = (userId: string): LocalNotificationCache => { + let cache = instances.get(userId); + if (!cache) { + cache = new LocalNotificationCache(userId); + instances.set(userId, cache); + } + return cache; +}; + +export const destroyLocalNotificationCache = (userId: string): void => { + const cache = instances.get(userId); + if (cache) { + cache.destroy(); + instances.delete(userId); + } +}; From d724838a3d80add2dfcd02ec4b972a7d9510a2c8 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 26 Jul 2026 13:20:19 +0200 Subject: [PATCH 2/6] refactor(notifications): store only as much message body as the preview shows --- src/app/utils/localNotifications.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/utils/localNotifications.ts b/src/app/utils/localNotifications.ts index 3b357bfc2c..7aa0062846 100644 --- a/src/app/utils/localNotifications.ts +++ b/src/app/utils/localNotifications.ts @@ -12,7 +12,7 @@ export type StoredNotification = { dismissed?: boolean; }; -export const MAX_BODY_LENGTH = 500; +export const MAX_BODY_LENGTH = 120; // HTML cannot be sliced without breaking tags, so oversized messages lose it. // Ciphertext is dropped: multi-KB, shares the session's localStorage budget, and From 2c3b245050f5bb16a96dc473b9dd67fb06112c19 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 26 Jul 2026 13:56:53 +0200 Subject: [PATCH 3/6] Fix notification row actions being spread across the row --- src/app/pages/client/inbox/Notifications.tsx | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 093cf4676a..51318f35e5 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -1,6 +1,6 @@ import type { MouseEventHandler } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { Avatar, Box, Chip, Header, IconButton, Scroll, Text, config, toRem } from 'folds'; +import { Avatar, Badge, Box, Chip, Header, IconButton, Scroll, Text, config, toRem } from 'folds'; import { ArrowLeft, CaretUp, @@ -97,18 +97,12 @@ function NotificationItem({ event={event} renderContent={renderContent} actions={ - <> + // One element: MessagePreview drops this straight into a + // justifyContent="SpaceBetween" row, so separate children get spread + // across its full width instead of grouping on the right. + {!isRead && ( - + )} Open @@ -116,7 +110,7 @@ function NotificationItem({ Done - + } onOpen={handleOpen} hour24Clock={hour24Clock} From df6123501e938a379c157f3eb8cdd2fddbd3ceae Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 26 Jul 2026 14:37:57 +0200 Subject: [PATCH 4/6] feat(notifications): show inbox badge on the sidebar icon --- .changeset/local-notification-inbox.md | 2 +- src/app/hooks/useInboxNotificationCount.ts | 31 +-- src/app/hooks/useLocalNotificationTimeline.ts | 33 +-- src/app/pages/client/ClientNonUIFeatures.tsx | 2 +- .../client-non-ui/notificationRecorder.tsx | 192 ++++++++++++++++ .../client/client-non-ui/notifications.tsx | 214 ------------------ src/app/pages/client/sidebar/InboxTab.tsx | 5 +- src/app/state/utils/atomWithLocalStorage.ts | 4 +- src/app/utils/groupNotifications.ts | 20 +- .../utils/localNotificationBackfill.test.ts | 197 +++++++++++++--- src/app/utils/localNotificationBackfill.ts | 72 +++--- src/app/utils/localNotifications.test.ts | 18 -- src/app/utils/localNotifications.ts | 43 ++-- src/app/utils/throttleTrailing.ts | 32 +++ src/client/localNotificationCache.test.ts | 9 - src/client/localNotificationCache.ts | 9 +- src/client/slidingSyncSidebarCache.ts | 6 +- 17 files changed, 486 insertions(+), 403 deletions(-) create mode 100644 src/app/pages/client/client-non-ui/notificationRecorder.tsx create mode 100644 src/app/utils/throttleTrailing.ts diff --git a/.changeset/local-notification-inbox.md b/.changeset/local-notification-inbox.md index 1371a1e691..e431e8425d 100644 --- a/.changeset/local-notification-inbox.md +++ b/.changeset/local-notification-inbox.md @@ -2,4 +2,4 @@ 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. Notifications can be dismissed individually, the inbox defaults to mentions and DMs, and returning after time away backfills what was missed. +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.ts b/src/app/hooks/useInboxNotificationCount.ts index 6a885f5f70..46afbebd97 100644 --- a/src/app/hooks/useInboxNotificationCount.ts +++ b/src/app/hooks/useInboxNotificationCount.ts @@ -4,6 +4,7 @@ import { RoomEvent } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { getLocalNotificationCache } from '$client/localNotificationCache'; import { isStoredNotificationRead, type StoredNotification } from '$utils/localNotifications'; +import { throttleTrailing, type Throttled } from '$utils/throttleTrailing'; const RECOMPUTE_THROTTLE_MS = 500; @@ -14,14 +15,15 @@ type ReceiptContent = Record>>; class InboxCountStore { private count = 0; private readonly subscribers = new Set<() => void>(); - private trailing: ReturnType | undefined; - private lastRun = 0; + private readonly schedule: Throttled; private detach: (() => void) | undefined; constructor( readonly mx: MatrixClient, private readonly userId: string - ) {} + ) { + this.schedule = throttleTrailing(this.recompute, RECOMPUTE_THROTTLE_MS); + } getSnapshot = (): number => this.count; @@ -35,7 +37,7 @@ class InboxCountStore { }; }; - private counts = (entry: StoredNotification): boolean => { + private isUnreadMention = (entry: StoredNotification): boolean => { if (entry.dismissed) return false; if (!entry.highlight && !entry.isDM) return false; @@ -46,26 +48,12 @@ class InboxCountStore { }; private recompute = (): void => { - this.lastRun = Date.now(); - const next = getLocalNotificationCache(this.userId).countEntries(this.counts); + const next = getLocalNotificationCache(this.userId).countEntries(this.isUnreadMention); if (next === this.count) return; this.count = next; for (const onChange of this.subscribers) onChange(); }; - private schedule = (): void => { - const elapsed = Date.now() - this.lastRun; - if (elapsed >= RECOMPUTE_THROTTLE_MS) { - this.recompute(); - return; - } - if (this.trailing !== undefined) return; - this.trailing = setTimeout(() => { - this.trailing = undefined; - this.recompute(); - }, RECOMPUTE_THROTTLE_MS - elapsed); - }; - private onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event) => { const content = event.getContent(); const readByUs = Object.values(content).some((byType) => @@ -87,10 +75,7 @@ class InboxCountStore { private teardown(): void { this.detach?.(); this.detach = undefined; - if (this.trailing !== undefined) { - clearTimeout(this.trailing); - this.trailing = undefined; - } + this.schedule.cancel(); stores.delete(this.userId); } } diff --git a/src/app/hooks/useLocalNotificationTimeline.ts b/src/app/hooks/useLocalNotificationTimeline.ts index 3eef925e5f..53453554b2 100644 --- a/src/app/hooks/useLocalNotificationTimeline.ts +++ b/src/app/hooks/useLocalNotificationTimeline.ts @@ -6,6 +6,7 @@ import { allRoomsAtom } from '$state/room-list/roomList'; import { getLocalNotificationCache } from '$client/localNotificationCache'; import { sliceNotificationPage, type StoredNotification } from '$utils/localNotifications'; import { groupNotifications } from '$utils/groupNotifications'; +import { throttleTrailing } from '$utils/throttleTrailing'; type RoomNotificationsGroup = { roomId: string; @@ -18,7 +19,6 @@ type NotificationTimeline = { const RELOAD_THROTTLE_MS = 500; type LoadTimeline = (from?: string) => Promise; -type SilentReloadTimeline = () => Promise; export const sameNotificationTimeline = ( a: NotificationTimeline, @@ -49,7 +49,7 @@ export const useLocalNotificationTimeline = ( paginationLimit: number, filterMode: 'all' | 'mentions' = 'mentions', includeDone?: boolean -): [NotificationTimeline, LoadTimeline, SilentReloadTimeline] => { +): [NotificationTimeline, LoadTimeline] => { const mx = useMatrixClient(); const allRooms = useAtomValue(allRoomsAtom); const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); @@ -96,32 +96,11 @@ export const useLocalNotificationTimeline = ( [applyUpTo, paginationLimit] ); - const silentReloadTimeline: SilentReloadTimeline = useCallback(async () => { - applyUpTo(loadedLimitRef.current); - }, [applyUpTo]); - useEffect(() => { - let trailing: ReturnType | undefined; - let lastRun = 0; - - const run = () => { - lastRun = Date.now(); + const reload = throttleTrailing(() => { applyUpTo(loadedLimitRef.current); bumpReceiptVersion((v) => v + 1); - }; - - const reload = () => { - const elapsed = Date.now() - lastRun; - if (elapsed >= RELOAD_THROTTLE_MS) { - run(); - return; - } - if (trailing !== undefined) return; - trailing = setTimeout(() => { - trailing = undefined; - run(); - }, RELOAD_THROTTLE_MS - elapsed); - }; + }, RELOAD_THROTTLE_MS); const unsubscribe = cache.subscribe(reload); mx.on(RoomEvent.Receipt, reload); @@ -129,9 +108,9 @@ export const useLocalNotificationTimeline = ( return () => { unsubscribe(); mx.off(RoomEvent.Receipt, reload); - clearTimeout(trailing); + reload.cancel(); }; }, [mx, cache, applyUpTo]); - return [notificationTimeline, loadTimeline, silentReloadTimeline]; + return [notificationTimeline, loadTimeline]; }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 2f92e2344d..e870e57dec 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -6,10 +6,10 @@ 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, - NotificationRecorder, HandleNotificationClick, SyncNotificationSettingsWithServiceWorker, HandleDecryptPushEvent, 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..a079cb8bf9 --- /dev/null +++ b/src/app/pages/client/client-non-ui/notificationRecorder.tsx @@ -0,0 +1,192 @@ +import { useAtomValue } from 'jotai'; +import { useEffect, useRef } from 'react'; +import type { MatrixEvent, RoomEventHandlerMap } from '$types/matrix-sdk'; +import { ClientEvent, EventType, RoomEvent, 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 { createLogger } from '$utils/debug'; +import { getNotificationType } from '$utils/room/unread'; +import { + arePushRulesReady, + evaluateNotification, + isAwaitingDecryption, + watchDecryption, +} from '$utils/localNotifications'; +import { getLocalNotificationCache } from '$client/localNotificationCache'; +import { backfillLocalNotifications, runLiveTimelineScan } from '$utils/localNotificationBackfill'; + +const logger = createLogger('NotificationRecorder'); +const RECORDED_CAP = 300; +const HEARTBEAT_INTERVAL_MS = 60_000; + +export function NotificationRecorder() { + const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + const mDirectsRef = useRef(mDirects); + mDirectsRef.current = mDirects; + + const recordedRef = useRef>(new Set()); + const decryptingRef = useRef>(new Set()); + const hasBackfilledRef = useRef(false); + const backfillControllerRef = useRef(undefined); + const decryptWatchersRef = useRef void>>(new Map()); + const hasScannedRef = useRef(false); + const [storeContent] = useSetting(settingsAtom, 'showMessageContentInNotifications'); + const [storeEncryptedContent] = useSetting( + settingsAtom, + 'showMessageContentInEncryptedNotifications' + ); + const storeContentRef = useRef(storeContent); + storeContentRef.current = storeContent; + const storeEncryptedContentRef = useRef(storeContent && storeEncryptedContent); + storeEncryptedContentRef.current = storeContent && storeEncryptedContent; + const prevMxRef = useRef(mx); + if (prevMxRef.current !== mx) { + prevMxRef.current = mx; + recordedRef.current = new Set(); + decryptingRef.current = new Set(); + hasBackfilledRef.current = false; + hasScannedRef.current = false; + } + + useEffect(() => { + const userId = mx.getSafeUserId(); + const cache = getLocalNotificationCache(userId); + const contentOptions = () => ({ + storeContent: storeContentRef.current, + storeEncryptedContent: storeEncryptedContentRef.current, + }); + + const markRecorded = (eventId: string) => { + recordedRef.current.add(eventId); + if (recordedRef.current.size > RECORDED_CAP) { + const oldest = recordedRef.current.values().next().value; + if (oldest !== undefined) recordedRef.current.delete(oldest); + } + }; + + const handler: RoomEventHandlerMap[RoomEvent.Timeline] = ( + mEvent, + room, + toStartOfTimeline, + removed + ) => { + if (toStartOfTimeline || removed) return; + if (!room) return; + const eventId = mEvent.getId(); + if (!eventId) return; + + if (recordedRef.current.has(eventId)) return; + + // Leave unrecorded so the rescan picks it up once push rules arrive. + if (!arePushRulesReady(mx)) return; + + const encrypted = isAwaitingDecryption(mEvent); + if (encrypted && decryptingRef.current.has(eventId)) return; + + const evaluate = () => + evaluateNotification( + mx, + room, + mEvent, + mDirectsRef.current, + getNotificationType(mx, room.roomId), + { storeContent: encrypted ? storeEncryptedContentRef.current : storeContentRef.current } + ); + + markRecorded(eventId); + const stored = evaluate(); + if (stored) cache.merge(stored); + + if (!encrypted) return; + + decryptingRef.current.add(eventId); + decryptWatchersRef.current.set( + mEvent, + watchDecryption( + mEvent, + () => { + decryptingRef.current.delete(eventId); + const upgraded = evaluate(); + if (upgraded) cache.merge(upgraded); + }, + () => decryptingRef.current.delete(eventId) + ) + ); + }; + + mx.on(RoomEvent.Timeline, handler); + + // Only advance the watermark while syncing, so an outage isn't treated as "nothing missed". + const beat = () => { + if (mx.getSyncState() === SyncState.Syncing) cache.updateLastSeenTs(Date.now()); + }; + const heartbeatInterval = setInterval(beat, HEARTBEAT_INTERVAL_MS); + + // SlidingSyncSdk assigns client.pushRules without emitting AccountData, so + // this cannot wait on that event. Runs every start because shouldBackfill + // declines whenever the heartbeat kept the gap under its threshold. + const scanOnce = () => { + if (hasScannedRef.current || !arePushRulesReady(mx)) return; + hasScannedRef.current = true; + void runLiveTimelineScan(mx, userId, mDirectsRef.current, contentOptions()).catch( + (err: unknown) => { + logger.warn('live timeline scan failed', err); + } + ); + }; + + const onSync = (state: SyncState) => { + if ( + state !== SyncState.Prepared && + state !== SyncState.Syncing && + state !== SyncState.Catchup + ) { + return; + } + scanOnce(); + + if (hasBackfilledRef.current) return; + hasBackfilledRef.current = true; + const controller = new AbortController(); + backfillControllerRef.current = controller; + void backfillLocalNotifications( + mx, + userId, + contentOptions(), + Date.now(), + controller.signal + ).catch((err: unknown) => { + logger.warn('backfill failed', err); + }); + }; + mx.on(ClientEvent.Sync, onSync); + const currentState = mx.getSyncState(); + if (currentState) onSync(currentState); + + // Covers a later push-rule change. + const onPushRules = (event: MatrixEvent) => { + if (event.getType() !== (EventType.PushRules as string)) return; + scanOnce(); + }; + mx.on(ClientEvent.AccountData, onPushRules); + + const decryptWatchers = decryptWatchersRef.current; + return () => { + mx.off(RoomEvent.Timeline, handler); + mx.off(ClientEvent.Sync, onSync); + mx.off(ClientEvent.AccountData, onPushRules); + clearInterval(heartbeatInterval); + // These hold mx, room and the previous account's cache through their closure. + for (const stop of decryptWatchers.values()) stop(); + decryptWatchers.clear(); + backfillControllerRef.current?.abort(); + backfillControllerRef.current = undefined; + beat(); + }; + }, [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 b18bdab551..e3b0b0bb3e 100644 --- a/src/app/pages/client/client-non-ui/notifications.tsx +++ b/src/app/pages/client/client-non-ui/notifications.tsx @@ -5,7 +5,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import type { RoomEventHandlerMap } from '$types/matrix-sdk'; import { - ClientEvent, MatrixEvent, MatrixEventEvent, MsgType, @@ -57,15 +56,8 @@ import { resolveNotificationPreviewText, } from '$utils/notificationStyle'; import { isMobileOrTablet } from '$utils/platform'; -import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { showToast } from '$state/toast'; -import { arePushRulesReady, evaluateNotification } from '$utils/localNotifications'; -import { getLocalNotificationCache } from '$client/localNotificationCache'; -import { - backfillLocalNotifications, - scheduleLiveTimelineScan, -} from '$utils/localNotificationBackfill'; import { nativeNotificationRepliesAtom, nativeNotificationReplyInFlightAtom, @@ -709,212 +701,6 @@ function registerNativeNotificationListener( }; } -const recorderLogger = createLogger('NotificationRecorder'); -const RECORDED_CAP = 300; -const HEARTBEAT_INTERVAL_MS = 60_000; -const DECRYPT_TIMEOUT_MS = 30_000; - -export function NotificationRecorder() { - const mx = useMatrixClient(); - const mDirects = useAtomValue(mDirectAtom); - const mDirectsRef = useRef(mDirects); - mDirectsRef.current = mDirects; - - const recordedRef = useRef>(new Set()); - const decryptingRef = useRef>(new Set()); - const hasBackfilledRef = useRef(false); - const decryptTimeoutsRef = useRef>>(new Set()); - const backfillControllerRef = useRef(undefined); - const missedBeforePushRulesRef = useRef(false); - const decryptListenersRef = useRef void>>(new Map()); - const hasScannedRef = useRef(false); - const [storeContent] = useSetting(settingsAtom, 'showMessageContentInNotifications'); - const [storeEncryptedContent] = useSetting( - settingsAtom, - 'showMessageContentInEncryptedNotifications' - ); - const storeContentRef = useRef(storeContent); - storeContentRef.current = storeContent; - const storeEncryptedContentRef = useRef(storeContent && storeEncryptedContent); - storeEncryptedContentRef.current = storeContent && storeEncryptedContent; - const prevMxRef = useRef(mx); - if (prevMxRef.current !== mx) { - prevMxRef.current = mx; - recordedRef.current = new Set(); - decryptingRef.current = new Set(); - hasBackfilledRef.current = false; - missedBeforePushRulesRef.current = false; - hasScannedRef.current = false; - } - - useEffect(() => { - const userId = mx.getSafeUserId(); - const cache = getLocalNotificationCache(userId); - - const markRecorded = (eventId: string) => { - recordedRef.current.add(eventId); - if (recordedRef.current.size > RECORDED_CAP) { - const oldest = recordedRef.current.values().next().value; - if (oldest !== undefined) recordedRef.current.delete(oldest); - } - }; - - const handler: RoomEventHandlerMap[RoomEvent.Timeline] = ( - mEvent, - room, - toStartOfTimeline, - removed - ) => { - if (toStartOfTimeline || removed) return; - if (!room) return; - const eventId = mEvent.getId(); - if (!eventId) return; - - if (recordedRef.current.has(eventId)) return; - - // Leave unrecorded so the rescan picks it up once push rules arrive. - if (!arePushRulesReady(mx)) { - missedBeforePushRulesRef.current = true; - return; - } - - if (mEvent.getType() === 'm.room.encrypted' && mEvent.isEncrypted()) { - if (decryptingRef.current.has(eventId)) return; - decryptingRef.current.add(eventId); - markRecorded(eventId); - - const stored = evaluateNotification( - mx, - room, - mEvent, - mDirectsRef.current, - getNotificationType(mx, room.roomId), - { storeContent: storeEncryptedContentRef.current } - ); - if (stored) { - cache.merge(stored); - } - - // Not `once`: Decrypted also fires for a decryption FAILURE, whose clear - // event is an m.bad.encrypted placeholder. Staying subscribed lets the - // SDK's later retry replace it. - const handleDecrypted = () => { - if (mEvent.isDecryptionFailure()) return; - decryptingRef.current.delete(eventId); - const upgraded = evaluateNotification( - mx, - room, - mEvent, - mDirectsRef.current, - getNotificationType(mx, room.roomId), - { storeContent: storeEncryptedContentRef.current } - ); - if (upgraded) cache.merge(upgraded); - mEvent.off(MatrixEventEvent.Decrypted, handleDecrypted); - decryptListenersRef.current.delete(mEvent); - }; - mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted); - decryptListenersRef.current.set(mEvent, handleDecrypted); - - // Stop waiting, but keep the placeholder: a megolm key can still arrive - // later, and deleting the entry loses the notification for good. - const timeoutId = setTimeout(() => { - decryptTimeoutsRef.current.delete(timeoutId); - decryptingRef.current.delete(eventId); - }, DECRYPT_TIMEOUT_MS); - decryptTimeoutsRef.current.add(timeoutId); - return; - } - - const stored = evaluateNotification( - mx, - room, - mEvent, - mDirectsRef.current, - getNotificationType(mx, room.roomId), - { storeContent: storeContentRef.current } - ); - markRecorded(eventId); - if (stored) cache.merge(stored); - }; - - mx.on(RoomEvent.Timeline, handler); - - // Only advance the watermark while syncing, so an outage isn't treated as "nothing missed". - const beat = () => { - if (mx.getSyncState() === SyncState.Syncing) cache.updateLastSeenTs(Date.now()); - }; - const heartbeatInterval = setInterval(beat, HEARTBEAT_INTERVAL_MS); - - // SlidingSyncSdk assigns client.pushRules without emitting AccountData, so - // this cannot wait on that event. Runs every start because shouldBackfill - // declines whenever the heartbeat kept the gap under its threshold. - const scanOnce = () => { - if (hasScannedRef.current || !arePushRulesReady(mx)) return; - hasScannedRef.current = true; - missedBeforePushRulesRef.current = false; - void scheduleLiveTimelineScan(mx, userId, mDirectsRef.current, { - storeContent: storeContentRef.current, - storeEncryptedContent: storeEncryptedContentRef.current, - }).catch((err: unknown) => { - recorderLogger.warn('live timeline scan failed', err); - }); - }; - - const onSync = (state: SyncState) => { - if ( - state !== SyncState.Prepared && - state !== SyncState.Syncing && - state !== SyncState.Catchup - ) { - return; - } - scanOnce(); - - if (hasBackfilledRef.current) return; - hasBackfilledRef.current = true; - const controller = new AbortController(); - backfillControllerRef.current = controller; - void backfillLocalNotifications(mx, userId, Date.now(), controller.signal).catch( - (err: unknown) => { - recorderLogger.warn('backfill failed', err); - } - ); - }; - mx.on(ClientEvent.Sync, onSync); - const currentState = mx.getSyncState(); - if (currentState) onSync(currentState); - - // Covers a later push-rule change. - const onPushRules = (event: MatrixEvent) => { - if (event.getType() !== (EventType.PushRules as string)) return; - scanOnce(); - }; - mx.on(ClientEvent.AccountData, onPushRules); - - const decryptTimeouts = decryptTimeoutsRef.current; - const decryptListeners = decryptListenersRef.current; - return () => { - mx.off(RoomEvent.Timeline, handler); - mx.off(ClientEvent.Sync, onSync); - mx.off(ClientEvent.AccountData, onPushRules); - clearInterval(heartbeatInterval); - for (const id of decryptTimeouts) clearTimeout(id); - decryptTimeouts.clear(); - // These hold mx, room and the previous account's cache through their closure. - for (const [event, listener] of decryptListeners) { - event.off(MatrixEventEvent.Decrypted, listener); - } - decryptListeners.clear(); - backfillControllerRef.current?.abort(); - backfillControllerRef.current = undefined; - beat(); - }; - }, [mx]); - - return null; -} - // Routes taps on native plugin notifications (desktop + iOS) using the `extra` // payload attached in sendNativeTauriNotification. export function NativeNotificationClickRouting() { 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/utils/atomWithLocalStorage.ts b/src/app/state/utils/atomWithLocalStorage.ts index ceb795e268..f942414e4d 100644 --- a/src/app/state/utils/atomWithLocalStorage.ts +++ b/src/app/state/utils/atomWithLocalStorage.ts @@ -1,6 +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 = ['sable.notificationCache.', 'sable.slidingSyncSidebar.']; +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); diff --git a/src/app/utils/groupNotifications.ts b/src/app/utils/groupNotifications.ts index 362292e398..28524f16b7 100644 --- a/src/app/utils/groupNotifications.ts +++ b/src/app/utils/groupNotifications.ts @@ -1,26 +1,22 @@ import { EventType } from '$types/matrix-sdk'; +import type { StoredNotification } from './localNotifications'; -type NotificationEntry = { - event: { type: string }; - room_id: string; -}; - -type RoomNotificationsGroup = { +type RoomNotificationsGroup = { roomId: string; - notifications: N[]; + notifications: StoredNotification[]; }; -export const groupNotifications = ( - notifications: N[], +export const groupNotifications = ( + notifications: StoredNotification[], allowRooms: Set -): RoomNotificationsGroup[] => { - const groups: RoomNotificationsGroup[] = []; +): 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]; + const lastAddedGroup: RoomNotificationsGroup | undefined = groups[groupIndex]; if (notification.room_id === lastAddedGroup?.roomId) { lastAddedGroup.notifications.push(notification); return; diff --git a/src/app/utils/localNotificationBackfill.test.ts b/src/app/utils/localNotificationBackfill.test.ts index 8203aa9e36..24af3c4591 100644 --- a/src/app/utils/localNotificationBackfill.test.ts +++ b/src/app/utils/localNotificationBackfill.test.ts @@ -1,18 +1,15 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import { backfillLocalNotifications, scheduleLiveTimelineScan } from './localNotificationBackfill'; +import { backfillLocalNotifications, runLiveTimelineScan } from './localNotificationBackfill'; import { getLocalNotificationCache, clearLocalNotificationCache, } from '$client/localNotificationCache'; import { MAX_BACKFILL_ROOMS } from './localNotifications'; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - const ROOM_ID = '!active:example.com'; const USER_ID = '@test:example.com'; +const CONTENT = { storeContent: true, storeEncryptedContent: true }; type RoomOverrides = Omit, 'isSpaceRoom'> & { lastActiveTs?: number; @@ -50,17 +47,77 @@ const createEvent = (ts: number, id?: string): Partial => getRelation: () => undefined, }) as unknown as Partial; -// --------------------------------------------------------------------------- -// Setup / teardown -// --------------------------------------------------------------------------- + +type FakeEncryptedEvent = Partial & { + decryptTo: (content: Record) => void; + failDecryption: () => void; +}; + +const createEncryptedEvent = (ts: number, id: string): FakeEncryptedEvent => { + const listeners: (() => void)[] = []; + let type = 'm.room.encrypted'; + let content: Record = { algorithm: 'm.megolm.v1.aes-sha2', ciphertext: 'AAA' }; + let failed = false; + + const event = { + getId: () => id, + getTs: () => ts, + getSender: () => '@other:example.com', + getType: () => type, + getContent: () => content, + isEncrypted: () => true, + isDecryptionFailure: () => failed, + isRedacted: () => false, + isSending: () => false, + getRelation: () => undefined, + on: (_event: string, listener: () => void) => { + listeners.push(listener); + return event; + }, + off: (_event: string, listener: () => void) => { + const index = listeners.indexOf(listener); + if (index !== -1) listeners.splice(index, 1); + return event; + }, + decryptTo: (clear: Record) => { + failed = false; + type = 'm.room.message'; + content = clear; + for (const listener of listeners.slice()) listener(); + }, + failDecryption: () => { + failed = true; + type = 'm.room.message'; + content = { msgtype: 'm.bad.encrypted', body: '** Unable to decrypt **' }; + for (const listener of listeners.slice()) listener(); + }, + }; + + return event as unknown as FakeEncryptedEvent; +}; + +const encryptedClient = (room: Room, notifyFor: (mEvent: MatrixEvent) => boolean): MatrixClient => + ({ + getRooms: () => [room], + getRoom: () => room, + scrollback: async (r: Room) => r, + getAccountData: (type: unknown) => + type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, + getRoomPushRule: () => { + throw new Error('no rule'); + }, + getSafeUserId: () => USER_ID, + pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, + getUserId: () => USER_ID, + pushProcessor: { + actionsForEvent: (mEvent: MatrixEvent) => ({ notify: notifyFor(mEvent), tweaks: {} }), + }, + }) as unknown as MatrixClient; beforeEach(() => { clearLocalNotificationCache(USER_ID); }); -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- describe('backfillLocalNotifications', () => { it('no watermark (new device) → no backfill', async () => { @@ -84,7 +141,7 @@ describe('backfillLocalNotifications', () => { }, } as unknown as MatrixClient; - const recorded = await backfillLocalNotifications(mx, USER_ID); + const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT); expect(recorded).toBe(0); expect(scrollback).not.toHaveBeenCalled(); @@ -115,7 +172,7 @@ describe('backfillLocalNotifications', () => { }, } as unknown as MatrixClient; - const recorded = await backfillLocalNotifications(mx, USER_ID, now); + const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT, now); expect(recorded).toBe(0); expect(scrollback).not.toHaveBeenCalled(); @@ -188,7 +245,7 @@ describe('backfillLocalNotifications', () => { }, } as unknown as MatrixClient; - const recorded = await backfillLocalNotifications(mx, USER_ID, now); + const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT, now); expect(recorded).toBeGreaterThanOrEqual(0); // Only the active (non-space, non-muted) room should be scrolled @@ -235,7 +292,7 @@ describe('backfillLocalNotifications', () => { }, } as unknown as MatrixClient; - await backfillLocalNotifications(mx, USER_ID, now); + await backfillLocalNotifications(mx, USER_ID, CONTENT, now); // (30 rooms × up to 2 pages = up to 60 calls, but unique rooms ≤ 30.) const scrollbackRoomIds = scrollback.mock.calls.map((call) => (call[0] as Room).roomId); @@ -284,7 +341,7 @@ describe('backfillLocalNotifications', () => { }, } as unknown as MatrixClient; - await backfillLocalNotifications(mx, USER_ID, now); + await backfillLocalNotifications(mx, USER_ID, CONTENT, now); expect(scrollback).toHaveBeenCalledTimes(1); }); @@ -329,7 +386,7 @@ describe('backfillLocalNotifications', () => { }, } as unknown as MatrixClient; - await backfillLocalNotifications(mx, uid, now); + await backfillLocalNotifications(mx, uid, CONTENT, now); expect(scrollback).toHaveBeenCalledTimes(1); clearLocalNotificationCache(uid); } @@ -369,7 +426,7 @@ describe('backfillLocalNotifications', () => { }, } as unknown as MatrixClient; - await backfillLocalNotifications(mx, uid, now); + await backfillLocalNotifications(mx, uid, CONTENT, now); expect(scrollback).toHaveBeenCalledTimes(2); clearLocalNotificationCache(uid); } @@ -420,7 +477,7 @@ describe('backfillLocalNotifications', () => { }, } as unknown as MatrixClient; - await backfillLocalNotifications(mx, USER_ID, now); + await backfillLocalNotifications(mx, USER_ID, CONTENT, now); expect(scrollback).not.toHaveBeenCalled(); }); @@ -469,7 +526,7 @@ describe('backfillLocalNotifications', () => { }, } as unknown as MatrixClient; - await backfillLocalNotifications(mx, USER_ID, now); + await backfillLocalNotifications(mx, USER_ID, CONTENT, now); expect(maxInFlight).toBe(1); }); @@ -515,7 +572,7 @@ describe('backfillLocalNotifications', () => { }, } as unknown as MatrixClient; - const recorded = await backfillLocalNotifications(mx, USER_ID, now); + const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT, now); expect(recorded).toBe(3); }); @@ -561,19 +618,87 @@ describe('backfillLocalNotifications', () => { }, } as unknown as MatrixClient; - const recorded = await backfillLocalNotifications(mx, USER_ID, now); + const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT, now); expect(recorded).toBe(1); }); }); -// --------------------------------------------------------------------------- -// recordLiveTimelines — recovers what was dropped before push rules synced -// --------------------------------------------------------------------------- -describe('scheduleLiveTimelineScan', () => { - const CONTENT = { storeContent: true, storeEncryptedContent: true }; +describe('backfillLocalNotifications in encrypted rooms', () => { + const encryptedRoom = (events: Partial[], now: number): Room => + createRoom(ROOM_ID, { + lastActiveTs: now - 5 * 1000, + _events: events, + getLiveTimeline: () => + ({ + getEvents: () => events as MatrixEvent[], + getState: () => ({ getStateEvents: () => ({}) }), + }) as unknown as ReturnType, + }); + + it('records a mention that only becomes visible after decryption', async () => { + const now = Date.now(); + getLocalNotificationCache(USER_ID).updateLastSeenTs(now - 2 * 60 * 60 * 1000); + + const mEvent = createEncryptedEvent(now - 10 * 1000, '$enc'); + const room = encryptedRoom([mEvent], now); + // Ciphertext matches no rule; the mention only shows once decrypted. + const mx = encryptedClient(room, (e) => e.getType() === 'm.room.message'); + + const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT, now); + expect(recorded).toBe(0); + + mEvent.decryptTo({ msgtype: 'm.text', body: 'hey @test' }); + + const entries = getLocalNotificationCache(USER_ID).getEntries(); + expect(entries).toHaveLength(1); + expect(entries[0]!.event.content.body).toBe('hey @test'); + }); + + it('keeps the placeholder when decryption fails', async () => { + const now = Date.now(); + getLocalNotificationCache(USER_ID).updateLastSeenTs(now - 2 * 60 * 60 * 1000); + + const mEvent = createEncryptedEvent(now - 10 * 1000, '$enc'); + const room = encryptedRoom([mEvent], now); + const mx = encryptedClient(room, () => true); + + await backfillLocalNotifications(mx, USER_ID, CONTENT, now); + expect(getLocalNotificationCache(USER_ID).getEntries()).toHaveLength(1); + + mEvent.failDecryption(); + expect(getLocalNotificationCache(USER_ID).getEntries()).toHaveLength(1); + + // The SDK retries once the megolm key arrives. + mEvent.decryptTo({ msgtype: 'm.text', body: 'recovered' }); + const entries = getLocalNotificationCache(USER_ID).getEntries(); + expect(entries).toHaveLength(1); + expect(entries[0]!.event.content.body).toBe('recovered'); + }); + + it('omits the body when encrypted content must not be persisted', async () => { + const now = Date.now(); + getLocalNotificationCache(USER_ID).updateLastSeenTs(now - 2 * 60 * 60 * 1000); + const mEvent = createEvent(now - 10 * 1000, '$clear'); + const room = encryptedRoom([mEvent], now); + const mx = encryptedClient(room, () => true); + + await backfillLocalNotifications( + mx, + USER_ID, + { storeContent: true, storeEncryptedContent: false }, + now + ); + + const entry = getLocalNotificationCache(USER_ID).getEntries()[0]!; + expect(entry.event.content.body).toBeUndefined(); + expect(entry.event.content.msgtype).toBe('m.text'); + }); +}); + +describe('runLiveTimelineScan', () => { const clientWith = (rooms: Room[], withPushRules = true): MatrixClient => ({ getRooms: () => rooms, @@ -597,7 +722,7 @@ describe('scheduleLiveTimelineScan', () => { _events: [createEvent(1000, '$a'), createEvent(2000, '$b')], }); - const recorded = await scheduleLiveTimelineScan( + const recorded = await runLiveTimelineScan( clientWith([room]), USER_ID, new Set(), @@ -612,8 +737,8 @@ describe('scheduleLiveTimelineScan', () => { const room = createRoom(ROOM_ID, { _events: [createEvent(1000, '$a')] }); const mx = clientWith([room]); - await scheduleLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); - await scheduleLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); + await runLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); + await runLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); expect(getLocalNotificationCache(USER_ID).getEntries()).toHaveLength(1); }); @@ -623,9 +748,9 @@ describe('scheduleLiveTimelineScan', () => { const mx = clientWith([room]); const cache = getLocalNotificationCache(USER_ID); - await scheduleLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); + await runLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); cache.dismiss('$a'); - await scheduleLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); + await runLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); expect(cache.getEntries()[0]!.dismissed).toBe(true); }); @@ -633,7 +758,7 @@ describe('scheduleLiveTimelineScan', () => { it('records nothing while push rules are still missing', async () => { const room = createRoom(ROOM_ID, { _events: [createEvent(1000, '$a')] }); - const recorded = await scheduleLiveTimelineScan( + const recorded = await runLiveTimelineScan( clientWith([room], false), USER_ID, new Set(), @@ -649,15 +774,13 @@ describe('scheduleLiveTimelineScan', () => { _events: [createEvent(1000, '$a')], }); - expect(await scheduleLiveTimelineScan(clientWith([space]), USER_ID, new Set(), CONTENT)).toBe( - 0 - ); + expect(await runLiveTimelineScan(clientWith([space]), USER_ID, new Set(), CONTENT)).toBe(0); }); it('omits the body when content must not be persisted', async () => { const room = createRoom(ROOM_ID, { _events: [createEvent(1000, '$a')] }); - await scheduleLiveTimelineScan(clientWith([room]), USER_ID, new Set(), { + await runLiveTimelineScan(clientWith([room]), USER_ID, new Set(), { storeContent: false, storeEncryptedContent: false, }); diff --git a/src/app/utils/localNotificationBackfill.ts b/src/app/utils/localNotificationBackfill.ts index c7bb2e821f..164fc568f1 100644 --- a/src/app/utils/localNotificationBackfill.ts +++ b/src/app/utils/localNotificationBackfill.ts @@ -1,14 +1,16 @@ import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import { ClientEvent, Direction, EventType, MatrixEventEvent } from '$types/matrix-sdk'; +import { ClientEvent, EventType } from '$types/matrix-sdk'; import { NotificationType } from '$types/matrix/room'; import { getLocalNotificationCache } from '$client/localNotificationCache'; import { createLogger } from '$utils/debug'; -import { getAccountData } from '$utils/room/hierarchy'; +import { getAccountData, getStateEvent } from '$utils/room/hierarchy'; import { getMDirects, getNotificationType } from '$utils/room/unread'; import { evaluateNotification, + isAwaitingDecryption, selectBackfillRooms, shouldBackfill, + watchDecryption, backfillPageCount, type BackfillRoomInfo, type StoredNotification, @@ -17,7 +19,6 @@ import { const logger = createLogger('localNotificationBackfill'); const SCROLLBACK_LIMIT = 50; -const DECRYPT_TIMEOUT_MS = 30_000; // Bounds the transient batch; the cache only keeps MAX_ENTRIES anyway. const SCAN_FLUSH_BATCH = 200; @@ -27,15 +28,9 @@ export type ScanContentOptions = { }; const isEncryptedRoom = (room: Room): boolean => - room - .getLiveTimeline() - .getState(Direction.Forward) - ?.getStateEvents(EventType.RoomEncryption, '') !== null; - -// Recovers events that arrived before push rules synced. Reads only what the -// client already holds — no network — and yields between rooms so a large -// account cannot block the main thread through a whole scan. -export const scheduleLiveTimelineScan = async ( + getStateEvent(room, EventType.RoomEncryption) !== undefined; + +export const runLiveTimelineScan = async ( mx: MatrixClient, userId: string, mDirects: Set, @@ -81,6 +76,7 @@ export const scheduleLiveTimelineScan = async ( export const backfillLocalNotifications = async ( mx: MatrixClient, userId: string, + content: ScanContentOptions, now: number = Date.now(), signal?: AbortSignal ): Promise => { @@ -94,7 +90,6 @@ export const backfillLocalNotifications = async ( return 0; } - // Build BackfillRoomInfo from all rooms the client knows about. const allRooms = mx.getRooms(); const roomInfos: BackfillRoomInfo[] = allRooms.map((room) => ({ roomId: room.roomId, @@ -104,9 +99,13 @@ export const backfillLocalNotifications = async ( })); const selectedRoomIds = selectBackfillRooms(roomInfos, watermark); const pages = backfillPageCount(watermark, now); + const activeRooms = roomInfos.filter( + (r) => !r.isSpaceRoom && !r.isMuted && r.lastActiveTs > watermark + ).length; logger.log('backfill starting', { rooms: selectedRoomIds.length, + skippedRooms: activeRooms - selectedRoomIds.length, pages, gapMs: now - watermark, }); @@ -134,12 +133,22 @@ export const backfillLocalNotifications = async ( let recorded = 0; const processed = new Set(); + const stopWatchers: (() => void)[] = []; + const releaseWatchers = () => { + for (const stop of stopWatchers) stop(); + stopWatchers.length = 0; + }; + signal?.addEventListener('abort', releaseWatchers, { once: true }); + for (const roomId of selectedRoomIds) { if (signal?.aborted) return recorded; const room = mx.getRoom(roomId); if (!room) continue; const notificationType = getNotificationType(mx, roomId); if (notificationType === NotificationType.Mute) continue; + const storeContent = isEncryptedRoom(room) + ? content.storeEncryptedContent + : content.storeContent; try { for (let page = 0; page < pages; page += 1) { @@ -154,33 +163,24 @@ export const backfillLocalNotifications = async ( const eventId = mEvent.getId(); if (!eventId || processed.has(eventId)) continue; processed.add(eventId); - const stored = evaluateNotification(mx, room, mEvent, mDirects, notificationType); + const evaluate = () => + evaluateNotification(mx, room, mEvent, mDirects, notificationType, { storeContent }); + + const stored = evaluate(); if (stored) { cache.merge(stored); recorded += 1; - if (mEvent.getType() === 'm.room.encrypted' && mEvent.isEncrypted()) { - const handleDecrypted = () => { - const upgraded = evaluateNotification(mx, room, mEvent, mDirects, notificationType); + } + + // Also watch events that did not notify as ciphertext: a mention is + // only visible once the clear event arrives. + if (isAwaitingDecryption(mEvent)) { + stopWatchers.push( + watchDecryption(mEvent, () => { + const upgraded = evaluate(); if (upgraded) cache.merge(upgraded); - else cache.remove(eventId); - }; - mEvent.once(MatrixEventEvent.Decrypted, handleDecrypted); - const timeoutId = setTimeout(() => { - mEvent.off(MatrixEventEvent.Decrypted, handleDecrypted); - }, DECRYPT_TIMEOUT_MS); - if (signal) { - if (signal.aborted) mEvent.off(MatrixEventEvent.Decrypted, handleDecrypted); - else - signal.addEventListener( - 'abort', - () => { - clearTimeout(timeoutId); - mEvent.off(MatrixEventEvent.Decrypted, handleDecrypted); - }, - { once: true } - ); - } - } + }) + ); } } diff --git a/src/app/utils/localNotifications.test.ts b/src/app/utils/localNotifications.test.ts index d4cdd37d7e..6eaf31b6e7 100644 --- a/src/app/utils/localNotifications.test.ts +++ b/src/app/utils/localNotifications.test.ts @@ -17,9 +17,6 @@ const USER_ID = '@user:example.com'; const OTHER_USER = '@other:example.com'; const EVENT_ID = '$event1'; -// --------------------------------------------------------------------------- -// Mock helpers — follow the hand-rolled pattern from room.unread.test.ts -// --------------------------------------------------------------------------- const createEvent = (overrides: Partial = {}): MatrixEvent => ({ @@ -71,9 +68,6 @@ const createClient = ( }, }) as unknown as MatrixClient; -// --------------------------------------------------------------------------- -// evaluateNotification exclusions -// --------------------------------------------------------------------------- describe('arePushRulesReady', () => { it('is false before push rules have synced', () => { @@ -218,9 +212,6 @@ describe('evaluateNotification exclusions', () => { }); }); -// --------------------------------------------------------------------------- -// evaluateNotification inclusions -// --------------------------------------------------------------------------- describe('evaluateNotification inclusions', () => { it('returns StoredNotification for normal message with notify=true', () => { @@ -349,9 +340,6 @@ describe('evaluateNotification inclusions', () => { }); }); -// --------------------------------------------------------------------------- -// sliceNotificationPage -// --------------------------------------------------------------------------- describe('sliceNotificationPage', () => { const makeItem = ( @@ -449,10 +437,6 @@ describe('sliceNotificationPage', () => { }); }); -// --------------------------------------------------------------------------- -// isStoredNotificationRead -// --------------------------------------------------------------------------- - describe('isStoredNotificationRead', () => { const entry = (ts = 1000): StoredNotification => ({ room_id: ROOM_ID, event: { event_id: EVENT_ID }, ts }) as StoredNotification; @@ -497,8 +481,6 @@ describe('isStoredNotificationRead', () => { expect(isStoredNotificationRead(room, USER_ID, entry(9000))).toBe(false); }); - // Under sliding sync an entry outside the loaded window is the normal case, - // so it must not be hidden just because nothing resolves. it('treats an unresolvable entry as unread', () => { const room = readStateRoom({}); diff --git a/src/app/utils/localNotifications.ts b/src/app/utils/localNotifications.ts index 7aa0062846..aea8c78ebf 100644 --- a/src/app/utils/localNotifications.ts +++ b/src/app/utils/localNotifications.ts @@ -1,5 +1,6 @@ + import type { IContent, IEvent, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import { ReceiptType } from '$types/matrix-sdk'; +import { EventType, MatrixEventEvent, ReceiptType } from '$types/matrix-sdk'; import { NotificationType } from '$types/matrix/room'; import { isDMRoom, isNotificationEvent } from './room/unread'; @@ -14,9 +15,6 @@ export type StoredNotification = { export const MAX_BODY_LENGTH = 120; -// HTML cannot be sliced without breaking tags, so oversized messages lose it. -// Ciphertext is dropped: multi-KB, shares the session's localStorage budget, and -// is never rendered. Always copies — the SDK mutates `content` in place. const truncateContent = (content: IContent, storeContent: boolean): IContent => { if (content.ciphertext !== undefined) { return typeof content.algorithm === 'string' ? { algorithm: content.algorithm } : {}; @@ -42,8 +40,6 @@ const truncateContent = (content: IContent, storeContent: boolean): IContent => return truncated; }; -// Defaults to public receipts only, and markAsRead sends a private one when -// hideReads is on. const latestReceiptTs = (room: Room, userId: string): number | undefined => { const timestamps = [ReceiptType.Read, ReceiptType.ReadPrivate] .map((type) => room.getReadReceiptForUserId(userId, false, type)?.data?.ts) @@ -57,26 +53,17 @@ export const isStoredNotificationRead = ( userId: string, entry: StoredNotification ): boolean => { - // hasUserReadEvent warns for events missing from the timeline, so only - // consult it while the event is known. if (room.findEventById(entry.event.event_id)) { return room.hasUserReadEvent(userId, entry.event.event_id); } - // Outside the loaded window, which under sliding sync is routine. Compare - // against the receipt timestamp and default to UNREAD, so a notification is - // never hidden just because its event is not in memory. const receiptTs = latestReceiptTs(room, userId); if (receiptTs === undefined) return false; return entry.ts <= receiptTs; }; -// actionsForEvent returns {} rather than throwing before push rules sync, which -// reads as "do not notify". export const arePushRulesReady = (mx: MatrixClient): boolean => mx.pushRules?.global !== undefined; -// The DM override below force-notifies anything in a DM. These types are -// explicitly dont_notify by push rule and must not be resurrected by it. const DM_OVERRIDE_EXCLUDED = new Set(['m.reaction', 'm.room.create']); export type EvaluateOptions = { @@ -152,6 +139,29 @@ export const evaluateNotification = ( }; }; +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, @@ -169,9 +179,6 @@ export const sliceNotificationPage = ( return { page, nextToken }; }; -// --------------------------------------------------------------------------- -// Gap backfill — pure decision logic. Testable without network. -// --------------------------------------------------------------------------- export const GAP_THRESHOLD_MS = 5 * 60 * 1000; export const MAX_BACKFILL_ROOMS = 30; diff --git a/src/app/utils/throttleTrailing.ts b/src/app/utils/throttleTrailing.ts new file mode 100644 index 0000000000..fde113684f --- /dev/null +++ b/src/app/utils/throttleTrailing.ts @@ -0,0 +1,32 @@ +export type Throttled = (() => void) & { cancel: () => void }; + +/** Runs immediately when idle, otherwise once more at the end of the window. */ +export const throttleTrailing = (fn: () => void, waitMs: number): Throttled => { + let trailing: ReturnType | undefined; + let lastRun = 0; + + const run = () => { + lastRun = Date.now(); + fn(); + }; + + const throttled = () => { + const elapsed = Date.now() - lastRun; + if (elapsed >= waitMs) { + run(); + return; + } + if (trailing !== undefined) return; + trailing = setTimeout(() => { + trailing = undefined; + run(); + }, waitMs - elapsed); + }; + + throttled.cancel = () => { + clearTimeout(trailing); + trailing = undefined; + }; + + return throttled; +}; diff --git a/src/client/localNotificationCache.test.ts b/src/client/localNotificationCache.test.ts index bd8c1a05ec..2e1dc85d43 100644 --- a/src/client/localNotificationCache.test.ts +++ b/src/client/localNotificationCache.test.ts @@ -28,11 +28,6 @@ const makeEntry = ( dismissed, }); -/** - * Every cache instance registers a `storage` listener and can hold a pending - * debounced write, both of which would bleed into later tests under the same - * storage key. Track them and tear them down. - */ const caches: LocalNotificationCache[] = []; const openCache = (id: string): LocalNotificationCache => { const cache = new LocalNotificationCache(id); @@ -256,10 +251,6 @@ describe('LocalNotificationCache', () => { }); }); -// --------------------------------------------------------------------------- -// mergeMany / countEntries — a batched scan must not notify per entry -// --------------------------------------------------------------------------- - describe('LocalNotificationCache batching', () => { it('notifies once for a whole batch', () => { const cache = openCache(userId); diff --git a/src/client/localNotificationCache.ts b/src/client/localNotificationCache.ts index 4cf858df29..23ed57ef90 100644 --- a/src/client/localNotificationCache.ts +++ b/src/client/localNotificationCache.ts @@ -1,5 +1,7 @@ import type { StoredNotification } from '$utils/localNotifications'; +export const NOTIFICATION_CACHE_KEY_PREFIX = 'sable.notificationCache.'; + const CACHE_VERSION = 1; const MAX_ENTRIES = 300; const CACHE_WRITE_DELAY_MS = 500; @@ -12,6 +14,9 @@ type CacheData = { lastSeenTs?: number; }; +const storageKeyFor = (userId: string): string => + `${NOTIFICATION_CACHE_KEY_PREFIX}v${CACHE_VERSION}.${encodeURIComponent(userId)}`; + const emptyCache = (): CacheData => ({ version: CACHE_VERSION, entries: [] }); const parseCache = (value: string | null): CacheData => { @@ -55,7 +60,7 @@ export class LocalNotificationCache { constructor(userId: string) { this.userId = userId; - this.storageKey = `sable.notificationCache.v1.${encodeURIComponent(userId)}`; + this.storageKey = storageKeyFor(userId); this.data = this.readStored(); window.addEventListener('storage', this.onStorageEvent); } @@ -297,7 +302,7 @@ export class LocalNotificationCache { export function clearLocalNotificationCache(userId: string): void { try { - globalThis.localStorage?.removeItem(`sable.notificationCache.v1.${encodeURIComponent(userId)}`); + globalThis.localStorage?.removeItem(storageKeyFor(userId)); } catch { // Storage can be disabled for this origin; logout must continue regardless. } diff --git a/src/client/slidingSyncSidebarCache.ts b/src/client/slidingSyncSidebarCache.ts index 8bdcff1d8e..10c1a03d45 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,7 @@ 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 +184,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; From 91462a9596074cef5ad2d24bb93b8d6f46295bc0 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 8 Aug 2026 23:17:42 -0500 Subject: [PATCH 5/6] fix: expand cache, load from all rooms, ui changes, cleanup stuff --- .../hooks/useInboxNotificationCount.test.ts | 23 + .../hooks/useInboxNotificationCount.test.tsx | 187 ---- src/app/hooks/useInboxNotificationCount.ts | 117 +-- ...eLocalNotificationTimeline.render.test.tsx | 133 --- .../useLocalNotificationTimeline.test.ts | 80 -- src/app/hooks/useLocalNotificationTimeline.ts | 229 +++-- .../client-non-ui/notificationRecorder.tsx | 188 +--- .../client/client-non-ui/notifications.tsx | 55 +- src/app/pages/client/inbox/Notifications.tsx | 474 +++++---- src/app/utils/groupNotifications.ts | 30 - .../utils/localNotificationBackfill.test.ts | 904 +++--------------- src/app/utils/localNotificationBackfill.ts | 286 +++--- src/app/utils/localNotifications.test.ts | 594 ++---------- src/app/utils/localNotifications.ts | 50 +- src/app/utils/notifications.test.ts | 31 +- src/app/utils/notifications.ts | 12 +- src/app/utils/throttleTrailing.ts | 32 - src/client/localNotificationCache.test.ts | 329 +------ src/client/localNotificationCache.ts | 316 ++---- 19 files changed, 963 insertions(+), 3107 deletions(-) create mode 100644 src/app/hooks/useInboxNotificationCount.test.ts delete mode 100644 src/app/hooks/useInboxNotificationCount.test.tsx delete mode 100644 src/app/hooks/useLocalNotificationTimeline.render.test.tsx delete mode 100644 src/app/hooks/useLocalNotificationTimeline.test.ts delete mode 100644 src/app/utils/groupNotifications.ts delete mode 100644 src/app/utils/throttleTrailing.ts 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.test.tsx b/src/app/hooks/useInboxNotificationCount.test.tsx deleted file mode 100644 index 02920a818e..0000000000 --- a/src/app/hooks/useInboxNotificationCount.test.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { TypedEventEmitter } from 'matrix-js-sdk/lib/models/typed-event-emitter'; -import type { MatrixClient, Room } from '$types/matrix-sdk'; -import { RoomEvent } from '$types/matrix-sdk'; -import type { StoredNotification } from '$utils/localNotifications'; -import { - clearLocalNotificationCache, - destroyLocalNotificationCache, - getLocalNotificationCache, -} from '$client/localNotificationCache'; -import { useInboxNotificationCount } from './useInboxNotificationCount'; - -const USER_ID = '@user:example.com'; -const ROOM_ID = '!room:example.com'; - -type RoomBehaviour = { - read?: boolean; - known?: boolean; - receiptTs?: number; -}; - -const createRoom = ({ read = false, known = true, receiptTs }: RoomBehaviour = {}): Room => - ({ - roomId: ROOM_ID, - findEventById: (id: string) => - known && !id.startsWith('$receipt') ? { getTs: () => 1 } : undefined, - hasUserReadEvent: () => read, - getReadReceiptForUserId: () => - receiptTs === undefined ? null : { eventId: '$receipt', data: { ts: receiptTs } }, - }) as unknown as Room; - -let currentRoom: Room | undefined = createRoom(); - -const emitter = new TypedEventEmitter void>>(); -const makeClient = () => - Object.assign(Object.create(Object.getPrototypeOf(emitter) as object), emitter, { - getSafeUserId: () => USER_ID, - getRoom: () => currentRoom, - }) as unknown as MatrixClient; - -let mockClient = makeClient(); - -vi.mock('$hooks/useMatrixClient', () => ({ - useMatrixClient: () => mockClient, -})); - -const entry = ( - eventId: string, - overrides: Partial = {} -): StoredNotification => ({ - room_id: ROOM_ID, - event: { - event_id: eventId, - type: 'm.room.message', - content: { body: eventId, msgtype: 'm.text' }, - sender: '@other:example.com', - origin_server_ts: 1000, - room_id: ROOM_ID, - unsigned: {}, - }, - ts: 1000, - highlight: true, - isDM: false, - ...overrides, -}); - -beforeEach(() => { - localStorage.clear(); - currentRoom = createRoom(); - emitter.removeAllListeners(); - mockClient = makeClient(); -}); - -afterEach(() => { - destroyLocalNotificationCache(USER_ID); - clearLocalNotificationCache(USER_ID); - localStorage.clear(); -}); - -describe('useInboxNotificationCount', () => { - it('counts an unread mention', async () => { - getLocalNotificationCache(USER_ID).merge(entry('$a')); - - const { result } = renderHook(() => useInboxNotificationCount()); - - await waitFor(() => expect(result.current).toBe(1)); - }); - - it('counts an unread DM even without a highlight', async () => { - getLocalNotificationCache(USER_ID).merge(entry('$a', { highlight: false, isDM: true })); - - const { result } = renderHook(() => useInboxNotificationCount()); - - await waitFor(() => expect(result.current).toBe(1)); - }); - - it('ignores an entry that is neither a highlight nor a DM', async () => { - getLocalNotificationCache(USER_ID).merge(entry('$a', { highlight: false, isDM: false })); - - const { result } = renderHook(() => useInboxNotificationCount()); - - await waitFor(() => expect(result.current).toBe(0)); - }); - - it('ignores a dismissed entry', async () => { - getLocalNotificationCache(USER_ID).merge(entry('$a', { dismissed: true })); - - const { result } = renderHook(() => useInboxNotificationCount()); - - await waitFor(() => expect(result.current).toBe(0)); - }); - - it('ignores an entry the user has already read', async () => { - currentRoom = createRoom({ read: true }); - getLocalNotificationCache(USER_ID).merge(entry('$a')); - - const { result } = renderHook(() => useInboxNotificationCount()); - - await waitFor(() => expect(result.current).toBe(0)); - }); - - // Under sliding sync an entry outside the loaded window is routine; it must - // still badge rather than being treated as read. - it('counts an entry whose event is outside the loaded timeline', async () => { - currentRoom = createRoom({ known: false }); - getLocalNotificationCache(USER_ID).merge(entry('$a')); - - const { result } = renderHook(() => useInboxNotificationCount()); - - await waitFor(() => expect(result.current).toBe(1)); - }); - - it('stops counting once a receipt covers an aged-out entry', async () => { - currentRoom = createRoom({ known: false, receiptTs: 5000 }); - getLocalNotificationCache(USER_ID).merge(entry('$a')); - - const { result } = renderHook(() => useInboxNotificationCount()); - - await waitFor(() => expect(result.current).toBe(0)); - }); - - it('picks up a notification recorded after mount', async () => { - const { result } = renderHook(() => useInboxNotificationCount()); - await waitFor(() => expect(result.current).toBe(0)); - - await act(async () => { - getLocalNotificationCache(USER_ID).merge(entry('$late')); - await new Promise((resolve) => setTimeout(resolve, 600)); - }); - - await waitFor(() => expect(result.current).toBe(1)); - }); - - it('shares one subscription across consumers', async () => { - getLocalNotificationCache(USER_ID).merge(entry('$a')); - - const first = renderHook(() => useInboxNotificationCount()); - const second = renderHook(() => useInboxNotificationCount()); - - await waitFor(() => expect(first.result.current).toBe(1)); - expect(second.result.current).toBe(1); - expect(emitter.listenerCount(RoomEvent.Receipt)).toBe(1); - - first.unmount(); - expect(emitter.listenerCount(RoomEvent.Receipt)).toBe(1); - - const third = renderHook(() => useInboxNotificationCount()); - expect(emitter.listenerCount(RoomEvent.Receipt)).toBe(1); - - second.unmount(); - third.unmount(); - }); - - it('rebuilds the store when the client is replaced', async () => { - getLocalNotificationCache(USER_ID).merge(entry('$a')); - - const first = renderHook(() => useInboxNotificationCount()); - await waitFor(() => expect(first.result.current).toBe(1)); - first.unmount(); - - mockClient = makeClient(); - const second = renderHook(() => useInboxNotificationCount()); - - await waitFor(() => expect(second.result.current).toBe(1)); - }); -}); diff --git a/src/app/hooks/useInboxNotificationCount.ts b/src/app/hooks/useInboxNotificationCount.ts index 46afbebd97..6daf25efba 100644 --- a/src/app/hooks/useInboxNotificationCount.ts +++ b/src/app/hooks/useInboxNotificationCount.ts @@ -1,100 +1,29 @@ -import { useSyncExternalStore } from 'react'; -import type { MatrixClient, RoomEventHandlerMap } from '$types/matrix-sdk'; -import { RoomEvent } from '$types/matrix-sdk'; +import { useAtomValue } from 'jotai'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { getLocalNotificationCache } from '$client/localNotificationCache'; -import { isStoredNotificationRead, type StoredNotification } from '$utils/localNotifications'; -import { throttleTrailing, type Throttled } from '$utils/throttleTrailing'; - -const RECOMPUTE_THROTTLE_MS = 500; - -type ReceiptContent = Record>>; - -// Counting costs a room and timeline lookup per entry, so consumers share one -// subscription and one receipt listener, throttled. -class InboxCountStore { - private count = 0; - private readonly subscribers = new Set<() => void>(); - private readonly schedule: Throttled; - private detach: (() => void) | undefined; - - constructor( - readonly mx: MatrixClient, - private readonly userId: string - ) { - this.schedule = throttleTrailing(this.recompute, RECOMPUTE_THROTTLE_MS); - } - - getSnapshot = (): number => this.count; - - subscribe = (onChange: () => void): (() => void) => { - this.subscribers.add(onChange); - if (this.subscribers.size === 1) this.attach(); - - return () => { - this.subscribers.delete(onChange); - if (this.subscribers.size === 0) this.teardown(); - }; - }; - - private isUnreadMention = (entry: StoredNotification): boolean => { - if (entry.dismissed) return false; - if (!entry.highlight && !entry.isDM) return false; - - const room = this.mx.getRoom(entry.room_id); - if (!room) return false; - - return !isStoredNotificationRead(room, this.userId, entry); - }; - - private recompute = (): void => { - const next = getLocalNotificationCache(this.userId).countEntries(this.isUnreadMention); - if (next === this.count) return; - this.count = next; - for (const onChange of this.subscribers) onChange(); - }; - - private onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event) => { - const content = event.getContent(); - const readByUs = Object.values(content).some((byType) => - Object.values(byType).some((receipts) => this.userId in receipts) - ); - if (readByUs) this.schedule(); - }; - - private attach(): void { - const unsubscribe = getLocalNotificationCache(this.userId).subscribe(this.schedule); - this.mx.on(RoomEvent.Receipt, this.onReceipt); - this.detach = () => { - unsubscribe(); - this.mx.off(RoomEvent.Receipt, this.onReceipt); - }; - this.recompute(); - } - - private teardown(): void { - this.detach?.(); - this.detach = undefined; - this.schedule.cancel(); - stores.delete(this.userId); - } -} - -const stores = new Map(); - -const getStore = (mx: MatrixClient, userId: string): InboxCountStore => { - const existing = stores.get(userId); - // The old client's getRoom() answers null for everything, i.e. a zero count. - if (existing && existing.mx === mx) return existing; - - const store = new InboxCountStore(mx, userId); - stores.set(userId, store); - return store; -}; +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 store = getStore(mx, mx.getSafeUserId()); + const roomIds = useAtomValue(allRoomsAtom); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const mDirects = useAtomValue(mDirectAtom); - return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + const rooms = roomIds.flatMap((roomId) => mx.getRoom(roomId) ?? []); + return countInboxNotifications(rooms, roomToUnread, mDirects); }; diff --git a/src/app/hooks/useLocalNotificationTimeline.render.test.tsx b/src/app/hooks/useLocalNotificationTimeline.render.test.tsx deleted file mode 100644 index 400c04e925..0000000000 --- a/src/app/hooks/useLocalNotificationTimeline.render.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { TypedEventEmitter } from 'matrix-js-sdk/lib/models/typed-event-emitter'; -import type { StoredNotification } from '$utils/localNotifications'; -import { - clearLocalNotificationCache, - destroyLocalNotificationCache, - getLocalNotificationCache, -} from '$client/localNotificationCache'; -import { useLocalNotificationTimeline } from './useLocalNotificationTimeline'; - -const USER_ID = '@user:example.com'; -const ROOM_ID = '!room:example.com'; - -const emitter = new TypedEventEmitter void>>(); -const mockClient = Object.assign(emitter, { getSafeUserId: () => USER_ID }); - -vi.mock('$hooks/useMatrixClient', () => ({ - useMatrixClient: () => mockClient, -})); - -vi.mock('$state/room-list/roomList', () => ({ - allRoomsAtom: { toString: () => 'allRoomsAtom' }, -})); - -vi.mock('jotai', () => ({ - useAtomValue: () => [ROOM_ID], -})); - -const entry = (eventId: string, ts: number): StoredNotification => ({ - room_id: ROOM_ID, - event: { - event_id: eventId, - type: 'm.room.message', - content: { body: eventId, msgtype: 'm.text' }, - sender: '@other:example.com', - origin_server_ts: ts, - room_id: ROOM_ID, - unsigned: {}, - }, - ts, - highlight: true, - isDM: false, -}); - -const seed = (count: number) => { - const cache = getLocalNotificationCache(USER_ID); - cache.mergeMany(Array.from({ length: count }, (_, i) => entry(`$e${i}`, 1000 + i))); - return cache; -}; - -beforeEach(() => { - localStorage.clear(); - emitter.removeAllListeners(); -}); - -afterEach(() => { - destroyLocalNotificationCache(USER_ID); - clearLocalNotificationCache(USER_ID); - localStorage.clear(); -}); - -describe('useLocalNotificationTimeline render behaviour', () => { - it('settles instead of re-rendering forever when the cache keeps changing', async () => { - const cache = seed(60); - let renders = 0; - - const { result } = renderHook(() => { - renders += 1; - return useLocalNotificationTimeline(24, 'all'); - }); - - await act(async () => { - await result.current[1](); - }); - const afterFirstLoad = renders; - - await act(async () => { - for (let i = 0; i < 10; i += 1) { - cache.merge(entry(`$new${i}`, 5000 + i)); - } - await new Promise((resolve) => setTimeout(resolve, 600)); - }); - - expect(renders - afterFirstLoad).toBeLessThan(10); - }); - - it('does not rewind the loaded window when the cache changes', async () => { - const cache = seed(60); - - const { result } = renderHook(() => useLocalNotificationTimeline(24, 'all')); - - await act(async () => { - await result.current[1](); - }); - const firstPage = result.current[0].groups[0]!.notifications.length; - expect(result.current[0].nextToken).toBe('24'); - - await act(async () => { - await result.current[1](result.current[0].nextToken); - }); - const secondPage = result.current[0].groups[0]!.notifications.length; - expect(secondPage).toBeGreaterThan(firstPage); - - // A silent reload used to reset back to a single page. - await act(async () => { - cache.merge(entry('$live', 9000)); - await new Promise((resolve) => setTimeout(resolve, 600)); - }); - - await waitFor(() => - expect(result.current[0].groups[0]!.notifications.length).toBeGreaterThanOrEqual(secondPage) - ); - }); - - it('keeps the same timeline object when nothing changed', async () => { - const cache = seed(5); - - const { result } = renderHook(() => useLocalNotificationTimeline(24, 'all')); - - await act(async () => { - await result.current[1](); - }); - const before = result.current[0]; - - await act(async () => { - cache.mergeMany([entry('$e0', 1000)]); - await new Promise((resolve) => setTimeout(resolve, 600)); - }); - - expect(result.current[0]).toBe(before); - }); -}); diff --git a/src/app/hooks/useLocalNotificationTimeline.test.ts b/src/app/hooks/useLocalNotificationTimeline.test.ts deleted file mode 100644 index 6d0a174afa..0000000000 --- a/src/app/hooks/useLocalNotificationTimeline.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { StoredNotification } from '$utils/localNotifications'; -import { sameNotificationTimeline } from './useLocalNotificationTimeline'; - -const entry = (eventId: string, overrides: Partial = {}): StoredNotification => - ({ - room_id: '!room:example.com', - event: { event_id: eventId, type: 'm.room.message' }, - ts: 1000, - highlight: false, - isDM: false, - ...overrides, - }) as StoredNotification; - -const timeline = (nextToken: string | undefined, notifications: StoredNotification[]) => ({ - nextToken, - groups: notifications.length ? [{ roomId: '!room:example.com', notifications }] : [], -}); - -describe('sameNotificationTimeline', () => { - it('treats a freshly recomputed but identical timeline as unchanged', () => { - const a = timeline('24', [entry('$1'), entry('$2')]); - const b = timeline('24', [entry('$1'), entry('$2')]); - - expect(sameNotificationTimeline(a, b)).toBe(true); - }); - - it('detects a new notification', () => { - const a = timeline('24', [entry('$1')]); - const b = timeline('24', [entry('$1'), entry('$2')]); - - expect(sameNotificationTimeline(a, b)).toBe(false); - }); - - it('detects pagination advancing', () => { - const a = timeline('24', [entry('$1')]); - const b = timeline('48', [entry('$1')]); - - expect(sameNotificationTimeline(a, b)).toBe(false); - }); - - it('detects a notification being dismissed', () => { - const a = timeline('24', [entry('$1')]); - const b = timeline('24', [entry('$1', { dismissed: true })]); - - expect(sameNotificationTimeline(a, b)).toBe(false); - }); - - it('detects an encrypted snapshot being replaced by its decrypted one', () => { - const a = timeline('24', [ - entry('$1', { event: { event_id: '$1', type: 'm.room.encrypted' } as never }), - ]); - const b = timeline('24', [entry('$1')]); - - expect(sameNotificationTimeline(a, b)).toBe(false); - }); - - it('detects reordering across rooms', () => { - const a = { - nextToken: '24', - groups: [ - { roomId: '!a:example.com', notifications: [entry('$1')] }, - { roomId: '!b:example.com', notifications: [entry('$2')] }, - ], - }; - const b = { - nextToken: '24', - groups: [ - { roomId: '!b:example.com', notifications: [entry('$2')] }, - { roomId: '!a:example.com', notifications: [entry('$1')] }, - ], - }; - - expect(sameNotificationTimeline(a, b)).toBe(false); - }); - - it('treats two empty timelines as unchanged', () => { - expect(sameNotificationTimeline(timeline(undefined, []), timeline(undefined, []))).toBe(true); - }); -}); diff --git a/src/app/hooks/useLocalNotificationTimeline.ts b/src/app/hooks/useLocalNotificationTimeline.ts index 53453554b2..0681ebe3e3 100644 --- a/src/app/hooks/useLocalNotificationTimeline.ts +++ b/src/app/hooks/useLocalNotificationTimeline.ts @@ -3,114 +3,153 @@ 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 { sliceNotificationPage, type StoredNotification } from '$utils/localNotifications'; -import { groupNotifications } from '$utils/groupNotifications'; -import { throttleTrailing } from '$utils/throttleTrailing'; +import { backfillLocalNotifications } from '$utils/localNotificationBackfill'; +import { + isStoredNotificationRead, + sliceNotificationPage, + type NotificationTab, + type StoredNotification, +} from '$utils/localNotifications'; -type RoomNotificationsGroup = { - roomId: string; - notifications: StoredNotification[]; +export type NotificationQuery = { + tab: NotificationTab; + includeRead: boolean; + limit: number; }; -type NotificationTimeline = { - nextToken?: string; - groups: RoomNotificationsGroup[]; -}; -const RELOAD_THROTTLE_MS = 500; - -type LoadTimeline = (from?: string) => Promise; - -export const sameNotificationTimeline = ( - a: NotificationTimeline, - b: NotificationTimeline -): boolean => { - if (a.nextToken !== b.nextToken || a.groups.length !== b.groups.length) return false; - return a.groups.every((group, i) => { - const other = b.groups[i]; - if (!other) return false; - if (group.roomId !== other.roomId) return false; - if (group.notifications.length !== other.notifications.length) return false; - - return group.notifications.every((notification, j) => { - const otherNotification = other.notifications[j]; - if (!otherNotification) return false; - return ( - notification.event.event_id === otherNotification.event.event_id && - // Changes when an encrypted snapshot is replaced by its decrypted one. - notification.event.type === otherNotification.event.type && - notification.dismissed === otherNotification.dismissed - ); - }); - }); +export type NotificationPage = { + items: StoredNotification[]; + canLoadOlder: boolean; }; -export const useLocalNotificationTimeline = ( - paginationLimit: number, - filterMode: 'all' | 'mentions' = 'mentions', - includeDone?: boolean -): [NotificationTimeline, LoadTimeline] => { +export const useLocalNotificationTimeline = (query: NotificationQuery) => { const mx = useMatrixClient(); - const allRooms = useAtomValue(allRoomsAtom); - const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); - - const [notificationTimeline, setNotificationTimeline] = useState({ - groups: [], - }); - // Re-render on our own read receipts so the per-notification unread dots follow - // them; the timeline itself is unchanged, so this must not touch it. - const [, bumpReceiptVersion] = useState(0); - - const cache = getLocalNotificationCache(mx.getSafeUserId()); - const loadedLimitRef = useRef(paginationLimit); - - // Always recomputed from offset 0 over a growing window, so a reload cannot - // rewind pagination and re-trigger the caller's load-more effect. - const applyUpTo = useCallback( - (limit: number) => { - const allEntries = cache.getEntries().filter((entry) => allJoinedRooms.has(entry.room_id)); - const { page, nextToken } = sliceNotificationPage( - allEntries, - 0, - limit, - filterMode, - includeDone - ); - const next: NotificationTimeline = { - nextToken, - groups: groupNotifications(page, allJoinedRooms), - }; - setNotificationTimeline((current) => - sameNotificationTimeline(current, next) ? current : next - ); - }, - [cache, filterMode, includeDone, allJoinedRooms] - ); - - const loadTimeline: LoadTimeline = useCallback( - async (from) => { - const limit = from ? Number(from) + paginationLimit : paginationLimit; - loadedLimitRef.current = limit; - applyUpTo(limit); - }, - [applyUpTo, paginationLimit] + 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(() => { - const reload = throttleTrailing(() => { - applyUpTo(loadedLimitRef.current); - bumpReceiptVersion((v) => v + 1); - }, RELOAD_THROTTLE_MS); - - const unsubscribe = cache.subscribe(reload); - mx.on(RoomEvent.Receipt, reload); + 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, reload); - reload.cancel(); + 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, }; - }, [mx, cache, applyUpTo]); + }, [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 [notificationTimeline, loadTimeline]; + return { page, loadingOlder, error, refresh, loadOlder }; }; diff --git a/src/app/pages/client/client-non-ui/notificationRecorder.tsx b/src/app/pages/client/client-non-ui/notificationRecorder.tsx index a079cb8bf9..167d3ccbd5 100644 --- a/src/app/pages/client/client-non-ui/notificationRecorder.tsx +++ b/src/app/pages/client/client-non-ui/notificationRecorder.tsx @@ -1,190 +1,56 @@ import { useAtomValue } from 'jotai'; import { useEffect, useRef } from 'react'; -import type { MatrixEvent, RoomEventHandlerMap } from '$types/matrix-sdk'; -import { ClientEvent, EventType, RoomEvent, SyncState } from '$types/matrix-sdk'; +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 { createLogger } from '$utils/debug'; -import { getNotificationType } from '$utils/room/unread'; -import { - arePushRulesReady, - evaluateNotification, - isAwaitingDecryption, - watchDecryption, -} from '$utils/localNotifications'; -import { getLocalNotificationCache } from '$client/localNotificationCache'; -import { backfillLocalNotifications, runLiveTimelineScan } from '$utils/localNotificationBackfill'; +import { arePushRulesReady } from '$utils/localNotifications'; +import { runLiveTimelineScan } from '$utils/localNotificationBackfill'; -const logger = createLogger('NotificationRecorder'); -const RECORDED_CAP = 300; -const HEARTBEAT_INTERVAL_MS = 60_000; +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 mDirectsRef = useRef(mDirects); - mDirectsRef.current = mDirects; - - const recordedRef = useRef>(new Set()); - const decryptingRef = useRef>(new Set()); - const hasBackfilledRef = useRef(false); - const backfillControllerRef = useRef(undefined); - const decryptWatchersRef = useRef void>>(new Map()); - const hasScannedRef = useRef(false); const [storeContent] = useSetting(settingsAtom, 'showMessageContentInNotifications'); const [storeEncryptedContent] = useSetting( settingsAtom, 'showMessageContentInEncryptedNotifications' ); - const storeContentRef = useRef(storeContent); - storeContentRef.current = storeContent; - const storeEncryptedContentRef = useRef(storeContent && storeEncryptedContent); - storeEncryptedContentRef.current = storeContent && storeEncryptedContent; - const prevMxRef = useRef(mx); - if (prevMxRef.current !== mx) { - prevMxRef.current = mx; - recordedRef.current = new Set(); - decryptingRef.current = new Set(); - hasBackfilledRef.current = false; - hasScannedRef.current = false; - } + const mDirectsRef = useRef(mDirects); + mDirectsRef.current = mDirects; + const contentRef = useRef({ storeContent, storeEncryptedContent }); + contentRef.current = { + storeContent, + storeEncryptedContent: storeContent && storeEncryptedContent, + }; + const startedFor = useRef(); useEffect(() => { - const userId = mx.getSafeUserId(); - const cache = getLocalNotificationCache(userId); - const contentOptions = () => ({ - storeContent: storeContentRef.current, - storeEncryptedContent: storeEncryptedContentRef.current, - }); - - const markRecorded = (eventId: string) => { - recordedRef.current.add(eventId); - if (recordedRef.current.size > RECORDED_CAP) { - const oldest = recordedRef.current.values().next().value; - if (oldest !== undefined) recordedRef.current.delete(oldest); + const start = () => { + if (startedFor.current === mx || !isReady(mx.getSyncState()) || !arePushRulesReady(mx)) { + return; } - }; - - const handler: RoomEventHandlerMap[RoomEvent.Timeline] = ( - mEvent, - room, - toStartOfTimeline, - removed - ) => { - if (toStartOfTimeline || removed) return; - if (!room) return; - const eventId = mEvent.getId(); - if (!eventId) return; - - if (recordedRef.current.has(eventId)) return; - - // Leave unrecorded so the rescan picks it up once push rules arrive. - if (!arePushRulesReady(mx)) return; - - const encrypted = isAwaitingDecryption(mEvent); - if (encrypted && decryptingRef.current.has(eventId)) return; - - const evaluate = () => - evaluateNotification( - mx, - room, - mEvent, - mDirectsRef.current, - getNotificationType(mx, room.roomId), - { storeContent: encrypted ? storeEncryptedContentRef.current : storeContentRef.current } - ); - - markRecorded(eventId); - const stored = evaluate(); - if (stored) cache.merge(stored); - - if (!encrypted) return; - - decryptingRef.current.add(eventId); - decryptWatchersRef.current.set( - mEvent, - watchDecryption( - mEvent, - () => { - decryptingRef.current.delete(eventId); - const upgraded = evaluate(); - if (upgraded) cache.merge(upgraded); - }, - () => decryptingRef.current.delete(eventId) - ) - ); - }; - - mx.on(RoomEvent.Timeline, handler); - - // Only advance the watermark while syncing, so an outage isn't treated as "nothing missed". - const beat = () => { - if (mx.getSyncState() === SyncState.Syncing) cache.updateLastSeenTs(Date.now()); - }; - const heartbeatInterval = setInterval(beat, HEARTBEAT_INTERVAL_MS); - - // SlidingSyncSdk assigns client.pushRules without emitting AccountData, so - // this cannot wait on that event. Runs every start because shouldBackfill - // declines whenever the heartbeat kept the gap under its threshold. - const scanOnce = () => { - if (hasScannedRef.current || !arePushRulesReady(mx)) return; - hasScannedRef.current = true; - void runLiveTimelineScan(mx, userId, mDirectsRef.current, contentOptions()).catch( - (err: unknown) => { - logger.warn('live timeline scan failed', err); - } + startedFor.current = mx; + const content = contentRef.current; + void runLiveTimelineScan(mx, mx.getSafeUserId(), mDirectsRef.current, content).catch( + () => undefined ); }; - - const onSync = (state: SyncState) => { - if ( - state !== SyncState.Prepared && - state !== SyncState.Syncing && - state !== SyncState.Catchup - ) { - return; - } - scanOnce(); - - if (hasBackfilledRef.current) return; - hasBackfilledRef.current = true; - const controller = new AbortController(); - backfillControllerRef.current = controller; - void backfillLocalNotifications( - mx, - userId, - contentOptions(), - Date.now(), - controller.signal - ).catch((err: unknown) => { - logger.warn('backfill failed', err); - }); + const onSync = () => start(); + const onAccountData = (event: MatrixEvent) => { + if (event.getType() === (EventType.PushRules as string)) start(); }; mx.on(ClientEvent.Sync, onSync); - const currentState = mx.getSyncState(); - if (currentState) onSync(currentState); - - // Covers a later push-rule change. - const onPushRules = (event: MatrixEvent) => { - if (event.getType() !== (EventType.PushRules as string)) return; - scanOnce(); - }; - mx.on(ClientEvent.AccountData, onPushRules); + mx.on(ClientEvent.AccountData, onAccountData); + start(); - const decryptWatchers = decryptWatchersRef.current; return () => { - mx.off(RoomEvent.Timeline, handler); mx.off(ClientEvent.Sync, onSync); - mx.off(ClientEvent.AccountData, onPushRules); - clearInterval(heartbeatInterval); - // These hold mx, room and the previous account's cache through their closure. - for (const stop of decryptWatchers.values()) stop(); - decryptWatchers.clear(); - backfillControllerRef.current?.abort(); - backfillControllerRef.current = undefined; - beat(); + mx.off(ClientEvent.AccountData, onAccountData); }; }, [mx]); 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/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 51318f35e5..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 { useEffect, useMemo, useRef, useState } from 'react'; -import { Avatar, Badge, 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,47 +13,45 @@ import { composerIcon, sizedIcon, } from '$components/icons/phosphor'; -import { useSearchParams } from 'react-router-dom'; -import type { Room } from '$types/matrix-sdk'; import { JoinRule, MatrixEvent } from '$types/matrix-sdk'; -import { useVirtualizer } from '@tanstack/react-virtual'; +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 { useLocalNotificationTimeline } from '$hooks/useLocalNotificationTimeline'; -import { isStoredNotificationRead, type StoredNotification } from '$utils/localNotifications'; -import { getLocalNotificationCache } from '$client/localNotificationCache'; +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'; -type RoomNotificationsGroupProps = { - room: Room; - appBaseUrl: string; - notifications: StoredNotification[]; - hideReads: boolean; - onOpen: (roomId: string, eventId: string) => void; - hour24Clock: boolean; - dateFormatString: string; - expanded: boolean; - onToggleExpanded: (roomId: string, expanded: boolean) => void; +type NotificationRow = { + notification: StoredNotification; + showHeader: boolean; }; +const notificationRows = (items: StoredNotification[]): NotificationRow[] => + items.map((notification, index) => ({ + notification, + showHeader: items[index - 1]?.room_id !== notification.room_id, + })); + type NotificationItemProps = { room: Room; notification: StoredNotification; @@ -77,14 +78,26 @@ function NotificationItem({ () => 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 handleDismiss = () => { - getLocalNotificationCache(mx.getSafeUserId()).dismiss(notification.event.event_id); - }; - const isRead = isStoredNotificationRead(room, mx.getSafeUserId(), notification); + const read = isStoredNotificationRead(room, mx.getSafeUserId(), notification); return ( - {!isRead && ( + {!read && ( )} Open - - Done - } onOpen={handleOpen} @@ -120,109 +127,84 @@ function NotificationItem({ ); } -function RoomNotificationsGroupComp({ +function NotificationRowItem({ room, appBaseUrl, - notifications, + row, hideReads, onOpen, + onMarkRead, hour24Clock, dateFormatString, - expanded, - onToggleExpanded, -}: 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 handleDismissAll = () => { - getLocalNotificationCache(mx.getSafeUserId()).dismissAllInRoom(room.roomId); - }; - const MAX_VISIBLE = 5; - const visible = expanded ? notifications : notifications.slice(0, MAX_VISIBLE); - const hiddenCount = notifications.length - visible.length; + const useAuthentication = useMediaAuthentication(); + const renderContent = useRoomMessagePreviewRenderer(room, { + settingsLinkBaseUrl: appBaseUrl, + }); return ( -
- - - ( - - )} - /> - - - {room.name} - - - - {notifications.length > 0 && ( - - Dismiss all - - )} - {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 )} - -
- - {visible.map((notification) => ( - - ))} - {hiddenCount > 0 && ( - onToggleExpanded(room.roomId, true)}> - {hiddenCount} more - - )} - +
+ )} +
); } -const useNotificationsSearchParams = ( - searchParams: URLSearchParams -): InboxNotificationsPathSearchParams => - useMemo( - () => ({ - only: searchParams.get('only') ?? undefined, - }), - [searchParams] - ); - export function Notifications() { const mx = useMatrixClient(); const [hideReads] = useSetting(settingsAtom, 'hideReads'); @@ -230,57 +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 filterMode = notificationsSearchParams.only === 'all' ? 'all' : 'mentions'; - const setFilterMode = (mode: 'mentions' | 'all') => { - if (mode === 'all') { - setSearchParams(new URLSearchParams({ only: 'all' })); - } else { - setSearchParams(); - } - }; - const [includeDone, setIncludeDone] = useState(false); - const [expandedRooms, setExpandedRooms] = useState>({}); - const handleToggleExpanded = (roomId: string, expanded: boolean) => { - setExpandedRooms((prev) => ({ ...prev, [roomId]: expanded })); - }; - - const [notificationTimeline, loadTimelineRaw] = useLocalNotificationTimeline( - 24, - filterMode, - includeDone - ); - const [timelineState, loadTimeline] = useAsyncCallback(loadTimelineRaw); - - const virtualizer = useVirtualizer({ - count: notificationTimeline.groups.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 40, - overscan: 4, - }); - const vItems = virtualizer.getVirtualItems(); + 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 ( @@ -311,39 +276,36 @@ export function Notifications() { Filter - - setFilterMode('mentions')} - variant={filterMode === 'mentions' ? 'Success' : 'Surface'} - aria-pressed={filterMode === 'mentions'} - before={filterMode === 'mentions' && sizedIcon(Check, '100')} - outlined - > - Mentions & DMs - - setFilterMode('all')} - variant={filterMode === 'all' ? 'Success' : 'Surface'} - aria-pressed={filterMode === 'all'} - before={filterMode === 'all' && sizedIcon(Check, '100')} - outlined - > - All - + + {(['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)} + + + ))} setIncludeDone((v) => !v)} - variant={includeDone ? 'Success' : 'Surface'} - aria-pressed={includeDone} - before={includeDone && sizedIcon(Check, '100')} + onClick={() => setFilter('read', includeRead ? undefined : '1')} + variant={includeRead ? 'Success' : 'Surface'} + aria-pressed={includeRead} + before={includeRead && sizedIcon(Check, '100')} outlined > - Include done + Include read + virtualizer.scrollToOffset(0)} + onClick={() => scrollRef.current?.scrollTo({ top: 0 })} variant="SurfaceVariant" radii="Pill" outlined @@ -353,84 +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/utils/groupNotifications.ts b/src/app/utils/groupNotifications.ts deleted file mode 100644 index 28524f16b7..0000000000 --- a/src/app/utils/groupNotifications.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { EventType } from '$types/matrix-sdk'; -import type { StoredNotification } from './localNotifications'; - -type RoomNotificationsGroup = { - roomId: string; - notifications: StoredNotification[]; -}; - -export const groupNotifications = ( - notifications: StoredNotification[], - 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; -}; diff --git a/src/app/utils/localNotificationBackfill.test.ts b/src/app/utils/localNotificationBackfill.test.ts index 24af3c4591..560411aff5 100644 --- a/src/app/utils/localNotificationBackfill.test.ts +++ b/src/app/utils/localNotificationBackfill.test.ts @@ -1,792 +1,196 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import { backfillLocalNotifications, runLiveTimelineScan } from './localNotificationBackfill'; +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 { - getLocalNotificationCache, clearLocalNotificationCache, + getLocalNotificationCache, } from '$client/localNotificationCache'; -import { MAX_BACKFILL_ROOMS } from './localNotifications'; - -const ROOM_ID = '!active:example.com'; -const USER_ID = '@test:example.com'; -const CONTENT = { storeContent: true, storeEncryptedContent: true }; -type RoomOverrides = Omit, 'isSpaceRoom'> & { - lastActiveTs?: number; - isSpaceRoom?: boolean; - _events?: Partial[]; -}; - -const createRoom = (roomId: string, overrides: RoomOverrides = {}): Room => { - const { lastActiveTs, isSpaceRoom, _events, ...rest } = overrides; - return { - roomId, - getLastActiveTimestamp: () => lastActiveTs ?? Date.now(), - isSpaceRoom: isSpaceRoom ? () => true : () => false, - getJoinedMemberCount: () => 3, // not 2, so isDMRoom's heuristic doesn't fire - getLiveTimeline: () => ({ - getEvents: () => (_events ?? []) as MatrixEvent[], - // evaluateNotification reads m.room.encryption to decide whether the - // encrypted-content setting applies. - getState: () => ({ getStateEvents: () => undefined }), - }), - getAccountData: () => undefined, - ...rest, +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 createEvent = (ts: number, id?: string): Partial => - ({ - getId: () => id ?? `$ev_${ts}`, - getTs: () => ts, - getSender: () => '@other:example.com', +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: () => ({ body: 'hello', msgtype: 'm.text' }), - isRedacted: () => false, - isSending: () => false, + getContent: () => ({ msgtype: 'm.text', body: 'hello' }), + getTs: () => frontier, getRelation: () => undefined, - }) as unknown as Partial; - - -type FakeEncryptedEvent = Partial & { - decryptTo: (content: Record) => void; - failDecryption: () => void; -}; - -const createEncryptedEvent = (ts: number, id: string): FakeEncryptedEvent => { - const listeners: (() => void)[] = []; - let type = 'm.room.encrypted'; - let content: Record = { algorithm: 'm.megolm.v1.aes-sha2', ciphertext: 'AAA' }; - let failed = false; - - const event = { - getId: () => id, - getTs: () => ts, - getSender: () => '@other:example.com', - getType: () => type, - getContent: () => content, - isEncrypted: () => true, - isDecryptionFailure: () => failed, isRedacted: () => false, isSending: () => false, - getRelation: () => undefined, - on: (_event: string, listener: () => void) => { - listeners.push(listener); - return event; - }, - off: (_event: string, listener: () => void) => { - const index = listeners.indexOf(listener); - if (index !== -1) listeners.splice(index, 1); - return event; - }, - decryptTo: (clear: Record) => { - failed = false; - type = 'm.room.message'; - content = clear; - for (const listener of listeners.slice()) listener(); - }, - failDecryption: () => { - failed = true; - type = 'm.room.message'; - content = { msgtype: 'm.bad.encrypted', body: '** Unable to decrypt **' }; - for (const listener of listeners.slice()) listener(); + 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; }, }; - - return event as unknown as FakeEncryptedEvent; }; -const encryptedClient = (room: Room, notifyFor: (mEvent: MatrixEvent) => boolean): MatrixClient => - ({ - getRooms: () => [room], - getRoom: () => room, - scrollback: async (r: Room) => r, - getAccountData: (type: unknown) => - type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, - getRoomPushRule: () => { - throw new Error('no rule'); - }, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: (mEvent: MatrixEvent) => ({ notify: notifyFor(mEvent), tweaks: {} }), - }, - }) as unknown as MatrixClient; - -beforeEach(() => { +afterEach(() => { clearLocalNotificationCache(USER_ID); + localStorage.clear(); + state.unread = 1; + state.directRooms.clear(); }); - describe('backfillLocalNotifications', () => { - it('no watermark (new device) → no backfill', async () => { - const scrollback = vi - .fn() - .mockResolvedValue(undefined as unknown as Room); - - const mx = { - getRooms: () => [], - getRoom: () => undefined, - scrollback, - getAccountData: (type: unknown) => - type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: {} }), - }, - } as unknown as MatrixClient; - - const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT); - - expect(recorded).toBe(0); - expect(scrollback).not.toHaveBeenCalled(); - }); - - it('recent watermark (small gap) → no backfill', async () => { - const now = Date.now(); - const cache = getLocalNotificationCache(USER_ID); - cache.updateLastSeenTs(now - 1 * 60 * 1000); // 1 minute ago, below GAP_THRESHOLD_MS (5 min) - - const scrollback = vi - .fn() - .mockResolvedValue(undefined as unknown as Room); - - const mx = { - getRooms: () => [], - getRoom: () => undefined, - scrollback, - getAccountData: (type: unknown) => - type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: {} }), - }, - } as unknown as MatrixClient; - - const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT, now); - - expect(recorded).toBe(0); - expect(scrollback).not.toHaveBeenCalled(); - }); - - it('stale watermark (large gap) → backfills active rooms', async () => { - const now = Date.now(); - const twoHoursAgo = now - 2 * 60 * 60 * 1000; - - const cache = getLocalNotificationCache(USER_ID); - cache.updateLastSeenTs(twoHoursAgo); - - const activeEvents = [createEvent(now - 30 * 1000, '$active1')]; - const spaceEvents = [createEvent(now - 30 * 1000, '$space1')]; - - const activeRoom = createRoom('!active:example.com', { - lastActiveTs: now - 60 * 1000, - _events: activeEvents, - }); - const spaceRoom = createRoom('!space:example.com', { - isSpaceRoom: true, - lastActiveTs: now - 60 * 1000, - _events: spaceEvents, + it('loads and records only when sync reports unread notifications', async () => { + const { mx, scrollback } = setup(); + await backfillLocalNotifications(mx, USER_ID, { + storeContent: true, + storeEncryptedContent: true, }); - const mutedRoom = createRoom('!muted:example.com', { - lastActiveTs: now - 60 * 1000, - _events: [], - }); - - const scrollback = vi - .fn() - .mockImplementation(async (room: Room) => room as unknown as Room); - - const pushRulesForMuted = { - global: { - override: [{ rule_id: '!muted:example.com', enabled: true, actions: ['dont_notify'] }], - }, - }; - const getAccountData = vi - .fn<(type: unknown) => unknown>() - .mockImplementation((eventType: unknown) => { - if (eventType === 'm.direct') return { getContent: () => ({}) } as unknown; - if (eventType === 'm.push_rules') { - return { getContent: () => pushRulesForMuted } as unknown; - } - return undefined; - }); - - const mx = { - getRooms: () => [activeRoom, spaceRoom, mutedRoom], - getRoom: (roomId: string) => { - if (roomId === '!active:example.com') return activeRoom; - if (roomId === '!space:example.com') return spaceRoom; - if (roomId === '!muted:example.com') return mutedRoom; - return undefined; - }, - scrollback, - getAccountData, - getRoomPushRule: (_scope: string, roomId: string) => { - if (roomId === '!muted:example.com') throw new Error('no rule'); - throw new Error('no rule'); - }, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: {} }), - }, - } as unknown as MatrixClient; - - const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT, now); - - expect(recorded).toBeGreaterThanOrEqual(0); - // Only the active (non-space, non-muted) room should be scrolled - const scrollbackRoomIds = scrollback.mock.calls.map((call) => (call[0] as Room).roomId); - expect(scrollbackRoomIds).toContain('!active:example.com'); - expect(scrollbackRoomIds).not.toContain('!space:example.com'); - expect(scrollbackRoomIds).not.toContain('!muted:example.com'); - }); - - it('respects MAX_BACKFILL_ROOMS = 30', async () => { - const now = Date.now(); - const twoHoursAgo = now - 2 * 60 * 60 * 1000; - - const cache = getLocalNotificationCache(USER_ID); - cache.updateLastSeenTs(twoHoursAgo); - - const scrollback = vi - .fn() - .mockResolvedValue(undefined as unknown as Room); - - const rooms: Room[] = Array.from({ length: 40 }, (_, i) => - createRoom(`!room${i}:example.com`, { - lastActiveTs: now - 60 * 1000 + i * 1000, // each slightly more recent - _events: [], - }) - ); - - const mx = { - getRooms: () => rooms, - getRoom: (roomId: string) => rooms.find((r) => r.roomId === roomId), - scrollback, - getAccountData: (type: unknown) => - type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, - getRoomPushRule: () => { - throw new Error('no rule'); - }, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: {} }), - }, - } as unknown as MatrixClient; - - await backfillLocalNotifications(mx, USER_ID, CONTENT, now); - - // (30 rooms × up to 2 pages = up to 60 calls, but unique rooms ≤ 30.) - const scrollbackRoomIds = scrollback.mock.calls.map((call) => (call[0] as Room).roomId); - const uniqueRooms = new Set(scrollbackRoomIds); - expect(uniqueRooms.size).toBeLessThanOrEqual(MAX_BACKFILL_ROOMS); - }); - - it('early stop: events older than watermark → only 1 page', async () => { - const now = Date.now(); - const twoHoursAgo = now - 2 * 60 * 60 * 1000; - - const cache = getLocalNotificationCache(USER_ID); - cache.updateLastSeenTs(twoHoursAgo); - - const events = [ - createEvent(now - 10 * 1000, '$recent'), - createEvent(now - 60 * 1000, '$mid'), - createEvent(twoHoursAgo - 10 * 1000, '$old'), // older than watermark - ]; - - const room = createRoom(ROOM_ID, { - lastActiveTs: now - 60 * 1000, - _events: events, - }); - - const scrollback = vi - .fn() - .mockImplementation(async (r: Room) => r as unknown as Room); - - const mx = { - getRooms: () => [room], - getRoom: () => room, - scrollback, - getAccountData: (type: unknown) => - type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, - getRoomPushRule: () => { - throw new Error('no rule'); - }, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: {} }), - }, - } as unknown as MatrixClient; - - await backfillLocalNotifications(mx, USER_ID, CONTENT, now); - - expect(scrollback).toHaveBeenCalledTimes(1); - }); - - it('adaptive pages: small gap → 1 page, large gap → up to 2 pages', async () => { - const now = Date.now(); - const smallGapMs = 10 * 60 * 1000; // 10 minutes (< 30 min) - const largeGapMs = 2 * 60 * 60 * 1000; // 2 hours (> 30 min) - - { - const uid = `${USER_ID}_small`; - clearLocalNotificationCache(uid); - const cache = getLocalNotificationCache(uid); - cache.updateLastSeenTs(now - smallGapMs); - - const events = [createEvent(now - 5 * 1000, '$recent')]; - const room = createRoom('!small:example.com', { - lastActiveTs: now - 5 * 1000, - _events: events, - }); - - const scrollback = vi - .fn() - .mockImplementation(async (r: Room) => r as unknown as Room); - - const mx = { - getRooms: () => [room], - getRoom: () => room, - scrollback, - getAccountData: (type: unknown) => - type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, - getRoomPushRule: () => { - throw new Error('no rule'); - }, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: {} }), - }, - } as unknown as MatrixClient; - - await backfillLocalNotifications(mx, uid, CONTENT, now); - expect(scrollback).toHaveBeenCalledTimes(1); - clearLocalNotificationCache(uid); - } - - { - const uid = `${USER_ID}_large`; - clearLocalNotificationCache(uid); - const cache = getLocalNotificationCache(uid); - cache.updateLastSeenTs(now - largeGapMs); - - const events = [createEvent(now - 5 * 1000, '$recent')]; - const room = createRoom('!large:example.com', { - lastActiveTs: now - 5 * 1000, - _events: events, - }); - - const scrollback = vi - .fn() - .mockImplementation(async (r: Room) => r as unknown as Room); - const mx = { - getRooms: () => [room], - getRoom: () => room, - scrollback, - getAccountData: (type: unknown) => - type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, - getRoomPushRule: () => { - throw new Error('no rule'); - }, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: {} }), - }, - } as unknown as MatrixClient; - - await backfillLocalNotifications(mx, uid, CONTENT, now); - expect(scrollback).toHaveBeenCalledTimes(2); - clearLocalNotificationCache(uid); - } + expect(scrollback).toHaveBeenCalledOnce(); + expect(getLocalNotificationCache(USER_ID).getEntries()).toHaveLength(1); }); - it('muted room skipped', async () => { - const now = Date.now(); - const twoHoursAgo = now - 2 * 60 * 60 * 1000; - - const cache = getLocalNotificationCache(USER_ID); - cache.updateLastSeenTs(twoHoursAgo); - - const mutedRoom = createRoom('!muted:example.com', { - lastActiveTs: now - 60 * 1000, - _events: [], - }); - - const scrollback = vi - .fn() - .mockResolvedValue(undefined as unknown as Room); - - // getAccountData: return undefined for m.direct, return a mute override for m.push_rules - let callCount = 0; - const getAccountData = vi.fn<(type: unknown) => unknown>().mockImplementation(() => { - callCount += 1; - if (callCount === 1) return undefined; // EventType.Direct - return { - getContent: () => ({ - global: { - override: [{ rule_id: '!muted:example.com', actions: ['dont_notify'] }], - }, - }), - }; + it('does not paginate a read room', async () => { + state.unread = 0; + const { mx, scrollback } = setup(); + await backfillLocalNotifications(mx, USER_ID, { + storeContent: true, + storeEncryptedContent: true, }); - const mx = { - getRooms: () => [mutedRoom], - getRoom: () => mutedRoom, - scrollback, - getAccountData, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: {} }), - }, - } as unknown as MatrixClient; - - await backfillLocalNotifications(mx, USER_ID, CONTENT, now); - expect(scrollback).not.toHaveBeenCalled(); }); - it('sequential, not parallel: only one scrollback in-flight at a time', async () => { - const now = Date.now(); - const twoHoursAgo = now - 2 * 60 * 60 * 1000; - - const cache = getLocalNotificationCache(USER_ID); - cache.updateLastSeenTs(twoHoursAgo); - - let inFlight = 0; - let maxInFlight = 0; - - const rooms: Room[] = ['!r1:example.com', '!r2:example.com', '!r3:example.com'].map((id) => - createRoom(id, { - lastActiveTs: now - 60 * 1000, - _events: [createEvent(now - 5 * 1000)], - }) - ); - - const scrollback = vi.fn().mockImplementation(async (r: Room) => { - inFlight += 1; - maxInFlight = Math.max(maxInFlight, inFlight); - await new Promise((resolve) => setTimeout(resolve, 1)); - inFlight -= 1; - return r as unknown as Room; - }); - - const mx = { - getRooms: () => rooms, - getRoom: (roomId: string) => rooms.find((r) => r.roomId === roomId), - scrollback, - getAccountData: (type: unknown) => - type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, - getRoomPushRule: () => { - throw new Error('no rule'); - }, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: {} }), - }, - } as unknown as MatrixClient; - - await backfillLocalNotifications(mx, USER_ID, CONTENT, now); - - expect(maxInFlight).toBe(1); - }); - - it('returns count of recorded notifications', async () => { - const now = Date.now(); - const twoHoursAgo = now - 2 * 60 * 60 * 1000; - - const cache = getLocalNotificationCache(USER_ID); - cache.updateLastSeenTs(twoHoursAgo); - - const events = [ - createEvent(now - 10 * 1000, '$a'), - createEvent(now - 20 * 1000, '$b'), - createEvent(now - 30 * 1000, '$c'), - ]; - - const room = createRoom(ROOM_ID, { - lastActiveTs: now - 5 * 1000, - _events: events, - }); - - const scrollback = vi - .fn() - .mockImplementation(async (r: Room) => r as unknown as Room); - - const mx = { - getRooms: () => [room], - getRoom: () => room, - scrollback, - getAccountData: (type: unknown) => - type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, - getRoomPushRule: () => { - throw new Error('no rule'); - }, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: {} }), - }, - } as unknown as MatrixClient; - - const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT, now); - - expect(recorded).toBe(3); - }); - - it('records events newer than the watermark when the page also contains older ones', async () => { - // must not stop the page being recorded. - const now = Date.now(); - const twoHoursAgo = now - 2 * 60 * 60 * 1000; - - const cache = getLocalNotificationCache(USER_ID); - cache.updateLastSeenTs(twoHoursAgo); - - const events = [ - createEvent(twoHoursAgo - 10 * 1000, '$old'), // older than watermark - createEvent(now - 30 * 1000, '$recent'), // newer than watermark - ]; - - const room = createRoom(ROOM_ID, { - lastActiveTs: now - 5 * 1000, - _events: events, - }); - - const scrollback = vi - .fn() - .mockImplementation(async (r: Room) => r as unknown as Room); - - const mx = { - getRooms: () => [room], - getRoom: () => room, - scrollback, - getAccountData: (type: unknown) => - type === 'm.direct' ? ({ getContent: () => ({}) } as unknown) : undefined, - getRoomPushRule: () => { - throw new Error('no rule'); - }, - getSafeUserId: () => USER_ID, - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getUserId: () => USER_ID, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: {} }), - }, - } as unknown as MatrixClient; - - const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT, now); - - expect(recorded).toBe(1); - }); -}); - - -describe('backfillLocalNotifications in encrypted rooms', () => { - const encryptedRoom = (events: Partial[], now: number): Room => - createRoom(ROOM_ID, { - lastActiveTs: now - 5 * 1000, - _events: events, - getLiveTimeline: () => - ({ - getEvents: () => events as MatrixEvent[], - getState: () => ({ getStateEvents: () => ({}) }), - }) as unknown as ReturnType, + 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; + }), }); - it('records a mention that only becomes visible after decryption', async () => { - const now = Date.now(); - getLocalNotificationCache(USER_ID).updateLastSeenTs(now - 2 * 60 * 60 * 1000); - - const mEvent = createEncryptedEvent(now - 10 * 1000, '$enc'); - const room = encryptedRoom([mEvent], now); - // Ciphertext matches no rule; the mention only shows once decrypted. - const mx = encryptedClient(room, (e) => e.getType() === 'm.room.message'); - - const recorded = await backfillLocalNotifications(mx, USER_ID, CONTENT, now); - expect(recorded).toBe(0); - - mEvent.decryptTo({ msgtype: 'm.text', body: 'hey @test' }); - - const entries = getLocalNotificationCache(USER_ID).getEntries(); - expect(entries).toHaveLength(1); - expect(entries[0]!.event.content.body).toBe('hey @test'); - }); - - it('keeps the placeholder when decryption fails', async () => { - const now = Date.now(); - getLocalNotificationCache(USER_ID).updateLastSeenTs(now - 2 * 60 * 60 * 1000); - - const mEvent = createEncryptedEvent(now - 10 * 1000, '$enc'); - const room = encryptedRoom([mEvent], now); - const mx = encryptedClient(room, () => true); - - await backfillLocalNotifications(mx, USER_ID, CONTENT, now); - expect(getLocalNotificationCache(USER_ID).getEntries()).toHaveLength(1); - - mEvent.failDecryption(); - expect(getLocalNotificationCache(USER_ID).getEntries()).toHaveLength(1); - - // The SDK retries once the megolm key arrives. - mEvent.decryptTo({ msgtype: 'm.text', body: 'recovered' }); - const entries = getLocalNotificationCache(USER_ID).getEntries(); - expect(entries).toHaveLength(1); - expect(entries[0]!.event.content.body).toBe('recovered'); - }); - - it('omits the body when encrypted content must not be persisted', async () => { - const now = Date.now(); - getLocalNotificationCache(USER_ID).updateLastSeenTs(now - 2 * 60 * 60 * 1000); - - const mEvent = createEvent(now - 10 * 1000, '$clear'); - const room = encryptedRoom([mEvent], now); - const mx = encryptedClient(room, () => true); - await backfillLocalNotifications( mx, USER_ID, - { storeContent: true, storeEncryptedContent: false }, - now + { storeContent: true, storeEncryptedContent: true }, + { includeRead: true } ); - const entry = getLocalNotificationCache(USER_ID).getEntries()[0]!; - expect(entry.event.content.body).toBeUndefined(); - expect(entry.event.content.msgtype).toBe('m.text'); - }); -}); - -describe('runLiveTimelineScan', () => { - const clientWith = (rooms: Room[], withPushRules = true): MatrixClient => - ({ - getRooms: () => rooms, - getRoom: (roomId: string) => rooms.find((r) => r.roomId === roomId), - getSafeUserId: () => USER_ID, - getUserId: () => USER_ID, - pushRules: withPushRules - ? { global: { override: [], content: [], room: [], sender: [], underride: [] } } - : undefined, - getAccountData: () => undefined, - getRoomPushRule: () => undefined, - pushProcessor: { - actionsForEvent: vi - .fn<() => { notify: boolean; tweaks: Record }>() - .mockReturnValue({ notify: true, tweaks: { highlight: true } }), - }, - }) as unknown as MatrixClient; - - it('records events already sitting in the live timeline', async () => { - const room = createRoom(ROOM_ID, { - _events: [createEvent(1000, '$a'), createEvent(2000, '$b')], + 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; + }), }); - const recorded = await runLiveTimelineScan( - clientWith([room]), - USER_ID, - new Set(), - CONTENT - ); - - expect(recorded).toBe(2); - expect(getLocalNotificationCache(USER_ID).getEntries()).toHaveLength(2); - }); - - it('is idempotent, so a rescan cannot duplicate entries', async () => { - const room = createRoom(ROOM_ID, { _events: [createEvent(1000, '$a')] }); - const mx = clientWith([room]); - - await runLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); - await runLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); - - expect(getLocalNotificationCache(USER_ID).getEntries()).toHaveLength(1); - }); - - it('preserves a dismissal across a rescan', async () => { - const room = createRoom(ROOM_ID, { _events: [createEvent(1000, '$a')] }); - const mx = clientWith([room]); - const cache = getLocalNotificationCache(USER_ID); - - await runLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); - cache.dismiss('$a'); - await runLiveTimelineScan(mx, USER_ID, new Set(), CONTENT); - - expect(cache.getEntries()[0]!.dismissed).toBe(true); - }); - - it('records nothing while push rules are still missing', async () => { - const room = createRoom(ROOM_ID, { _events: [createEvent(1000, '$a')] }); - - const recorded = await runLiveTimelineScan( - clientWith([room], false), + await backfillLocalNotifications( + mx, USER_ID, - new Set(), - CONTENT + { storeContent: true, storeEncryptedContent: true }, + { includeRead: true, tab: 'dms' } ); - expect(recorded).toBe(0); - }); - - it('skips space rooms', async () => { - const space = createRoom('!space:example.com', { - isSpaceRoom: true, - _events: [createEvent(1000, '$a')], - }); - - expect(await runLiveTimelineScan(clientWith([space]), USER_ID, new Set(), CONTENT)).toBe(0); - }); - - it('omits the body when content must not be persisted', async () => { - const room = createRoom(ROOM_ID, { _events: [createEvent(1000, '$a')] }); - - await runLiveTimelineScan(clientWith([room]), USER_ID, new Set(), { - storeContent: false, - storeEncryptedContent: false, - }); - - const entry = getLocalNotificationCache(USER_ID).getEntries()[0]!; - expect(entry.event.content.body).toBeUndefined(); - expect(entry.event.content.msgtype).toBe('m.text'); + expect(order).toEqual(['direct']); }); }); diff --git a/src/app/utils/localNotificationBackfill.ts b/src/app/utils/localNotificationBackfill.ts index 164fc568f1..7cdd93a648 100644 --- a/src/app/utils/localNotificationBackfill.ts +++ b/src/app/utils/localNotificationBackfill.ts @@ -1,34 +1,36 @@ -import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import { ClientEvent, EventType } from '$types/matrix-sdk'; +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 { createLogger } from '$utils/debug'; import { getAccountData, getStateEvent } from '$utils/room/hierarchy'; -import { getMDirects, getNotificationType } from '$utils/room/unread'; +import { getMDirects, getNotificationType, getUnreadInfo, isDMRoom } from '$utils/room/unread'; import { evaluateNotification, isAwaitingDecryption, - selectBackfillRooms, - shouldBackfill, + isStoredNotificationRead, watchDecryption, - backfillPageCount, - type BackfillRoomInfo, + type NotificationTab, type StoredNotification, } from './localNotifications'; -const logger = createLogger('localNotificationBackfill'); -const SCROLLBACK_LIMIT = 50; - -// Bounds the transient batch; the cache only keeps MAX_ENTRIES anyway. -const SCAN_FLUSH_BATCH = 200; +const PAGE_SIZE = 50; +const MAX_PAGES = 5; export type ScanContentOptions = { storeContent: boolean; storeEncryptedContent: boolean; }; -const isEncryptedRoom = (room: Room): boolean => - getStateEvent(room, EventType.RoomEncryption) !== undefined; +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, @@ -36,164 +38,148 @@ export const runLiveTimelineScan = async ( mDirects: Set, content: ScanContentOptions ): Promise => { - const cache = getLocalNotificationCache(userId); - const pending: StoredNotification[] = []; - let recorded = 0; - - const flush = () => { - if (pending.length === 0) return; - cache.mergeMany(pending); - recorded += pending.length; - pending.length = 0; - }; - + const notifications: StoredNotification[] = []; for (const room of mx.getRooms()) { - if (room.isSpaceRoom()) continue; const notificationType = getNotificationType(mx, room.roomId); - if (notificationType === NotificationType.Mute) continue; + if (room.isSpaceRoom() || notificationType === NotificationType.Mute) continue; - const storeContent = isEncryptedRoom(room) - ? content.storeEncryptedContent - : content.storeContent; - for (const mEvent of room.getLiveTimeline().getEvents()) { - const stored = evaluateNotification(mx, room, mEvent, mDirects, notificationType, { - storeContent, + for (const event of room.getLiveTimeline().getEvents()) { + const notification = evaluateNotification(mx, room, event, mDirects, notificationType, { + storeContent: storesContent(room, content), }); - if (!stored) continue; - pending.push(stored); - if (pending.length >= SCAN_FLUSH_BATCH) flush(); + 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)); } - flush(); + getLocalNotificationCache(userId).mergeMany(notifications); + return notifications.length; +}; - logger.log('live timeline scan complete', { recorded }); - return recorded; +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, - now: number = Date.now(), - signal?: AbortSignal + options: BackfillOptions = {} ): Promise => { + const { includeRead = false, signal, tab = 'all' } = options; const cache = getLocalNotificationCache(userId); - const lastSeenTs = cache.getLastSeenTs(); - const watermark = shouldBackfill(lastSeenTs, now); - if (watermark === undefined) { - logger.log('backfill skipped', { - reason: lastSeenTs === undefined ? 'new-device' : 'small-gap', - }); - return 0; - } - - const allRooms = mx.getRooms(); - const roomInfos: BackfillRoomInfo[] = allRooms.map((room) => ({ - roomId: room.roomId, - lastActiveTs: room.getLastActiveTimestamp(), - isSpaceRoom: room.isSpaceRoom(), - isMuted: getNotificationType(mx, room.roomId) === NotificationType.Mute, - })); - const selectedRoomIds = selectBackfillRooms(roomInfos, watermark); - const pages = backfillPageCount(watermark, now); - const activeRooms = roomInfos.filter( - (r) => !r.isSpaceRoom && !r.isMuted && r.lastActiveTs > watermark - ).length; - - logger.log('backfill starting', { - rooms: selectedRoomIds.length, - skippedRooms: activeRooms - selectedRoomIds.length, - pages, - gapMs: now - watermark, + 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), + }; }); - - // m.direct may not have arrived at SyncState.Prepared yet. - const mDirectEvent = getAccountData(mx, EventType.Direct); - let mDirects: Set; - if (mDirectEvent) { - mDirects = getMDirects(mDirectEvent); - } else { - mDirects = await new Promise>((resolve) => { - const handler = (event: MatrixEvent) => { - if (event.getType() === (EventType.Direct as string)) { - mx.off(ClientEvent.AccountData, handler); - resolve(getMDirects(event)); - } - }; - mx.on(ClientEvent.AccountData, handler); - setTimeout(() => { - mx.off(ClientEvent.AccountData, handler); - resolve(new Set()); - }, 5000); - }); - } - + let pages = 0; let recorded = 0; - const processed = new Set(); - const stopWatchers: (() => void)[] = []; - const releaseWatchers = () => { - for (const stop of stopWatchers) stop(); - stopWatchers.length = 0; - }; - signal?.addEventListener('abort', releaseWatchers, { once: true }); - - for (const roomId of selectedRoomIds) { - if (signal?.aborted) return recorded; - const room = mx.getRoom(roomId); - if (!room) continue; - const notificationType = getNotificationType(mx, roomId); - if (notificationType === NotificationType.Mute) continue; - const storeContent = isEncryptedRoom(room) - ? content.storeEncryptedContent - : content.storeContent; - - try { - for (let page = 0; page < pages; page += 1) { - if (signal?.aborted) return recorded; - // eslint-disable-next-line no-await-in-loop - await mx.scrollback(room, SCROLLBACK_LIMIT); - const events = room.getLiveTimeline().getEvents(); - for (const mEvent of events.toReversed()) { - if (mEvent.getTs() <= watermark) break; - if (signal?.aborted) return recorded; - const eventId = mEvent.getId(); - if (!eventId || processed.has(eventId)) continue; - processed.add(eventId); - const evaluate = () => - evaluateNotification(mx, room, mEvent, mDirects, notificationType, { storeContent }); - - const stored = evaluate(); - if (stored) { - cache.merge(stored); - recorded += 1; - } - - // Also watch events that did not notify as ciphertext: a mention is - // only visible once the clear event arrives. - if (isAwaitingDecryption(mEvent)) { - stopWatchers.push( - watchDecryption(mEvent, () => { - const upgraded = evaluate(); - if (upgraded) cache.merge(upgraded); - }) - ); - } - } - - const hasOlder = events.some((e) => e.getTs() < watermark); - if (hasOlder) break; + 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; } - } catch (err) { - logger.warn('backfill room failed', { roomId, err }); + if (isAwaitingDecryption(event)) { + stopWatching.push( + watchDecryption(event, () => { + const decrypted = evaluate(); + if (decrypted) cache.merge(decrypted); + }) + ); + } + if (state.missing === 0) break; } - // eslint-disable-next-line no-await-in-loop - await new Promise((r) => setTimeout(r, 0)); + 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 + ); } - logger.log('backfill complete', { recorded }); + 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 index 6eaf31b6e7..d79335547f 100644 --- a/src/app/utils/localNotifications.test.ts +++ b/src/app/utils/localNotifications.test.ts @@ -1,564 +1,144 @@ import { describe, expect, it, vi } from 'vitest'; -import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import type { PushProcessor } from '$types/matrix-sdk'; -import { EventType } from '$types/matrix-sdk'; +import type { MatrixClient, MatrixEvent, PushProcessor, Room } from '$types/matrix-sdk'; import { NotificationType } from '$types/matrix/room'; import { - MAX_BODY_LENGTH, - arePushRulesReady, evaluateNotification, isStoredNotificationRead, sliceNotificationPage, + type StoredNotification, } from './localNotifications'; -import type { StoredNotification } from './localNotifications'; -const ROOM_ID = '!test:example.com'; -const USER_ID = '@user:example.com'; -const OTHER_USER = '@other:example.com'; -const EVENT_ID = '$event1'; +const ROOM_ID = '!room:example.org'; +const USER_ID = '@me:example.org'; - -const createEvent = (overrides: Partial = {}): MatrixEvent => +const event = (overrides: Partial = {}): MatrixEvent => ({ - getId: () => EVENT_ID, - getSender: () => OTHER_USER, + getId: () => '$event', + getSender: () => '@alice:example.org', getType: () => 'm.room.message', - getContent: (() => ({ body: 'Hello', msgtype: 'm.text' })) as MatrixEvent['getContent'], - isRedacted: () => false, + getContent: () => ({ msgtype: 'm.text', body: 'Hello' }), + getTs: () => 100, getRelation: () => undefined, + isRedacted: () => false, isSending: () => false, - getTs: () => 1000, ...overrides, }) as unknown as MatrixEvent; -const createRoom = (overrides: Partial = {}, encrypted = false): Room => +const room = (overrides: Partial = {}): Room => ({ roomId: ROOM_ID, isSpaceRoom: () => false, - getJoinedMemberCount: () => 3, // not 2, so isDMRoom's heuristic doesn't fire by default - getLiveTimeline: () => ({ - getState: () => ({ - getStateEvents: (type: string) => - encrypted && type === EventType.RoomEncryption ? ({} as MatrixEvent) : undefined, - }), - }), + getJoinedMemberCount: () => 3, ...overrides, }) as unknown as Room; -const createClient = ( - rooms: Record = {}, - pushActionsOverride: ReturnType = { notify: true, tweaks: {} }, - getAccountDataFn?: MatrixClient['getAccountData'], - getSafeUserIdFn?: MatrixClient['getSafeUserId'] -): MatrixClient => +const client = (actions = { notify: true, tweaks: {} }): MatrixClient => ({ + getSafeUserId: () => USER_ID, getUserId: () => USER_ID, - getSafeUserId: getSafeUserIdFn ?? (() => USER_ID), - // evaluateNotification refuses to classify until push rules have synced. - pushRules: { global: { override: [], content: [], room: [], sender: [], underride: [] } }, - getRoom: (roomId: string) => rooms[roomId], + pushRules: { global: {} }, pushProcessor: { - actionsForEvent: vi - .fn() - .mockReturnValue(pushActionsOverride), + actionsForEvent: vi.fn().mockReturnValue(actions), } as unknown as PushProcessor, - getAccountData: getAccountDataFn ?? (() => undefined), - getRoomPushRule: () => { - throw new Error('no rule'); - }, }) 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 + ); -describe('arePushRulesReady', () => { - it('is false before push rules have synced', () => { - const mx = { pushRules: undefined } as unknown as MatrixClient; - - expect(arePushRulesReady(mx)).toBe(false); - }); - - it('is false when the ruleset has no global scope', () => { - const mx = { pushRules: {} } as unknown as MatrixClient; - - expect(arePushRulesReady(mx)).toBe(false); - }); - - it('is true once a global ruleset is present', () => { - expect(arePushRulesReady(createClient())).toBe(true); - }); -}); - -describe('evaluateNotification without push rules', () => { - // actionsForEvent yields {} before rules sync, so notify is undefined and a - // would-be mention looks identical to "do not notify". - const clientWithoutRules = (room: Room): MatrixClient => - ({ - ...(createClient({ [ROOM_ID]: room }) as unknown as Record), - pushRules: undefined, - }) as unknown as MatrixClient; - - it('declines to classify rather than deciding not to notify', () => { - const room = createRoom(); - const mx = clientWithoutRules(room); - const event = createEvent(); - - expect( - evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages) - ).toBeUndefined(); + expect(result).toMatchObject({ + room_id: ROOM_ID, + highlight: true, + isDM: true, + }); }); - it('declines even for an event the DM override would otherwise notify on', () => { - const room = createRoom(); - const mx = clientWithoutRules(room); - const event = createEvent(); - + it('applies the DM policy when push rules do not notify', () => { const result = evaluateNotification( - mx, - room, - event, + client({ notify: false, tweaks: {} }), + room(), + event(), new Set([ROOM_ID]), NotificationType.AllMessages ); - expect(result).toBeUndefined(); + expect(result?.isDM).toBe(true); }); - it('records the same event once rules are present', () => { - const room = createRoom(); - const event = createEvent(); - + 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( - createClient({ [ROOM_ID]: room }), - room, - event, + client(), + targetRoom as Room, + targetEvent as MatrixEvent, new Set(), - NotificationType.AllMessages + notificationType as NotificationType ) - ).toBeDefined(); - }); -}); - -describe('evaluateNotification exclusions', () => { - it('returns undefined for muted room', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent(); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.Mute); - - expect(result).toBeUndefined(); - }); - - it('returns undefined for space room', () => { - const room = createRoom({ isSpaceRoom: () => true }); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent(); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result).toBeUndefined(); - }); - - it('returns undefined for self-sender event', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent({ getSender: () => USER_ID }); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result).toBeUndefined(); - }); - - it('returns undefined for m.room.member event', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent({ getType: () => 'm.room.member' }); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result).toBeUndefined(); - }); - - it('returns undefined for redacted event', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent({ isRedacted: () => true }); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result).toBeUndefined(); - }); - - it('returns undefined for m.replace edit event', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent({ - getRelation: () => ({ rel_type: 'm.replace', event_id: '$orig' }), - }); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when isSending() is true', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent({ isSending: () => true }); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result).toBeUndefined(); - }); -}); - - -describe('evaluateNotification inclusions', () => { - it('returns StoredNotification for normal message with notify=true', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent(); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result).toBeDefined(); - expect(result!.room_id).toBe(ROOM_ID); - expect(result!.event.event_id).toBe(EVENT_ID); - expect(result!.event.type).toBe('m.room.message'); - expect(result!.event.content.body).toBe('Hello'); - expect(result!.highlight).toBe(false); - }); - - it('returns StoredNotification with highlight=true when tweaks.highlight is set', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }, { notify: true, tweaks: { highlight: true } }); - const event = createEvent(); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result).toBeDefined(); - expect(result!.highlight).toBe(true); - }); - - it('DM force-override: notify=false but isDM + not MentionsAndKeywords → returns snapshot', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }, { notify: false, tweaks: {} }); - const event = createEvent(); - const mDirects = new Set([ROOM_ID]); - - const result = evaluateNotification(mx, room, event, mDirects, NotificationType.AllMessages); - - expect(result).toBeDefined(); - expect(result!.room_id).toBe(ROOM_ID); - }); - - it('DM force-override: notify=false, isDM, but MentionsAndKeywords → returns undefined (override does NOT apply)', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }, { notify: false, tweaks: {} }); - const event = createEvent(); - const mDirects = new Set([ROOM_ID]); - - const result = evaluateNotification( - mx, - room, - event, - mDirects, - NotificationType.MentionsAndKeywords - ); - - expect(result).toBeUndefined(); - }); - - it('truncates body to MAX_BODY_LENGTH', () => { - const longBody = 'x'.repeat(1000); - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent({ - getContent: (() => ({ body: longBody, msgtype: 'm.text' })) as MatrixEvent['getContent'], - }); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result).toBeDefined(); - expect(result!.event.content.body).toBe(`${longBody.slice(0, MAX_BODY_LENGTH)}…`); - expect(result!.event.content.msgtype).toBe('m.text'); - }); - - it('drops html when the message is too long, so the preview matches what is stored', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent({ - getContent: (() => ({ - body: 'x'.repeat(1000), - formatted_body: `${'x'.repeat(1000)}`, - format: 'org.matrix.custom.html', - msgtype: 'm.text', - })) as MatrixEvent['getContent'], - }); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result!.event.content.formatted_body).toBeUndefined(); - expect(result!.event.content.format).toBeUndefined(); - expect(result!.event.content.body).toHaveLength(MAX_BODY_LENGTH + 1); - }); - - it('keeps html for short messages', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent({ - getContent: (() => ({ - body: 'hi', - formatted_body: 'hi', - format: 'org.matrix.custom.html', - msgtype: 'm.text', - })) as MatrixEvent['getContent'], - }); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result!.event.content.formatted_body).toBe('hi'); - expect(result!.event.content.body).toBe('hi'); - }); - - it('drops oversized html even when the plain body is short', () => { - const room = createRoom(); - const mx = createClient({ [ROOM_ID]: room }); - const event = createEvent({ - getContent: (() => ({ - body: 'short', - formatted_body: `${'x'.repeat(1000)}`, - format: 'org.matrix.custom.html', - msgtype: 'm.text', - })) as MatrixEvent['getContent'], - }); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result!.event.content.formatted_body).toBeUndefined(); - expect(result!.event.content.body).toBe('short'); + ).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 makeItem = ( - ts: number, - highlight = false, - extra: Partial = {} - ): StoredNotification => - ({ - room_id: ROOM_ID, - event: { - event_id: `$e${ts}`, - type: 'm.room.message', - content: {}, - sender: OTHER_USER, - origin_server_ts: ts, - room_id: ROOM_ID, - unsigned: {}, - }, - ts, - highlight, - ...extra, - }) as StoredNotification; - - it('first page: 30 items, offset=0, limit=24 → 24 items, nextToken="24"', () => { - const all = Array.from({ length: 30 }, (_, i) => makeItem(3000 - i)); - - const { page, nextToken } = sliceNotificationPage(all, 0, 24, 'all'); - - expect(page).toHaveLength(24); - expect(nextToken).toBe('24'); - }); - - it('second page: offset=24, limit=24 → 6 items, nextToken=undefined', () => { - const all = Array.from({ length: 30 }, (_, i) => makeItem(3000 - i)); - - const { page, nextToken } = sliceNotificationPage(all, 24, 24, 'all'); - - expect(page).toHaveLength(6); - expect(nextToken).toBeUndefined(); - }); - - it('last partial page: 50 items, offset=48, limit=24 → 2 items, nextToken=undefined', () => { - const all = Array.from({ length: 50 }, (_, i) => makeItem(5000 - i)); - - const { page, nextToken } = sliceNotificationPage(all, 48, 24, 'all'); - - expect(page).toHaveLength(2); - expect(nextToken).toBeUndefined(); - }); - - it('onlyHighlight=true filters non-highlight items', () => { - const all = [ - makeItem(5, false), - makeItem(4, true), - makeItem(3, false), - makeItem(2, true), - makeItem(1, false), - ]; - - const { page } = sliceNotificationPage(all, 0, 5, 'mentions'); - - expect(page).toHaveLength(2); - expect(page.every((n) => n.highlight)).toBe(true); - }); - - it("'mentions' keeps DMs that are not highlights", () => { - // The default filter is highlights *and* DMs; a plain DM message has no - // highlight tweak and must still appear. - const all = [ - makeItem(3, false, { isDM: true }), - makeItem(2, true), - makeItem(1, false, { isDM: false }), - ]; - - const { page } = sliceNotificationPage(all, 0, 5, 'mentions'); - - expect(page.map((n) => n.ts)).toEqual([3, 2]); - }); - - it('hides dismissed entries unless includeDone is set', () => { - const all = [makeItem(2, true, { dismissed: true }), makeItem(1, true)]; - - expect(sliceNotificationPage(all, 0, 5, 'mentions').page.map((n) => n.ts)).toEqual([1]); - expect(sliceNotificationPage(all, 0, 5, 'mentions', true).page.map((n) => n.ts)).toEqual([ - 2, 1, - ]); - }); - - it('returns items newest-first (sorted by ts descending)', () => { - const all = [makeItem(10), makeItem(5), makeItem(20), makeItem(15)]; - - const { page } = sliceNotificationPage(all, 0, 4, 'all'); - - expect(page.map((n) => n.ts)).toEqual([20, 15, 10, 5]); + 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', () => { - const entry = (ts = 1000): StoredNotification => - ({ room_id: ROOM_ID, event: { event_id: EVENT_ID }, ts }) as StoredNotification; - - const readStateRoom = (overrides: Partial): Room => - createRoom({ - findEventById: () => undefined, - getEventReadUpTo: () => null, - getReadReceiptForUserId: () => null, - hasUserReadEvent: () => false, - ...overrides, - } as Partial); - - it('defers to hasUserReadEvent while the event is in the timeline', () => { - const room = readStateRoom({ - findEventById: ((id: string) => - id === EVENT_ID ? createEvent() : undefined) as Room['findEventById'], - hasUserReadEvent: (() => true) as Room['hasUserReadEvent'], - }); - - expect(isStoredNotificationRead(room, USER_ID, entry())).toBe(true); - }); - - it('reports unread while the event is in the timeline and unread', () => { - const room = readStateRoom({ - findEventById: (() => createEvent()) as Room['findEventById'], - hasUserReadEvent: (() => false) as Room['hasUserReadEvent'], - }); - - expect(isStoredNotificationRead(room, USER_ID, entry())).toBe(false); - }); - - it('falls back to the receipt timestamp once the event has aged out', () => { - const room = readStateRoom({ - getReadReceiptForUserId: (() => ({ - eventId: '$readUpTo', - data: { ts: 5000 }, - })) as unknown as Room['getReadReceiptForUserId'], - }); - - expect(isStoredNotificationRead(room, USER_ID, entry(1000))).toBe(true); - expect(isStoredNotificationRead(room, USER_ID, entry(9000))).toBe(false); - }); - - it('treats an unresolvable entry as unread', () => { - const room = readStateRoom({}); - - expect(isStoredNotificationRead(room, USER_ID, entry())).toBe(false); - }); - - it('honours a private receipt from a manual mark-as-read', () => { - const room = readStateRoom({ - getReadReceiptForUserId: ((_userId: string, _ignore?: boolean, type?: string) => - type === 'm.read.private' - ? { eventId: '$private', data: { ts: 5000 } } - : null) as unknown as Room['getReadReceiptForUserId'], - }); - - expect(isStoredNotificationRead(room, USER_ID, entry(1000))).toBe(true); - expect(isStoredNotificationRead(room, USER_ID, entry(9000))).toBe(false); - }); - - it('takes the newest of the public and private receipts', () => { - const room = readStateRoom({ - getReadReceiptForUserId: ((_userId: string, _ignore?: boolean, type?: string) => - type === 'm.read.private' - ? { eventId: '$private', data: { ts: 8000 } } - : { - eventId: '$public', - data: { ts: 2000 }, - }) as unknown as Room['getReadReceiptForUserId'], - }); - - expect(isStoredNotificationRead(room, USER_ID, entry(5000))).toBe(true); - }); - - it('honours a mark-as-read while the event is still loaded', () => { - const room = readStateRoom({ - findEventById: (() => createEvent()) as Room['findEventById'], - hasUserReadEvent: (() => true) as Room['hasUserReadEvent'], - getReadReceiptForUserId: (() => null) as unknown as Room['getReadReceiptForUserId'], + it('uses the SDK read relation when the event is loaded', () => { + const targetRoom = room({ + findEventById: () => event(), + hasUserReadEvent: () => true, }); - - expect(isStoredNotificationRead(room, USER_ID, entry())).toBe(true); + expect(isStoredNotificationRead(targetRoom, USER_ID, stored('$event', 100))).toBe(true); }); -}); - -describe('evaluateNotification storage footprint', () => { - const encryptedEvent = (content: Record) => - createEvent({ - getType: () => 'm.room.encrypted', - getContent: (() => content) as MatrixEvent['getContent'], - }); - it('drops megolm ciphertext and keeps only the algorithm', () => { - const room = createRoom({}, true); - const mx = createClient({ [ROOM_ID]: room }); - const event = encryptedEvent({ - algorithm: 'm.megolm.v1.aes-sha2', - ciphertext: 'A'.repeat(4096), - sender_key: 'curve25519:abc', - session_id: 'session', + it('falls back to receipt timestamps for cached events', () => { + const targetRoom = room({ + findEventById: () => undefined, + getReadReceiptForUserId: () => ({ data: { ts: 100 } }) as never, }); - - const result = evaluateNotification(mx, room, event, new Set(), NotificationType.AllMessages); - - expect(result!.event.type).toBe('m.room.encrypted'); - expect(result!.event.content).toEqual({ algorithm: 'm.megolm.v1.aes-sha2' }); - }); - - it('drops the payload even when the algorithm is absent', () => { - const room = createRoom({}, true); - const mx = createClient({ [ROOM_ID]: room }); - - const result = evaluateNotification( - mx, - room, - encryptedEvent({ ciphertext: 'B'.repeat(2048) }), - new Set(), - NotificationType.AllMessages - ); - - expect(result!.event.content).toEqual({}); + expect(isStoredNotificationRead(targetRoom, USER_ID, stored('$event', 100))).toBe(true); }); }); diff --git a/src/app/utils/localNotifications.ts b/src/app/utils/localNotifications.ts index aea8c78ebf..4942241771 100644 --- a/src/app/utils/localNotifications.ts +++ b/src/app/utils/localNotifications.ts @@ -1,4 +1,3 @@ - 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'; @@ -10,14 +9,15 @@ export type StoredNotification = { ts: number; highlight: boolean; isDM: boolean; - dismissed?: 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 typeof content.algorithm === 'string' ? { algorithm: content.algorithm } : {}; + return { ...content }; } if (!storeContent) { @@ -166,43 +166,19 @@ export const sliceNotificationPage = ( all: StoredNotification[], offset: number, limit: number, - filterMode: 'all' | 'mentions', - includeDone?: boolean + tab: NotificationTab, + includeRead: boolean, + isRead: (entry: StoredNotification) => boolean ): { page: StoredNotification[]; nextToken?: string } => { - let filtered = all; - if (filterMode === 'mentions') filtered = filtered.filter((n) => n.highlight || n.isDM); - if (!includeDone) filtered = filtered.filter((n) => !n.dismissed); - const sorted = [...filtered].toSorted((a, b) => b.ts - a.ts); + 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 }; }; - - -export const GAP_THRESHOLD_MS = 5 * 60 * 1000; -export const MAX_BACKFILL_ROOMS = 30; -const GAP_PAGES_MULTIPLIER_MS = 30 * 60 * 1000; - -export type BackfillRoomInfo = { - roomId: string; - lastActiveTs: number; - isSpaceRoom: boolean; - isMuted: boolean; -}; - -export const shouldBackfill = (lastSeenTs: number | undefined, now: number): number | undefined => { - if (lastSeenTs === undefined) return undefined; - if (now - lastSeenTs < GAP_THRESHOLD_MS) return undefined; - return lastSeenTs; -}; - -export const selectBackfillRooms = (rooms: BackfillRoomInfo[], lastSeenTs: number): string[] => - rooms - .filter((r) => !r.isSpaceRoom && !r.isMuted && r.lastActiveTs > lastSeenTs) - .toSorted((a, b) => b.lastActiveTs - a.lastActiveTs) - .slice(0, MAX_BACKFILL_ROOMS) - .map((r) => r.roomId); - -export const backfillPageCount = (lastSeenTs: number, now: number): number => - now - lastSeenTs > GAP_PAGES_MULTIPLIER_MS ? 2 : 1; 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/app/utils/throttleTrailing.ts b/src/app/utils/throttleTrailing.ts deleted file mode 100644 index fde113684f..0000000000 --- a/src/app/utils/throttleTrailing.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type Throttled = (() => void) & { cancel: () => void }; - -/** Runs immediately when idle, otherwise once more at the end of the window. */ -export const throttleTrailing = (fn: () => void, waitMs: number): Throttled => { - let trailing: ReturnType | undefined; - let lastRun = 0; - - const run = () => { - lastRun = Date.now(); - fn(); - }; - - const throttled = () => { - const elapsed = Date.now() - lastRun; - if (elapsed >= waitMs) { - run(); - return; - } - if (trailing !== undefined) return; - trailing = setTimeout(() => { - trailing = undefined; - run(); - }, waitMs - elapsed); - }; - - throttled.cancel = () => { - clearTimeout(trailing); - trailing = undefined; - }; - - return throttled; -}; diff --git a/src/client/localNotificationCache.test.ts b/src/client/localNotificationCache.test.ts index 2e1dc85d43..1d088d7d1b 100644 --- a/src/client/localNotificationCache.test.ts +++ b/src/client/localNotificationCache.test.ts @@ -1,312 +1,63 @@ -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { StoredNotification } from '$utils/localNotifications'; -import { clearLocalNotificationCache, LocalNotificationCache } from './localNotificationCache'; - -const userId = '@user:example.com'; -const userId2 = '@other:example.com'; - -const makeEntry = ( - eventId: string, - roomId = '!room:example.com', - ts = Date.now(), - highlight = false, - dismissed?: boolean, - isDM = false -): StoredNotification => ({ - room_id: roomId, - event: { - event_id: eventId, - type: 'm.room.message', - content: { body: `test ${eventId}` }, - sender: '@user:example.com', - origin_server_ts: ts, - unsigned: {}, - }, - ts, - highlight, - isDM, - dismissed, -}); +import { LocalNotificationCache } from './localNotificationCache'; +const USER_ID = '@me:example.org'; const caches: LocalNotificationCache[] = []; -const openCache = (id: string): LocalNotificationCache => { - const cache = new LocalNotificationCache(id); + +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(); -}); - +beforeEach(() => localStorage.clear()); afterEach(() => { - while (caches.length > 0) caches.pop()?.destroy(); - vi.restoreAllMocks(); + caches.splice(0).forEach((cache) => cache.destroy()); localStorage.clear(); }); describe('LocalNotificationCache', () => { - it('dedup by event_id', () => { - const cache = openCache(userId); - const entry = makeEntry('$ev1'); - cache.merge(entry); - cache.merge(entry); - expect(cache.getEntries()).toHaveLength(1); - }); - - it('newest-first ordering', () => { - const cache = openCache(userId); - cache.merge(makeEntry('$ev1', '!room:example.com', 100)); - cache.merge(makeEntry('$ev2', '!room:example.com', 200)); - cache.merge(makeEntry('$ev3', '!room:example.com', 50)); - const tss = cache.getEntries().map((e) => e.ts); - expect(tss).toEqual([200, 100, 50]); - }); - - it('MAX_ENTRIES truncation (oldest dropped)', () => { - const cache = openCache(userId); - for (let i = 0; i < 310; i++) { - cache.merge(makeEntry(`$ev${i}`, '!room:example.com', i)); - } - const entries = cache.getEntries(); - expect(entries).toHaveLength(300); - expect(entries.at(0)?.ts).toBe(309); - expect(entries.at(-1)?.ts).toBe(10); - }); - - it('round-trip via destroy() flush', () => { - const cache = openCache(userId); - for (let i = 0; i < 5; i++) { - cache.merge(makeEntry(`$ev${i}`, '!room:example.com', i)); - } - cache.destroy(); - - const restored = openCache(userId); - expect(restored.getEntries()).toHaveLength(5); - expect(restored.getEntries().map((e) => e.event.event_id)).toEqual([ - '$ev4', - '$ev3', - '$ev2', - '$ev1', - '$ev0', + 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('keeps removals out of storage', () => { - const cache = openCache(userId); - cache.merge(makeEntry('$ev1')); - cache.merge(makeEntry('$ev2')); - cache.destroy(); - - const reopened = openCache(userId); - reopened.remove('$ev1'); - reopened.destroy(); - - const restored = openCache(userId); - expect(restored.getEntries().map((e) => e.event.event_id)).toEqual(['$ev2']); - }); - - it('reinstates a removed entry when it is recorded again', () => { - const cache = openCache(userId); - cache.merge(makeEntry('$ev1')); - cache.remove('$ev1'); - cache.merge(makeEntry('$ev1')); - cache.destroy(); - - const restored = openCache(userId); - expect(restored.getEntries().map((e) => e.event.event_id)).toEqual(['$ev1']); - }); - - it('version mismatch returns empty', () => { - const key = `sable.notificationCache.v1.${encodeURIComponent(userId)}`; - localStorage.setItem(key, JSON.stringify({ version: 999, entries: [makeEntry('$ev1')] })); - const cache = openCache(userId); - expect(cache.getEntries()).toEqual([]); - }); - - it('corrupt JSON returns empty', () => { - const key = `sable.notificationCache.v1.${encodeURIComponent(userId)}`; - localStorage.setItem(key, '{not json'); - const cache = openCache(userId); - expect(cache.getEntries()).toEqual([]); - }); - - it('QuotaExceededError retry with halving', () => { - const cache = openCache(userId); - for (let i = 0; i < 100; i++) { - cache.merge(makeEntry(`$ev${i}`, '!room:example.com', i)); - } - - let calls = 0; - const spy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - calls++; - throw new DOMException('Quota exceeded', 'QuotaExceededError'); - }); - - cache.destroy(); - - spy.mockRestore(); - - expect(calls).toBeGreaterThanOrEqual(7); - }); - - it('clear(userId) touches only that key', () => { - const cacheA = openCache(userId); - const cacheB = openCache(userId2); - cacheA.merge(makeEntry('$evA')); - cacheB.merge(makeEntry('$evB')); - cacheA.destroy(); - cacheB.destroy(); - - clearLocalNotificationCache(userId); - - const restoredA = openCache(userId); - const restoredB = openCache(userId2); - expect(restoredA.getEntries()).toEqual([]); - expect(restoredB.getEntries()).toHaveLength(1); - }); - - it('cross-tab: two instances merging concurrently both survive', () => { - const cacheA = openCache(userId); - const cacheB = openCache(userId); - cacheA.merge(makeEntry('$evX', '!room:example.com', 100)); - cacheB.merge(makeEntry('$evY', '!room:example.com', 200)); - cacheA.destroy(); - cacheB.destroy(); - - const key = `sable.notificationCache.v1.${encodeURIComponent(userId)}`; - const raw = localStorage.getItem(key); - expect(raw).not.toBeNull(); - const parsed = JSON.parse(raw!); - const eventIds = parsed.entries.map((e: StoredNotification) => e.event.event_id); - expect(eventIds).toContain('$evX'); - expect(eventIds).toContain('$evY'); - }); - - it('dismissed flag preserved on re-record', () => { - const cache = openCache(userId); - const entry = makeEntry('$ev1'); - cache.merge(entry); - cache.dismiss('$ev1'); - cache.merge(makeEntry('$ev1')); - expect(cache.getEntries().at(0)?.dismissed).toBe(true); - }); - - it('dismissing one entry leaves siblings untouched', () => { - const cache = openCache(userId); - const x = makeEntry('$evX', '!room:example.com', 100, false); - const y = makeEntry('$evY', '!room:example.com', 200, false); - const z = makeEntry('$evZ', '!room:example.com', 300, false); - cache.merge(x); - cache.merge(y); - cache.merge(z); - cache.dismiss('$evY'); - - const entries = cache.getEntries(); - const find = (id: string) => entries.find((e) => e.event.event_id === id)!; - expect(find('$evX').dismissed).toBeFalsy(); - expect(find('$evY').dismissed).toBe(true); - expect(find('$evZ').dismissed).toBeFalsy(); - }); - - it('badge count excludes dismissed and non-highlights', () => { - const cache = openCache(userId); - cache.merge(makeEntry('$ev1', '!room:example.com', 100, true, false)); // highlight, undismissed - cache.merge(makeEntry('$ev2', '!room:example.com', 200, true, true)); // highlight, dismissed - cache.merge(makeEntry('$ev3', '!room:example.com', 300, false, false)); // non-highlight, undismissed - - const undismissedHighlights = cache.getEntries().filter((e) => e.highlight && !e.dismissed); - expect(undismissedHighlights).toHaveLength(1); - expect(undismissedHighlights.at(0)?.event.event_id).toBe('$ev1'); - }); - - it('lastSeenTs with 60s throttle', () => { - const cache = openCache(userId); - cache.updateLastSeenTs(1000); - expect(cache.getLastSeenTs()).toBe(1000); - - cache.updateLastSeenTs(1001); - expect(cache.getLastSeenTs()).toBe(1000); - - cache.updateLastSeenTs(61001); - expect(cache.getLastSeenTs()).toBe(61001); - }); - - it('subscribe notifies on merge', () => { - const cache = openCache(userId); - const listener = vi.fn<() => void>(); - cache.subscribe(listener); - cache.merge(makeEntry('$ev1')); - expect(listener).toHaveBeenCalledTimes(1); - }); - - it('persists lastSeenTs across a flush and reload', () => { - const cache = openCache(userId); - cache.updateLastSeenTs(12345); - cache.merge(makeEntry('$ev1')); + it('persists entries', () => { + const cache = open(); + cache.merge(entry('$a', 1)); cache.destroy(); - const restored = openCache(userId); - expect(restored.getLastSeenTs()).toBe(12345); - }); -}); - -describe('LocalNotificationCache batching', () => { - it('notifies once for a whole batch', () => { - const cache = openCache(userId); - const listener = vi.fn<() => void>(); - cache.subscribe(listener); - - cache.mergeMany([makeEntry('$a', '!r:e.com', 3), makeEntry('$b', '!r:e.com', 2)]); - - expect(listener).toHaveBeenCalledTimes(1); - expect(cache.getEntries()).toHaveLength(2); - }); - - it('does not notify for an empty batch', () => { - const cache = openCache(userId); - const listener = vi.fn<() => void>(); - cache.subscribe(listener); - - cache.mergeMany([]); - - expect(listener).not.toHaveBeenCalled(); + const restored = open(); + expect(restored.getEntries()[0]?.event.event_id).toBe('$a'); }); - it('keeps entries newest-first across a batch', () => { - const cache = openCache(userId); - - cache.mergeMany([ - makeEntry('$old', '!r:e.com', 100), - makeEntry('$new', '!r:e.com', 300), - makeEntry('$mid', '!r:e.com', 200), - ]); - - expect(cache.getEntries().map((e) => e.event.event_id)).toEqual(['$new', '$mid', '$old']); + 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('dedupes within a batch and preserves an existing dismissal', () => { - const cache = openCache(userId); - cache.merge(makeEntry('$a', '!r:e.com', 100)); - cache.dismiss('$a'); - - cache.mergeMany([makeEntry('$a', '!r:e.com', 100), makeEntry('$a', '!r:e.com', 100)]); - - const entries = cache.getEntries(); - expect(entries).toHaveLength(1); - expect(entries[0]!.dismissed).toBe(true); - }); - - it('counts in place without copying entries', () => { - const cache = openCache(userId); - cache.mergeMany([ - makeEntry('$a', '!r:e.com', 300, true), - makeEntry('$b', '!r:e.com', 200, false), - makeEntry('$c', '!r:e.com', 100, true, true), - ]); - - expect(cache.countEntries((e) => e.highlight)).toBe(2); - expect(cache.countEntries((e) => e.highlight && !e.dismissed)).toBe(1); - expect(cache.countEntries(() => false)).toBe(0); + 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 index 23ed57ef90..d046ad2eab 100644 --- a/src/client/localNotificationCache.ts +++ b/src/client/localNotificationCache.ts @@ -1,17 +1,14 @@ -import type { StoredNotification } from '$utils/localNotifications'; +import type { NotificationTab, StoredNotification } from '$utils/localNotifications'; export const NOTIFICATION_CACHE_KEY_PREFIX = 'sable.notificationCache.'; -const CACHE_VERSION = 1; -const MAX_ENTRIES = 300; -const CACHE_WRITE_DELAY_MS = 500; -const STORAGE_EVENT_DEBOUNCE_MS = 200; -const HEARTBEAT_THROTTLE_MS = 60_000; +const CACHE_VERSION = 4; +const MAX_ENTRIES = 5_000; type CacheData = { version: number; entries: StoredNotification[]; - lastSeenTs?: number; + historyCutoffs?: Partial>; }; const storageKeyFor = (userId: string): string => @@ -19,153 +16,62 @@ const storageKeyFor = (userId: string): string => const emptyCache = (): CacheData => ({ version: CACHE_VERSION, entries: [] }); -const parseCache = (value: string | null): CacheData => { - if (!value) return emptyCache(); +const readCache = (key: string): CacheData => { try { - const parsed = JSON.parse(value) as Partial; - if (parsed.version !== CACHE_VERSION || !Array.isArray(parsed.entries)) { - return emptyCache(); - } - return { version: CACHE_VERSION, entries: parsed.entries, lastSeenTs: parsed.lastSeenTs }; + 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(); } }; -const newestTs = (a: number | undefined, b: number | undefined): number | undefined => { - if (a === undefined) return b; - if (b === undefined) return a; - return Math.max(a, b); -}; - -type IdleWindow = Window & - typeof globalThis & { - requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; - cancelIdleCallback?: (handle: number) => void; - }; - export class LocalNotificationCache { private data: CacheData; - private dirty: Set = new Set(); - private removed: Set = new Set(); - readonly userId: string; - private readonly storageKey: string; - private listeners: Set<() => void> = new Set(); - private writeTimeoutId: ReturnType | undefined; - private idleCallbackId: number | undefined; - private storageDebounceId: ReturnType | undefined; - private lastHeartbeatTs: number | undefined; - private destroyed = false; - private quotaExhausted = false; + private readonly key: string; + private readonly listeners = new Set<() => void>(); - constructor(userId: string) { - this.userId = userId; - this.storageKey = storageKeyFor(userId); - this.data = this.readStored(); - window.addEventListener('storage', this.onStorageEvent); + 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]); } - /** One sort, one write and one notification for the whole batch. */ mergeMany(entries: StoredNotification[]): void { - if (this.destroyed || entries.length === 0) return; - - const indexByEventId = new Map( - this.data.entries.map((entry, index) => [entry.event.event_id, index]) + if (entries.length === 0) return; + const merged = new Map( + [...this.data.entries, ...entries].map((entry) => [entry.event.event_id, entry]) ); - let reorder = false; - - for (const entry of entries) { - const eventId = entry.event.event_id; - this.removed.delete(eventId); - - const idx = indexByEventId.get(eventId); - if (idx === undefined) { - indexByEventId.set(eventId, this.data.entries.length); - this.data.entries.push(entry); - reorder = true; - } else { - const existing = this.data.entries[idx]!; - // A replacement can carry a different ts, which invalidates the order. - if (existing.ts !== entry.ts) reorder = true; - this.data.entries[idx] = { ...entry, dismissed: existing.dismissed || entry.dismissed }; - } - - this.dirty.add(eventId); - } - - if (reorder) { - this.data.entries.sort((a, b) => b.ts - a.ts); - if (this.data.entries.length > MAX_ENTRIES) { - this.data.entries.length = MAX_ENTRIES; - } - } - - this.scheduleWrite(); - this.notifyListeners(); + 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 })); } - /** Counts in place — getEntries() would copy every entry just to discard it. */ - countEntries(predicate: (entry: StoredNotification) => boolean): number { - let count = 0; - for (const entry of this.data.entries) { - if (predicate(entry)) count += 1; - } - return count; - } - - remove(eventId: string): void { - if (this.destroyed) return; - const idx = this.data.entries.findIndex((e) => e.event.event_id === eventId); - if (idx !== -1) this.data.entries.splice(idx, 1); - // Tombstone even when absent here — another tab may still have it on disk. - this.dirty.delete(eventId); - this.removed.add(eventId); - this.scheduleWrite(); - this.notifyListeners(); - } - - getLastSeenTs(): number | undefined { - return this.data.lastSeenTs; + getHistoryCutoff(tab: NotificationTab): number | undefined { + return this.data.historyCutoffs?.[tab]; } - updateLastSeenTs(ts: number): void { - if (this.destroyed) return; - if (this.lastHeartbeatTs !== undefined && ts - this.lastHeartbeatTs < HEARTBEAT_THROTTLE_MS) { - return; - } - this.lastHeartbeatTs = ts; - this.data = { ...this.data, lastSeenTs: ts }; - this.scheduleWrite(); - } - - dismiss(eventId: string): void { - if (this.destroyed) return; - const entry = this.data.entries.find((e) => e.event.event_id === eventId); - if (!entry) return; - entry.dismissed = true; - this.dirty.add(eventId); - this.scheduleWrite(); - this.notifyListeners(); - } - - dismissAllInRoom(roomId: string): void { - if (this.destroyed) return; - for (const entry of this.data.entries) { - if (entry.room_id === roomId) { - entry.dismissed = true; - this.dirty.add(entry.event.event_id); - } - } - this.scheduleWrite(); - this.notifyListeners(); + 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 { @@ -174,157 +80,55 @@ export class LocalNotificationCache { } destroy(): void { - if (this.writeTimeoutId !== undefined) { - clearTimeout(this.writeTimeoutId); - this.writeTimeoutId = undefined; - } - if (this.idleCallbackId !== undefined) { - (globalThis as IdleWindow).cancelIdleCallback?.(this.idleCallbackId); - this.idleCallbackId = undefined; - } - if (this.storageDebounceId !== undefined) { - clearTimeout(this.storageDebounceId); - this.storageDebounceId = undefined; - } - this.write(); - this.destroyed = true; - window.removeEventListener('storage', this.onStorageEvent); + globalThis.removeEventListener?.('storage', this.onStorage); this.listeners.clear(); } - private notifyListeners(): void { - for (const listener of this.listeners) listener(); - } - - private readStored(): CacheData { - try { - return parseCache(globalThis.localStorage?.getItem(this.storageKey) ?? null); - } catch { - // Storage can be disabled for this origin. - return emptyCache(); - } - } - - /** Replays unflushed local changes on top of a snapshot read from storage. */ - private applyPending(base: StoredNotification[]): StoredNotification[] { - const merged = base.filter((entry) => !this.removed.has(entry.event.event_id)); - - for (const eventId of this.dirty) { - const entry = this.data.entries.find((e) => e.event.event_id === eventId); - if (!entry) continue; - const idx = merged.findIndex((m) => m.event.event_id === eventId); - const existing = idx === -1 ? undefined : merged[idx]; - if (existing) { - merged[idx] = { ...entry, dismissed: existing.dismissed || entry.dismissed }; - } else { - merged.push(entry); - } - } - - merged.sort((a, b) => b.ts - a.ts); - return merged.slice(0, MAX_ENTRIES); - } - - private scheduleWrite(): void { - if (this.writeTimeoutId !== undefined || this.idleCallbackId !== undefined) return; - this.writeTimeoutId = setTimeout(() => { - this.writeTimeoutId = undefined; - const idleWindow = globalThis as IdleWindow; - if (typeof idleWindow.requestIdleCallback === 'function') { - this.idleCallbackId = idleWindow.requestIdleCallback( - () => { - this.idleCallbackId = undefined; - this.write(); - }, - { timeout: 2000 } - ); - return; - } - this.write(); - }, CACHE_WRITE_DELAY_MS); - } - private write(): void { - // Latched after a total quota failure: without this every later write - // replays the whole halving loop, ~9 stringify+setItem attempts each time. - if (this.quotaExhausted) return; - - const stored = this.readStored(); - const lastSeenTs = newestTs(this.data.lastSeenTs, stored.lastSeenTs); - - let entries = this.applyPending(stored.entries); + let entries = this.data.entries; for (;;) { - const nextData: CacheData = { version: CACHE_VERSION, entries, lastSeenTs }; try { - globalThis.localStorage?.setItem(this.storageKey, JSON.stringify(nextData)); - this.commit(nextData); + globalThis.localStorage?.setItem(this.key, JSON.stringify({ ...this.data, entries })); + this.data.entries = entries; return; } catch { - if (entries.length <= 1) { - // Out of quota even at a single entry. Give the space back rather than - // competing with the session token write, which has no such fallback. - this.quotaExhausted = true; - try { - globalThis.localStorage?.removeItem(this.storageKey); - } catch { - // Nothing further we can do. - } - return; - } + if (entries.length === 0) return; entries = entries.slice(0, Math.floor(entries.length / 2)); } } } - private commit(next: CacheData): void { - this.data = next; - this.dirty.clear(); - this.removed.clear(); + private notify(): void { + this.listeners.forEach((listener) => listener()); } - private onStorageEvent = (e: StorageEvent): void => { - if (e.key !== this.storageKey) return; - if (this.storageDebounceId !== undefined) { - clearTimeout(this.storageDebounceId); - } - this.storageDebounceId = setTimeout(() => { - this.storageDebounceId = undefined; - const disk = this.readStored(); - this.data = { - version: CACHE_VERSION, - entries: this.applyPending(disk.entries), - lastSeenTs: newestTs(this.data.lastSeenTs, disk.lastSeenTs), - }; - this.notifyListeners(); - }, STORAGE_EVENT_DEBOUNCE_MS); + private onStorage = (event: StorageEvent): void => { + if (event.key !== this.key) return; + this.data = readCache(this.key); + this.notify(); }; } -export function clearLocalNotificationCache(userId: string): void { - try { - globalThis.localStorage?.removeItem(storageKeyFor(userId)); - } catch { - // Storage can be disabled for this origin; logout must continue regardless. - } - instances.delete(userId); -} - -// Singleton keyed by userId so the recorder and the timeline hook share one instance. const instances = new Map(); export const getLocalNotificationCache = (userId: string): LocalNotificationCache => { - let cache = instances.get(userId); - if (!cache) { - cache = new LocalNotificationCache(userId); - instances.set(userId, cache); - } + 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 => { - const cache = instances.get(userId); - if (cache) { - cache.destroy(); - instances.delete(userId); + 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. } }; From 1c506956900e325e0c571ddd310fa51e383672f6 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 8 Aug 2026 23:20:12 -0500 Subject: [PATCH 6/6] chore: format sidebar cache --- src/client/slidingSyncSidebarCache.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/client/slidingSyncSidebarCache.ts b/src/client/slidingSyncSidebarCache.ts index 10c1a03d45..d75f0ca347 100644 --- a/src/client/slidingSyncSidebarCache.ts +++ b/src/client/slidingSyncSidebarCache.ts @@ -169,7 +169,9 @@ const hydrateRoomBatch = async ( export class SlidingSyncSidebarCache { public static clear(userId: string): void { try { - globalThis.localStorage?.removeItem(`${SIDEBAR_CACHE_KEY_PREFIX}${encodeURIComponent(userId)}`); + globalThis.localStorage?.removeItem( + `${SIDEBAR_CACHE_KEY_PREFIX}${encodeURIComponent(userId)}` + ); } catch { // Storage can be disabled for this origin. }