Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 ddf9da1a55 fix(reminders): a reminder stays until you've seen it (#338)
CI / Build & Quality Checks (pull_request) Successful in 3m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m6s
ReminderMonitor pushed an ordinary toast (auto-dismissed after 4 s) and
deleted the reminder at once, with no OS notification — one that came due
while the app was in the background or the user was away was lost.

Now the reminder toast is sticky; clicking it opens the message and
dismissing it closes it, and only then is the reminder deleted (an unseen
one returns on the next start). When the window isn't focused it also
raises an OS notification (respecting "show notifications" and quiet
hours; no decrypted E2EE text, per N106).

Verified headless (notifications granted, window unfocused): due at
t+24 s, the toast stayed up for the remaining 51 s, one OS notification
"Reminder :: call the dentist before 5", the reminder stayed in
io.lotus.reminders until dismissed, then was removed.

Fixes #338

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-11 22:11:31 -04:00
+48 -12
View File
@@ -783,15 +783,20 @@ function ReminderMonitor() {
const { reminders, removeReminder } = useReminders(); const { reminders, removeReminder } = useReminders();
const setToast = useSetAtom(toastQueueAtom); const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const quietActive = useNotificationsQuiet();
const firedRef = useRef<Set<string>>(new Set()); const firedRef = useRef<Set<string>>(new Set());
const removingRef = useRef<Set<string>>(new Set()); const removingRef = useRef<Set<string>>(new Set());
// Read the latest reminders / DM map via refs so the poll interval below is // Read the latest reminders / DM map / notification prefs via refs so the poll
// created once — not torn down and restarted (which resets its 30s countdown // interval below is created once — not torn down and restarted (which resets
// and can indefinitely defer a near-due reminder) on every reminder sync (N115). // its 30s countdown and can indefinitely defer a near-due reminder) on every
// reminder sync (N115).
const remindersRef = useRef(reminders); const remindersRef = useRef(reminders);
remindersRef.current = reminders; remindersRef.current = reminders;
const mDirectsRef = useRef(mDirects); const mDirectsRef = useRef(mDirects);
mDirectsRef.current = mDirects; mDirectsRef.current = mDirects;
const osNotifyRef = useRef(false);
osNotifyRef.current = showNotifications && !quietActive;
useEffect(() => { useEffect(() => {
const check = () => { const check = () => {
@@ -815,6 +820,23 @@ function ReminderMonitor() {
const hashPath = mDirectsRef.current.has(r.roomId) const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId) ? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(r.roomId, r.eventId); : getHomeRoomPath(r.roomId, r.eventId);
// [Gitea #338] A reminder is only done once the user has SEEN it: the
// toast stays until clicked or dismissed (it used to vanish after 4 s
// and the reminder was deleted at once, so one due while the user was
// away was lost). The removal is persisted on that click/dismiss; an
// unseen reminder comes back on the next start.
const done = () => {
if (removingRef.current.has(key)) return;
removingRef.current.add(key);
removeReminder(r.eventId, r.timestamp).catch(() => {
removingRef.current.delete(key);
});
};
const open = () => {
done();
// window.location.hash setter auto-prepends '#' (as the toast does)
window.location.hash = hashPath;
};
setToast({ setToast({
id: `reminder-${key}`, id: `reminder-${key}`,
displayName: 'Reminder', displayName: 'Reminder',
@@ -822,16 +844,30 @@ function ReminderMonitor() {
roomName, roomName,
roomId: r.roomId, roomId: r.roomId,
hashPath, hashPath,
sticky: true,
onClick: open,
onDismiss: done,
}); });
} // Away from the window: also raise an OS notification (the reminder is
// Persist the removal, retrying on a later tick if it fails — without // an explicit request, but quiet hours / notifications-off win). Never
// re-showing the toast (N114). The server echo drops it from // put decrypted E2EE text in it (N106).
// `reminders` once the write lands. if (osNotifyRef.current && notificationPermission('granted') && !document.hasFocus()) {
if (!removingRef.current.has(key)) { const encrypted = !!room?.hasEncryptionStateEvent();
removingRef.current.add(key); showOsNotification(
removeReminder(r.eventId, r.timestamp).catch(() => { 'Reminder',
removingRef.current.delete(key); {
}); icon: LogoSVG,
badge: LogoSVG,
body: encrypted ? `A message in ${roomName}` : body,
tag: `reminder-${key}`,
data: { path: hashPath, roomId: r.roomId },
},
() => {
window.focus();
open();
},
).catch(() => undefined);
}
} }
}); });
}; };