For encrypted rooms, bookmarks persist only {roomId, eventId, savedAt}
and reminders only their non-text fields; the preview, room name and
sender resolve locally at render/fire time from the timeline (with a
"Message unavailable" fallback). A one-time, loop-guarded cleanup strips
text from existing entries in currently-encrypted rooms. Unencrypted
rooms are unchanged. Unit-tested.
Fixes #10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
119 lines
3.9 KiB
TypeScript
119 lines
3.9 KiB
TypeScript
import { useCallback, useEffect } from 'react';
|
|
import { MatrixClient } from 'matrix-js-sdk';
|
|
import { useMatrixClient } from './useMatrixClient';
|
|
import { createAccountDataListStore } from './createAccountDataListStore';
|
|
|
|
export type Reminder = {
|
|
roomId: string;
|
|
eventId: string;
|
|
timestamp: number;
|
|
// Message preview. Account data is stored UNENCRYPTED on the homeserver, so
|
|
// this is omitted for E2EE rooms (see stripReminderText) and the toast resolves
|
|
// the text locally at fire time. Optional for entries stored before that policy.
|
|
message?: string;
|
|
};
|
|
|
|
const REMINDERS_KEY = 'io.lotus.reminders';
|
|
|
|
type RemindersContent = {
|
|
reminders: Reminder[];
|
|
};
|
|
|
|
/** Reduce a reminder to its non-text fields ({roomId, eventId, timestamp}). */
|
|
export const stripReminderText = (r: Reminder): Reminder => ({
|
|
roomId: r.roomId,
|
|
eventId: r.eventId,
|
|
timestamp: r.timestamp,
|
|
});
|
|
|
|
/** Apply the E2EE storage policy to a reminder about to be persisted. */
|
|
export const toStorableReminder = (
|
|
r: Reminder,
|
|
isEncryptedRoom: (roomId: string) => boolean,
|
|
): Reminder => (isEncryptedRoom(r.roomId) ? stripReminderText(r) : r);
|
|
|
|
/**
|
|
* One-time cleanup for entries persisted before the E2EE policy; `undefined`
|
|
* when nothing needs to change (so callers can skip the write).
|
|
*/
|
|
export const cleanupEncryptedReminders = (
|
|
reminders: Reminder[],
|
|
isEncryptedRoom: (roomId: string) => boolean,
|
|
): Reminder[] | undefined => {
|
|
let changed = false;
|
|
const next = reminders.map((r) => {
|
|
if (r.message !== undefined && isEncryptedRoom(r.roomId)) {
|
|
changed = true;
|
|
return stripReminderText(r);
|
|
}
|
|
return r;
|
|
});
|
|
return changed ? next : undefined;
|
|
};
|
|
|
|
const isEncryptedRoomFor =
|
|
(mx: MatrixClient) =>
|
|
(roomId: string): boolean =>
|
|
mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false;
|
|
|
|
// Shared, concurrency-safe store. See createAccountDataListStore for why the
|
|
// snapshot + write queue must be module-scoped (writes are serialized to avoid
|
|
// lost updates, since setAccountData replaces the whole content with no merge).
|
|
const remindersStore = createAccountDataListStore<Reminder[], RemindersContent>({
|
|
eventType: REMINDERS_KEY,
|
|
read: (content) => content?.reminders ?? [],
|
|
write: (reminders) => ({ reminders }),
|
|
});
|
|
|
|
// Once-per-client guard for the load-time cleanup (mirrors useBookmarks).
|
|
let cleanedUpFor: MatrixClient | null = null;
|
|
|
|
export function useReminders(): {
|
|
reminders: Reminder[];
|
|
addReminder: (r: Reminder) => Promise<void>;
|
|
removeReminder: (eventId: string, timestamp: number) => Promise<void>;
|
|
getReminders: () => Reminder[];
|
|
} {
|
|
const mx = useMatrixClient();
|
|
const reminders = remindersStore.useValue(mx);
|
|
|
|
// Strip text from entries saved (by older builds) for rooms that are encrypted,
|
|
// writing the store back at most once per client.
|
|
useEffect(() => {
|
|
if (cleanedUpFor === mx) return;
|
|
cleanedUpFor = mx;
|
|
if (cleanupEncryptedReminders(remindersStore.getLatest(mx), isEncryptedRoomFor(mx))) {
|
|
remindersStore
|
|
.enqueueWrite(
|
|
mx,
|
|
(current) => cleanupEncryptedReminders(current, isEncryptedRoomFor(mx)) ?? current,
|
|
)
|
|
.catch(() => {
|
|
if (cleanedUpFor === mx) cleanedUpFor = null;
|
|
});
|
|
}
|
|
}, [mx]);
|
|
|
|
const addReminder = useCallback(
|
|
(r: Reminder) =>
|
|
remindersStore.enqueueWrite(mx, (current) => [
|
|
...current,
|
|
// Never upload plaintext for E2EE rooms — account data is server-readable.
|
|
toStorableReminder(r, isEncryptedRoomFor(mx)),
|
|
]),
|
|
[mx],
|
|
);
|
|
|
|
const removeReminder = useCallback(
|
|
(eventId: string, timestamp: number) =>
|
|
remindersStore.enqueueWrite(mx, (current) =>
|
|
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
|
|
),
|
|
[mx],
|
|
);
|
|
|
|
const getReminders = useCallback(() => remindersStore.getLatest(mx), [mx]);
|
|
|
|
return { reminders, addReminder, removeReminder, getReminders };
|
|
}
|