import { useAtomValue, useSetAtom } from 'jotai'; import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { ClientEvent, ClientEventHandlerMap, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap, SyncState, Thread, ThreadEvent, } from 'matrix-js-sdk'; import { focusAssistActiveAtom } from '../../state/focusAssist'; import { manualDndAtom } from '../../state/manualDnd'; import { isSnoozeActive, notificationSnoozeUntilAtom } from '../../state/notificationSnooze'; import { isWithinTimeWindow } from '../../utils/timeWindow'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import NotificationSound from '../../../../public/sound/notification.ogg'; import InviteSound from '../../../../public/sound/invite.ogg'; import { notificationPermission, setFavicon, showOsNotification } from '../../utils/dom'; import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { allInvitesAtom } from '../../state/room-list/inviteList'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts'; import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath, getOriginBaseUrl, withOriginBaseUrl, } from '../pathUtils'; import { mDirectAtom } from '../../state/mDirectList'; import { getMemberName, getNotificationType, getUnreadInfo, isNotificationEvent, } from '../../utils/room'; import { NotificationType } from '../../../types/matrix/room'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater'; import { MAX_MUTE_TIMEOUT_MS, MuteTimerEntry, loadMuteTimers, unmuteRoom, } from '../../features/room-nav/RoomNavItem'; import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; import { toastQueueAtom } from '../../state/toast'; import { useReminders } from '../../hooks/useReminders'; import { getRoomRetentionMs, isExpired } from '../../utils/retention'; import { useTauriUpdater } from '../../hooks/useTauriUpdater'; import { invokeTauri } from '../../hooks/useTauri'; import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures'; import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts'; import { useRoomsListener } from '../../hooks/useRoomsListener'; import { threadNotificationsAtom } from '../../state/threadNotifications'; import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread'; import { getThreadNotificationMode, shouldNotifyThreadReply, THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, } from '../../utils/threadNotifications'; const LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus.png'); const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus-unread.png'); const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus-highlight.png'); // Grace period after the initial sync settles before invite notifications arm, so // the async invite-atom population lands first and isn't mistaken for new invites. const INVITE_NOTIFY_ARM_DELAY_MS = 3000; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); if (twitterEmoji) { document.documentElement.style.setProperty('--font-emoji', 'Twemoji'); } else { document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED'); } return null; } function PageZoomFeature() { const [pageZoom] = useSetting(settingsAtom, 'pageZoom'); if (pageZoom === 100) { document.documentElement.style.removeProperty('font-size'); } else { document.documentElement.style.setProperty('font-size', `calc(1em * ${pageZoom / 100})`); } return null; } function FaviconUpdater() { const roomToUnread = useAtomValue(roomToUnreadAtom); useEffect(() => { let totalNotif = 0; let totalHighlight = 0; roomToUnread.forEach((unread) => { // roomToUnread holds BOTH leaf rooms and per-ancestor space aggregates // (leaves have `from === null`, aggregates a Set). Sum only leaves — // otherwise a space-nested room is counted once as the leaf and again in // every ancestor space, inflating the tab title / favicon count. if (unread.from !== null) return; totalNotif += unread.total; totalHighlight += unread.highlight; }); if (totalNotif > 0) { setFavicon(totalHighlight > 0 ? LogoHighlightSVG : LogoUnreadSVG); } else { setFavicon(LogoSVG); } if (totalHighlight > 0) { document.title = `(${totalHighlight}) Lotus Chat`; } else if (totalNotif > 0) { document.title = `· Lotus Chat`; } else { document.title = 'Lotus Chat'; } }, [roomToUnread]); return null; } function InviteNotifications() { const audioRef = useRef(null); const invites = useAtomValue(allInvitesAtom); const mx = useMatrixClient(); // Notify only for invites that ARRIVE while the app runs — never for invites // already present at load. allInvitesAtom (a string[] of invited room ids) // populates asynchronously post-mount from the initial/cached sync, so any count/ // seed baseline mis-fires on reload. Stay "unarmed" until the initial sync settles // (keeping the id baseline synced to whatever loads), then notify only for room // ids that first appear AFTER arming. const armedRef = useRef(false); const knownInviteIdsRef = useRef>(new Set()); const navigate = useNavigate(); const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled'); const focusAssistActive = useAtomValue(focusAssistActiveAtom); const manualDnd = useAtomValue(manualDndAtom); const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom); const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart'); const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd'); const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId'); const setToast = useSetAtom(toastQueueAtom); const soundSrc = inviteSoundId !== 'none' ? (NOTIFICATION_SOUND_MAP[inviteSoundId] ?? InviteSound) : null; useEffect(() => { const el = audioRef.current; if (!el) return; const source = el.querySelector('source'); if (source && soundSrc) { source.src = soundSrc; el.load(); } }, [soundSrc]); const notify = useCallback( (count: number) => { if (document.hasFocus()) { setToast({ id: `invite-${Date.now()}`, displayName: 'Invitation', body: `You have ${count} new invitation request.`, roomName: 'Invites', roomId: '', hashPath: getInboxInvitesPath(), }); return; } const invitesPath = getInboxInvitesPath(); showOsNotification( 'Invitation', { icon: LogoSVG, badge: LogoSVG, body: `You have ${count} new invitation request.`, silent: true, tag: 'lotus-invites', data: { path: invitesPath }, }, () => { if (!window.closed) navigate(invitesPath); }, ); }, [navigate, setToast], ); const playSound = useCallback(() => { const audioElement = audioRef.current; audioElement?.play(); }, []); // Arm once the client's initial sync has settled (+ a short grace so the async // atom population lands first). Until armed, the effect below only tracks the // baseline of already-present invites without notifying. useEffect(() => { let timer: ReturnType | undefined; const arm = () => { timer = setTimeout(() => { armedRef.current = true; }, INVITE_NOTIFY_ARM_DELAY_MS); }; if (mx.getSyncState() === SyncState.Syncing) { arm(); return () => { if (timer) clearTimeout(timer); }; } const onSync: ClientEventHandlerMap[ClientEvent.Sync] = (state) => { if (state === SyncState.Syncing) { mx.off(ClientEvent.Sync, onSync); arm(); } }; mx.on(ClientEvent.Sync, onSync); return () => { mx.off(ClientEvent.Sync, onSync); if (timer) clearTimeout(timer); }; }, [mx]); useEffect(() => { const currentIds = new Set(invites); if (!armedRef.current) { // Not yet armed: keep the baseline synced to whatever the initial sync loads. knownInviteIdsRef.current = currentIds; return; } const newCount = invites.filter((id) => !knownInviteIdsRef.current.has(id)).length; knownInviteIdsRef.current = currentIds; if (newCount <= 0) return; const quietActive = focusAssistActive || manualDnd || isSnoozeActive(snoozeUntil) || (quietHoursEnabled && isWithinTimeWindow(quietHoursStart, quietHoursEnd)); if (quietActive) return; if (showNotifications && notificationPermission('granted')) { notify(newCount); } if (notificationSound && inviteSoundId !== 'none') { playSound(); } }, [ invites, showNotifications, notificationSound, notify, playSound, quietHoursEnabled, quietHoursStart, quietHoursEnd, focusAssistActive, manualDnd, snoozeUntil, inviteSoundId, ]); return ( ); } function PresenceUpdater() { usePresenceUpdater(); return null; } // Restores timed-mute timers persisted by RoomNavItem across reloads. Bare // setTimeouts don't survive a page reload, so without this a scheduled unmute is // lost and the room stays muted forever. On boot: unmute anything already // past-due and re-arm a timer for each future entry (clamped to setTimeout's max). function MuteTimerRestore() { const mx = useMatrixClient(); useEffect(() => { const timers = loadMuteTimers(); if (timers.length === 0) return undefined; const now = Date.now(); const pastDue: MuteTimerEntry[] = []; const future: MuteTimerEntry[] = []; timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry)); pastDue.forEach((entry) => { unmuteRoom(mx, entry.roomId); }); const handles = future.map((entry) => setTimeout( () => { unmuteRoom(mx, entry.roomId); }, Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS), ), ); return () => { handles.forEach(clearTimeout); }; }, [mx]); return null; } // Fires the custom-status auto-clear even when Settings→Profile is closed. The // expiry setTimeout used to live in ProfileStatus, which unmounts on close, so // the status never cleared. This always-mounted watcher polls the persisted // expiry key and clears (preserving the user's chosen presence) when due. function StatusExpiryMonitor() { const mx = useMatrixClient(); const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus'); const [hidePresence] = useSetting(settingsAtom, 'hidePresence'); // Read latest settings via refs so the poll interval isn't torn down/restarted // (resetting its countdown) whenever the presence setting changes. const presenceStatusRef = useRef(presenceStatus); presenceStatusRef.current = presenceStatus; const hidePresenceRef = useRef(hidePresence); hidePresenceRef.current = hidePresence; useEffect(() => { const userId = mx.getUserId(); if (!userId) return undefined; const expiryKey = STATUS_EXPIRY_KEY(userId); const msgKey = STATUS_MSG_KEY(userId); const check = () => { const stored = localStorage.getItem(expiryKey); if (!stored) return; const ts = parseInt(stored, 10); if (!ts || Date.now() < ts) return; localStorage.removeItem(msgKey); localStorage.removeItem(expiryKey); mx.setPresence({ presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current), status_msg: '', }).catch(() => undefined); }; check(); const interval = setInterval(check, 30_000); const onVisible = () => { if (document.visibilityState === 'visible') check(); }; document.addEventListener('visibilitychange', onVisible); return () => { clearInterval(interval); document.removeEventListener('visibilitychange', onVisible); }; }, [mx]); return null; } function MessageNotifications() { const audioRef = useRef(null); // Notify dedupe, keyed `${roomId}|${threadId ?? 'main'}` -> last notified // eventId, so the main timeline and each thread dedupe independently. const lastNotifiedEventRef = useRef>(new Map()); // Per-thread dedupe (thread-detection gate): threadId -> last notified eventId. const lastNotifiedThreadRef = useRef>(new Map()); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled'); const focusAssistActive = useAtomValue(focusAssistActiveAtom); const manualDnd = useAtomValue(manualDndAtom); const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom); const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart'); const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd'); const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId'); const setToast = useSetAtom(toastQueueAtom); const mDirects = useAtomValue(mDirectAtom); const soundSrc = messageSoundId !== 'none' ? (NOTIFICATION_SOUND_MAP[messageSoundId] ?? NotificationSound) : null; useEffect(() => { const el = audioRef.current; if (!el) return; const source = el.querySelector('source'); if (source && soundSrc) { source.src = soundSrc; el.load(); } }, [soundSrc]); const navigate = useNavigate(); const notificationSelected = useInboxNotificationsSelected(); const selectedRoomId = useSelectedRoom(); const threadPrefs = useAtomValue(threadNotificationsAtom); const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(selectedRoomId ?? '')); const notify = useCallback( ({ roomName, roomAvatar, username, roomId, eventId, body, encrypted, threadId, }: { roomName: string; roomAvatar?: string; username: string; roomId: string; eventId: string; body?: string; encrypted?: boolean; threadId?: string; }) => { const roomPath = mDirects.has(roomId) ? getDirectRoomPath(roomId, eventId) : getHomeRoomPath(roomId, eventId); if (document.hasFocus()) { setToast({ id: `${roomId}-${eventId}-${Date.now()}`, avatarUrl: roomAvatar, displayName: username, body: (body ?? '').slice(0, 80), roomName, roomId, hashPath: roomPath, }); return; } // N109: the OS notification subsystem fetches icon/badge OUTSIDE the page, // so the SW can't inject auth headers and authenticated-media URLs 401. // Use the static app logo (as invite notifications already do). // N106: never put decrypted E2EE plaintext into the OS notification (it // persists in the notification center / lock screen / is readable by other // apps). For encrypted rooms show only the sender; the in-page toast above // still shows the preview while the user is actively looking at the screen. showOsNotification( roomName, { icon: LogoSVG, badge: LogoSVG, body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username, silent: true, // Coalesce repeated notifications for the same room (replaces the old // manual notifRef.close() dedup, which a SW notification can't hold). // For thread replies widen the tag to room:thread so each thread // coalesces independently instead of clobbering the room's bucket. tag: threadId ? `${roomId}:${threadId}` : roomId, data: { path: roomPath }, }, () => { window.focus(); navigate(roomPath); }, ); }, [navigate, setToast, mDirects], ); // N105: when a service-worker-owned notification is clicked, the SW focuses // this tab and forwards the target path here so we can route to it (works even // when the click happened while the tab was in the background / reopened). useEffect(() => { if (!('serviceWorker' in navigator)) return undefined; const onMessage = (event: MessageEvent) => { const data = event.data ?? {}; if (data.type === 'notificationClick' && typeof data.path === 'string') { // On desktop, raise the native window — a service-worker/WebView2 // `client.focus()` only focuses the document, not the OS window. No-op // outside Tauri. invokeTauri('focus_main_window'); navigate(data.path); } }; navigator.serviceWorker.addEventListener('message', onMessage); return () => navigator.serviceWorker.removeEventListener('message', onMessage); }, [navigate]); const playSound = useCallback(() => { const audioElement = audioRef.current; audioElement?.play(); }, []); // Shared delivery tail for both the main timeline and per-thread paths: // room-level unread dedup → avatar resolution → OS/toast notify → sound, all // behind the quiet-hours / focus-assist gate. `threadId` (when set) widens the // OS coalescing tag so each thread notifies independently; the click path // stays the room path (RoomTimeline deep-links thread events into the panel). const deliverNotification = useCallback( (room: Room, mEvent: MatrixEvent, threadId?: string) => { const sender = mEvent.getSender(); const eventId = mEvent.getId(); if (!sender || !eventId) return; // Dedupe on the event id (per room AND per path): the same event can // re-fire (decryption, edit, thread repopulation). The main timeline and // each thread get their own slot — a shared per-room slot let a thread // reply overwrite the main slot, so a re-fired main message then mismatched // and double-notified. This replaces the old unread-COUNT dedupe, which // suppressed a genuinely-new message whenever its post-read count matched // the previously-notified count (the "read a DM, next message never // notifies/sounds" one-at-a-time cadence). const dedupeKey = `${room.roomId}|${threadId ?? 'main'}`; if (lastNotifiedEventRef.current.get(dedupeKey) === eventId) return; // Main-timeline path respects push rules: don't notify when the room has no // notification count (e.g. a non-mention in a Mentions-only room). The // thread path is already gated by shouldNotifyThreadReply, so it must NOT // re-gate on the room count — otherwise an explicit per-thread "All replies" // override in a Mentions-only room is silently dropped. if (!threadId && getUnreadInfo(room, undefined, mx).total === 0) return; lastNotifiedEventRef.current.set(dedupeKey, eventId); const quietActive = focusAssistActive || manualDnd || isSnoozeActive(snoozeUntil) || (quietHoursEnabled && isWithinTimeWindow(quietHoursStart, quietHoursEnd)); if (quietActive) return; if (showNotifications && notificationPermission('granted')) { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); notify({ roomName: room.name ?? 'Unknown', roomAvatar: avatarMxc ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined, username: getMemberName(room, sender), roomId: room.roomId, eventId, body: (mEvent.getContent().body as string | undefined) ?? '', encrypted: room.hasEncryptionStateEvent(), threadId, }); } if (notificationSound && messageSoundId !== 'none') { playSound(); } }, [ mx, notify, playSound, showNotifications, notificationSound, useAuthentication, quietHoursEnabled, quietHoursStart, quietHoursEnd, focusAssistActive, manualDnd, snoozeUntil, messageSoundId, ], ); useEffect(() => { const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = ( mEvent, room, toStartOfTimeline, removed, data, ) => { if (mx.getSyncState() !== 'SYNCING') return; if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return; if ( !room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent) || getNotificationType(mx, room.roomId) === NotificationType.Mute ) { return; } const sender = mEvent.getSender(); const eventId = mEvent.getId(); if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return; // Single-owner rule: thread replies are delivered by the ThreadEvent.NewReply // handler below (per-thread gating), so ignore them here — a reply notifies once. if (mEvent.threadRootId && mEvent.getId() !== mEvent.threadRootId) return; deliverNotification(room, mEvent); }; mx.on(RoomEvent.Timeline, handleTimelineEvent); return () => { mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); }; }, [mx, notificationSelected, selectedRoomId, deliverNotification]); const handleNewReply = useCallback( // useRoomsListener prepends the emitting Room; the thread's own room lookup // below is kept as the authority (identical object in practice). (_room: Room, thread: Thread, mEvent: MatrixEvent) => { if (mx.getSyncState() !== 'SYNCING') return; const room = mx.getRoom(thread.roomId); if (!room || room.isSpaceRoom()) return; if (!isNotificationEvent(mEvent) || mEvent.isSending()) return; const sender = mEvent.getSender(); if (!sender || sender === mx.getUserId()) return; // Suppress when the user is actively looking at this thread (or the inbox). if ( document.hasFocus() && (notificationSelected || (selectedRoomId === thread.roomId && activeThreadId === thread.id)) ) { return; } // Per-thread dedupe: a NewReply can re-fire for the same event as the // thread (re)populates; notify at most once per (thread, event). const eventId = mEvent.getId(); if (eventId) { if (lastNotifiedThreadRef.current.get(thread.id) === eventId) return; lastNotifiedThreadRef.current.set(thread.id, eventId); } const content = threadPrefs; const mode = getThreadNotificationMode(content, room.roomId, thread.id); const actions = mx.getPushActionsForEvent(mEvent); // `hasCurrentUserParticipated` is derived from the server thread bundle, // which lags a reply we just sent — so also treat any of our own events // already in the thread timeline as participation (T5: avoid under-notify). const myUserId = mx.getUserId(); const participated = thread.hasCurrentUserParticipated || thread.timeline.some((e) => e.getSender() === myUserId); const roomNotifType = getNotificationType(mx, room.roomId); const decision = shouldNotifyThreadReply({ mode, defaultBehavior: content.default ?? THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, participated, highlight: !!actions?.tweaks?.highlight, notify: !!actions?.notify, roomMuted: roomNotifType === NotificationType.Mute, // T6: honor a room-level "Mentions & Keywords only" setting for Default // threads instead of over-notifying every participated reply. roomMentionsOnly: roomNotifType === NotificationType.MentionsAndKeywords, }); if (decision === 'none') return; // E2EE caveat: NewReply can fire before decryption, so MentionsOnly may // under-notify in encrypted rooms (same class as the main timeline path). // Plaintext body suppression for encrypted rooms is handled inside notify(). deliverNotification(room, mEvent, thread.id); }, [mx, notificationSelected, selectedRoomId, activeThreadId, threadPrefs, deliverNotification], ); useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply); return ( ); } type ClientNonUIFeaturesProps = { children: ReactNode; }; function DeepLinkNavigator() { useDeepLinkNavigate(); return null; } function ReminderMonitor() { const mx = useMatrixClient(); const { reminders, removeReminder } = useReminders(); const setToast = useSetAtom(toastQueueAtom); const mDirects = useAtomValue(mDirectAtom); 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). const remindersRef = useRef(reminders); remindersRef.current = reminders; const mDirectsRef = useRef(mDirects); mDirectsRef.current = mDirects; useEffect(() => { const check = () => { const now = Date.now(); remindersRef.current.forEach((r) => { if (r.timestamp > now) return; const key = `${r.eventId}-${r.timestamp}`; // Show the toast exactly once. if (!firedRef.current.has(key)) { firedRef.current.add(key); const room = mx.getRoom(r.roomId); const hashPath = mDirectsRef.current.has(r.roomId) ? getDirectRoomPath(r.roomId, r.eventId) : getHomeRoomPath(r.roomId, r.eventId); setToast({ id: `reminder-${key}`, displayName: 'Reminder', body: r.message, roomName: room?.name ?? 'Unknown Room', roomId: r.roomId, hashPath, }); } // 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); }); } }); }; check(); const interval = setInterval(check, 30_000); const onVisible = () => { if (document.visibilityState === 'visible') check(); }; document.addEventListener('visibilitychange', onVisible); return () => { clearInterval(interval); document.removeEventListener('visibilitychange', onVisible); }; }, [mx, setToast, removeReminder]); return null; } // MSC1763: opt-in local enforcement of room retention. When enabled, permanently // redacts the user's OWN messages once a room's retention window passes. Own-only // (no redact PL needed); scoped to loaded live-timeline events; dedupes in-flight // redactions and retries on the next tick. Default-off, so nothing auto-deletes // unless the user turns it on. function RetentionSweeper() { const mx = useMatrixClient(); const [enforceRetentionLocally] = useSetting(settingsAtom, 'enforceRetentionLocally'); const enabledRef = useRef(enforceRetentionLocally); enabledRef.current = enforceRetentionLocally; const redactingRef = useRef>(new Set()); useEffect(() => { const check = () => { if (!enabledRef.current) return; const myId = mx.getUserId(); if (!myId) return; const now = Date.now(); mx.getRooms().forEach((room) => { const maxLifetime = getRoomRetentionMs(room); if (!maxLifetime) return; room .getLiveTimeline() .getEvents() .forEach((ev) => { const evId = ev.getId(); if (!evId || ev.getSender() !== myId) return; if (ev.isState() || ev.isRedacted() || ev.isSending()) return; const t = ev.getType(); // Only actual messages — never our membership/topic/reactions. if (t !== 'm.room.message' && t !== 'm.room.encrypted' && t !== 'm.sticker') return; if (!isExpired(ev.getTs(), maxLifetime, now)) return; if (redactingRef.current.has(evId)) return; redactingRef.current.add(evId); mx.redactEvent(room.roomId, evId, undefined, { reason: 'expired' }).catch(() => { redactingRef.current.delete(evId); }); }); }); }; check(); const interval = setInterval(check, 30_000); const onVisible = () => { if (document.visibilityState === 'visible') check(); }; document.addEventListener('visibilitychange', onVisible); return () => { clearInterval(interval); document.removeEventListener('visibilitychange', onVisible); }; }, [mx]); return null; } const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck'; function TauriUpdateFeature() { const { isTauri, status, check, install } = useTauriUpdater(); const setToast = useSetAtom(toastQueueAtom); const firedRef = useRef(null); useEffect(() => { if (!isTauri) return; const runCheck = () => { const last = Number(localStorage.getItem(TAURI_UPDATE_LAST_CHECK_KEY) ?? '0'); if (Date.now() - last < TAURI_UPDATE_CHECK_INTERVAL) return; localStorage.setItem(TAURI_UPDATE_LAST_CHECK_KEY, String(Date.now())); check(); }; runCheck(); const interval = setInterval(runCheck, TAURI_UPDATE_CHECK_INTERVAL); return () => clearInterval(interval); }, [isTauri, check]); useEffect(() => { if (status.state !== 'available') return; if (firedRef.current === status.version) return; firedRef.current = status.version; setToast({ id: `tauri-update-${status.version}`, displayName: '⬆ Update Available', body: `Lotus Chat ${status.version} is ready. Click to install and restart.`, roomName: 'System', roomId: '', onClick: install, sticky: true, }); }, [status, setToast, install]); return null; } function LotusDenoiseFeature() { const setToast = useSetAtom(toastQueueAtom); useEffect(() => { const handleMessage = (event: MessageEvent) => { if (event.data?.type === 'lotus-denoise-status') { const { active, error } = event.data; if (!active) { setToast({ id: `denoise-fail-${Date.now()}`, displayName: 'Audio Quality', body: `ML Noise Suppression failed: ${error || 'Unknown error'}. Falling back to raw mic.`, roomName: 'System', roomId: '', }); } } }; window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [setToast]); return null; } // Registers the global `?` shortcut (ignored while typing) and renders the // keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom. function KeyboardShortcutsFeature() { useKeyboardShortcutsTrigger(); return ; } function MsgDraftHydrator(): null { useHydrateMsgDrafts(); return null; } export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { return ( <> {children} ); }