2026-06-14 00:47:14 -04:00
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
2026-07-02 20:56:27 -04:00
|
|
|
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
2026-06-14 00:47:14 -04:00
|
|
|
import { useMatrixClient } from './useMatrixClient';
|
|
|
|
|
|
|
|
|
|
const NOTES_KEY = 'io.lotus.user_notes';
|
|
|
|
|
export const USER_NOTE_MAX_LENGTH = 500;
|
|
|
|
|
|
|
|
|
|
type UserNotesContent = Record<string, string>;
|
|
|
|
|
|
|
|
|
|
function readNotes(mx: MatrixClient): UserNotesContent {
|
|
|
|
|
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 20:56:27 -04:00
|
|
|
// Module-scoped serialization state.
|
|
|
|
|
//
|
|
|
|
|
// useUserNotes() can be mounted by many components at once, so a per-instance
|
|
|
|
|
// latest/queue would only serialize writes within one instance. Notes for
|
|
|
|
|
// different users saved from different instances (before the server echo lands)
|
|
|
|
|
// would each compute from a stale snapshot and clobber each other, since
|
|
|
|
|
// setAccountData replaces the whole record with no server merge. We therefore
|
|
|
|
|
// keep a single shared latest record + write queue, keyed off the active client.
|
|
|
|
|
type UserNotesModuleState = {
|
|
|
|
|
mx: MatrixClient;
|
|
|
|
|
latest: UserNotesContent;
|
|
|
|
|
writeQueue: Promise<unknown>;
|
|
|
|
|
listeners: Set<(record: UserNotesContent) => void>;
|
|
|
|
|
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let moduleState: UserNotesModuleState | null = null;
|
|
|
|
|
|
|
|
|
|
// 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): UserNotesModuleState {
|
|
|
|
|
if (moduleState && moduleState.mx === mx) {
|
|
|
|
|
return moduleState;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (moduleState) {
|
|
|
|
|
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const state: UserNotesModuleState = {
|
|
|
|
|
mx,
|
|
|
|
|
latest: readNotes(mx),
|
|
|
|
|
writeQueue: Promise.resolve(),
|
|
|
|
|
listeners: new Set(),
|
|
|
|
|
// Reassigned below once `state` is captured.
|
|
|
|
|
onAccountData: () => undefined,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
state.onAccountData = (evt) => {
|
|
|
|
|
if (evt.getType() === NOTES_KEY) {
|
|
|
|
|
const record = evt.getContent<UserNotesContent>() ?? {};
|
|
|
|
|
state.latest = record;
|
|
|
|
|
state.listeners.forEach((listener) => listener(record));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mx.on(ClientEvent.AccountData, state.onAccountData);
|
|
|
|
|
moduleState = state;
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function enqueueNotesWrite(
|
|
|
|
|
mx: MatrixClient,
|
|
|
|
|
compute: (current: UserNotesContent) => UserNotesContent,
|
|
|
|
|
): 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 (mx as any).setAccountData(NOTES_KEY, 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 00:47:14 -04:00
|
|
|
export function useUserNotes(): {
|
|
|
|
|
getNote: (userId: string) => string;
|
|
|
|
|
setNote: (userId: string, note: string) => Promise<void>;
|
|
|
|
|
} {
|
|
|
|
|
const mx = useMatrixClient();
|
2026-07-02 20:56:27 -04:00
|
|
|
const [notes, setNotes] = useState<UserNotesContent>(() => ensureModuleState(mx).latest);
|
2026-06-14 00:47:14 -04:00
|
|
|
|
2026-07-02 20:56:27 -04:00
|
|
|
// 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.
|
2026-06-14 00:47:14 -04:00
|
|
|
useEffect(() => {
|
2026-07-02 20:56:27 -04:00
|
|
|
const state = ensureModuleState(mx);
|
|
|
|
|
setNotes(state.latest);
|
|
|
|
|
state.listeners.add(setNotes);
|
|
|
|
|
return () => {
|
|
|
|
|
state.listeners.delete(setNotes);
|
|
|
|
|
};
|
2026-06-14 00:47:14 -04:00
|
|
|
}, [mx]);
|
|
|
|
|
|
|
|
|
|
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
|
|
|
|
|
|
|
|
|
|
const setNote = useCallback(
|
2026-07-02 20:56:27 -04:00
|
|
|
(userId: string, note: string) => {
|
2026-06-14 00:47:14 -04:00
|
|
|
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
|
2026-07-02 20:56:27 -04:00
|
|
|
return enqueueNotesWrite(mx, (current) => {
|
|
|
|
|
const updated = { ...current };
|
|
|
|
|
if (trimmed) {
|
|
|
|
|
updated[userId] = trimmed;
|
|
|
|
|
} else {
|
|
|
|
|
delete updated[userId];
|
|
|
|
|
}
|
|
|
|
|
return updated;
|
|
|
|
|
});
|
2026-06-14 00:47:14 -04:00
|
|
|
},
|
|
|
|
|
[mx],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { getNote, setNote };
|
|
|
|
|
}
|