131 lines
5.0 KiB
TypeScript
131 lines
5.0 KiB
TypeScript
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<T, C> = {
|
||
|
|
eventType: AccountDataEvent | string;
|
||
|
|
read: (content: C | undefined) => T;
|
||
|
|
write: (value: T) => object;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type AccountDataListStore<T> = {
|
||
|
|
/** 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<void>;
|
||
|
|
};
|
||
|
|
|
||
|
|
export function createAccountDataListStore<T, C = unknown>({
|
||
|
|
eventType,
|
||
|
|
read,
|
||
|
|
write,
|
||
|
|
}: AccountDataListStoreConfig<T, C>): AccountDataListStore<T> {
|
||
|
|
type ModuleState = {
|
||
|
|
mx: MatrixClient;
|
||
|
|
latest: T;
|
||
|
|
writeQueue: Promise<unknown>;
|
||
|
|
listeners: Set<(value: T) => void>;
|
||
|
|
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
||
|
|
};
|
||
|
|
|
||
|
|
let moduleState: ModuleState | null = null;
|
||
|
|
|
||
|
|
const readLatest = (mx: MatrixClient): T => read(getAccountData<C>(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<void> {
|
||
|
|
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<T>(() => 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 };
|
||
|
|
}
|