2026-07-02 20:56:27 -04:00
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
|
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
2026-06-17 20:26:43 -04:00
|
|
|
import { useMatrixClient } from './useMatrixClient';
|
2026-07-07 23:14:35 -04:00
|
|
|
import { getAccountData, setAccountData } from '../utils/accountData';
|
2026-06-17 20:26:43 -04:00
|
|
|
|
|
|
|
|
export type Reminder = {
|
|
|
|
|
roomId: string;
|
|
|
|
|
eventId: string;
|
|
|
|
|
timestamp: number;
|
|
|
|
|
message: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const REMINDERS_KEY = 'io.lotus.reminders';
|
|
|
|
|
|
|
|
|
|
type RemindersContent = {
|
|
|
|
|
reminders: Reminder[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function readReminders(mx: MatrixClient): Reminder[] {
|
2026-07-07 23:14:35 -04:00
|
|
|
return getAccountData<RemindersContent>(mx, REMINDERS_KEY)?.reminders ?? [];
|
2026-06-17 20:26:43 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 20:56:27 -04:00
|
|
|
// Module-scoped serialization state.
|
|
|
|
|
//
|
|
|
|
|
// The latest snapshot and the write queue must be shared across every hook
|
|
|
|
|
// instance: ReminderMonitor (auto-removes fired reminders) and RemindMeDialog
|
|
|
|
|
// (adds reminders) mount separate hooks, and a per-instance queue would let a
|
|
|
|
|
// remove and an add race across instances and clobber each other (setAccountData
|
|
|
|
|
// replaces the whole content, no server merge). We therefore keep a single
|
|
|
|
|
// shared queue + latest ref, keyed off the active MatrixClient.
|
|
|
|
|
type ReminderModuleState = {
|
|
|
|
|
mx: MatrixClient;
|
|
|
|
|
latest: Reminder[];
|
|
|
|
|
writeQueue: Promise<unknown>;
|
|
|
|
|
listeners: Set<(list: Reminder[]) => void>;
|
|
|
|
|
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let moduleState: ReminderModuleState | 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): ReminderModuleState {
|
|
|
|
|
if (moduleState && moduleState.mx === mx) {
|
|
|
|
|
return moduleState;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (moduleState) {
|
|
|
|
|
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const state: ReminderModuleState = {
|
|
|
|
|
mx,
|
|
|
|
|
latest: readReminders(mx),
|
|
|
|
|
writeQueue: Promise.resolve(),
|
|
|
|
|
listeners: new Set(),
|
|
|
|
|
// Reassigned below once `state` is captured.
|
|
|
|
|
onAccountData: () => undefined,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
state.onAccountData = (evt) => {
|
|
|
|
|
if (evt.getType() === REMINDERS_KEY) {
|
|
|
|
|
const list = evt.getContent<RemindersContent>()?.reminders ?? [];
|
|
|
|
|
state.latest = list;
|
|
|
|
|
state.listeners.forEach((listener) => listener(list));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mx.on(ClientEvent.AccountData, state.onAccountData);
|
|
|
|
|
moduleState = state;
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function enqueueReminderWrite(
|
|
|
|
|
mx: MatrixClient,
|
|
|
|
|
compute: (current: Reminder[]) => Reminder[],
|
|
|
|
|
): 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));
|
2026-07-07 23:14:35 -04:00
|
|
|
await setAccountData(mx, REMINDERS_KEY, { reminders: next });
|
2026-07-02 20:56:27 -04:00
|
|
|
});
|
|
|
|
|
// 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-17 20:26:43 -04:00
|
|
|
export function useReminders(): {
|
|
|
|
|
reminders: Reminder[];
|
|
|
|
|
addReminder: (r: Reminder) => Promise<void>;
|
|
|
|
|
removeReminder: (eventId: string, timestamp: number) => Promise<void>;
|
|
|
|
|
getReminders: () => Reminder[];
|
|
|
|
|
} {
|
|
|
|
|
const mx = useMatrixClient();
|
2026-07-02 20:56:27 -04:00
|
|
|
const [reminders, setReminders] = useState<Reminder[]>(() => ensureModuleState(mx).latest);
|
2026-06-17 20:26:43 -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-17 20:26:43 -04:00
|
|
|
useEffect(() => {
|
2026-07-02 20:56:27 -04:00
|
|
|
const state = ensureModuleState(mx);
|
|
|
|
|
setReminders(state.latest);
|
|
|
|
|
state.listeners.add(setReminders);
|
|
|
|
|
return () => {
|
|
|
|
|
state.listeners.delete(setReminders);
|
|
|
|
|
};
|
|
|
|
|
}, [mx]);
|
2026-06-17 20:26:43 -04:00
|
|
|
|
2026-06-28 09:17:19 -04:00
|
|
|
const addReminder = useCallback(
|
2026-07-02 20:56:27 -04:00
|
|
|
(r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]),
|
|
|
|
|
[mx],
|
2026-06-28 09:17:19 -04:00
|
|
|
);
|
|
|
|
|
|
2026-06-17 20:26:43 -04:00
|
|
|
const removeReminder = useCallback(
|
2026-06-28 09:17:19 -04:00
|
|
|
(eventId: string, timestamp: number) =>
|
2026-07-02 20:56:27 -04:00
|
|
|
enqueueReminderWrite(mx, (current) =>
|
2026-06-28 09:17:19 -04:00
|
|
|
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
|
|
|
|
|
),
|
2026-07-02 20:56:27 -04:00
|
|
|
[mx],
|
2026-06-17 20:26:43 -04:00
|
|
|
);
|
|
|
|
|
|
2026-07-02 20:56:27 -04:00
|
|
|
const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]);
|
2026-06-17 20:26:43 -04:00
|
|
|
|
|
|
|
|
return { reminders, addReminder, removeReminder, getReminders };
|
|
|
|
|
}
|