import { useEffect, useState } from 'react'; import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk'; import { AccountDataEvent } from '../../types/matrix/accountData'; import { getAccountData, setAccountData } from '../utils/accountData'; // Generic, concurrency-safe store for a single global account-data event that // holds a mutable collection (a list or a record). It is the shared engine // behind useBookmarks / useReminders / useUserNotes. // // Why a module-scoped singleton (per store, per client): // // These hooks are mounted by many components at once (e.g. useBookmarks() lives // on every message row). A per-instance snapshot + write queue would only // serialize writes within a single instance, so two edits issued from different // instances before the server echo lands would each compute from a stale // snapshot and clobber each other — setAccountData replaces the whole content // with no server-side merge. We therefore keep ONE shared `latest` snapshot and // ONE write queue per store, keyed off the active MatrixClient, and serialize // every write through that queue (the lost-update fix). Do not weaken this. // How the differing payload shapes are parameterized: // - `read` maps the raw account-data content (C | undefined) to the in-memory // value T (e.g. `content?.bookmarks ?? []`, or `content ?? {}` for notes). // - `write` maps the in-memory value T back to the content object to persist // (e.g. `{ bookmarks: value }`, or just `value` for the flat notes record). export type AccountDataListStoreConfig = { eventType: AccountDataEvent | string; read: (content: C | undefined) => T; write: (value: T) => object; }; export type AccountDataListStore = { /** React hook: subscribe to the store and return the latest value. */ useValue: (mx: MatrixClient) => T; /** Read the current shared snapshot without subscribing. */ getLatest: (mx: MatrixClient) => T; /** * Serialize a write. `compute` receives the latest snapshot and returns the * next value; writes run one at a time so none is computed from stale data. */ enqueueWrite: (mx: MatrixClient, compute: (current: T) => T) => Promise; }; export function createAccountDataListStore({ eventType, read, write, }: AccountDataListStoreConfig): AccountDataListStore { type ModuleState = { mx: MatrixClient; latest: T; writeQueue: Promise; listeners: Set<(value: T) => void>; onAccountData: ClientEventHandlerMap[ClientEvent.AccountData]; }; let moduleState: ModuleState | null = null; const readLatest = (mx: MatrixClient): T => read(getAccountData(mx, eventType)); // Lazily initialize the shared state for the given client. On a client change // (login/logout swaps the MatrixClient) we tear down the old subscription and // re-initialize against the new client so we never leak or double-subscribe. function ensureModuleState(mx: MatrixClient): ModuleState { if (moduleState && moduleState.mx === mx) { return moduleState; } if (moduleState) { moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData); } const state: ModuleState = { mx, latest: readLatest(mx), writeQueue: Promise.resolve(), listeners: new Set(), // Reassigned below once `state` is captured. onAccountData: () => undefined, }; state.onAccountData = (evt) => { if (evt.getType() === eventType) { const value = read(evt.getContent() as C); state.latest = value; state.listeners.forEach((listener) => listener(value)); } }; mx.on(ClientEvent.AccountData, state.onAccountData); moduleState = state; return state; } function enqueueWrite(mx: MatrixClient, compute: (current: T) => T): Promise { const state = ensureModuleState(mx); const run = state.writeQueue.then(async () => { const next = compute(state.latest); state.latest = next; state.listeners.forEach((listener) => listener(next)); await setAccountData(mx, eventType, write(next)); }); // Keep the chain alive even if one write rejects, but propagate the // rejection to this caller so it can react (e.g. retry). state.writeQueue = run.catch(() => undefined); return run; } function useValue(mx: MatrixClient): T { const [value, setValue] = useState(() => ensureModuleState(mx).latest); // Subscribe to the shared module state. A single AccountData listener is // installed per client (in ensureModuleState); each hook instance only // registers a local setter and unregisters it on unmount / client change. useEffect(() => { const state = ensureModuleState(mx); setValue(state.latest); state.listeners.add(setValue); return () => { state.listeners.delete(setValue); }; }, [mx]); return value; } const getLatest = (mx: MatrixClient): T => ensureModuleState(mx).latest; return { useValue, getLatest, enqueueWrite }; }