diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ae7997ed5..f2b84ffed 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -783,15 +783,20 @@ function ReminderMonitor() { const { reminders, removeReminder } = useReminders(); const setToast = useSetAtom(toastQueueAtom); const mDirects = useAtomValue(mDirectAtom); + const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); + const quietActive = useNotificationsQuiet(); const firedRef = useRef>(new Set()); const removingRef = useRef>(new Set()); - // Read the latest reminders / DM map via refs so the poll interval below is - // created once — not torn down and restarted (which resets its 30s countdown - // and can indefinitely defer a near-due reminder) on every reminder sync (N115). + // Read the latest reminders / DM map / notification prefs via refs so the poll + // interval below is created once — not torn down and restarted (which resets + // its 30s countdown and can indefinitely defer a near-due reminder) on every + // reminder sync (N115). const remindersRef = useRef(reminders); remindersRef.current = reminders; const mDirectsRef = useRef(mDirects); mDirectsRef.current = mDirects; + const osNotifyRef = useRef(false); + osNotifyRef.current = showNotifications && !quietActive; useEffect(() => { const check = () => { @@ -815,6 +820,23 @@ function ReminderMonitor() { const hashPath = mDirectsRef.current.has(r.roomId) ? getDirectRoomPath(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({ id: `reminder-${key}`, displayName: 'Reminder', @@ -822,16 +844,30 @@ function ReminderMonitor() { roomName, roomId: r.roomId, hashPath, + sticky: true, + onClick: open, + onDismiss: done, }); - } - // Persist the removal, retrying on a later tick if it fails — without - // re-showing the toast (N114). The server echo drops it from - // `reminders` once the write lands. - if (!removingRef.current.has(key)) { - removingRef.current.add(key); - removeReminder(r.eventId, r.timestamp).catch(() => { - removingRef.current.delete(key); - }); + // Away from the window: also raise an OS notification (the reminder is + // an explicit request, but quiet hours / notifications-off win). Never + // put decrypted E2EE text in it (N106). + if (osNotifyRef.current && notificationPermission('granted') && !document.hasFocus()) { + const encrypted = !!room?.hasEncryptionStateEvent(); + showOsNotification( + 'Reminder', + { + 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); + } } }); };