import { useAtomValue, useSetAtom } from 'jotai'; import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; import LogoSVG from '../../../../public/res/lotus.png'; import LogoUnreadSVG from '../../../../public/res/lotus-unread.png'; import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png'; import NotificationSound from '../../../../public/sound/notification.ogg'; import InviteSound from '../../../../public/sound/invite.ogg'; import { notificationPermission, setFavicon } 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 { usePreviousValue } from '../../hooks/usePreviousValue'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils'; import { mDirectAtom } from '../../state/mDirectList'; import { getMemberDisplayName, getNotificationType, getUnreadInfo, isNotificationEvent, } from '../../utils/room'; import { NotificationType, UnreadInfo } from '../../../types/matrix/room'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { usePresenceUpdater } from '../../hooks/usePresenceUpdater'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; import { toastQueueAtom } from '../../state/toast'; import { useReminders } from '../../hooks/useReminders'; import { useTauriUpdater } from '../../hooks/useTauriUpdater'; function isInQuietHours(start: string, end: string): boolean { const now = new Date(); const [sh, sm] = start.split(':').map(Number); const [eh, em] = end.split(':').map(Number); const cur = now.getHours() * 60 + now.getMinutes(); const s = sh! * 60 + (sm ?? 0); const e = eh! * 60 + (em ?? 0); // start===end means zero-length window → treat as disabled (no quiet hours) if (s === e) return false; return s < e ? cur >= s && cur < e : cur >= s || cur < e; } 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) => { 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 perviousInviteLen = usePreviousValue(invites.length, 0); const mx = useMatrixClient(); const navigate = useNavigate(); const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled'); 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 noti = new window.Notification('Invitation', { icon: LogoSVG, badge: LogoSVG, body: `You have ${count} new invitation request.`, silent: true, }); noti.onclick = () => { if (!window.closed) navigate(getInboxInvitesPath()); noti.close(); }; }, [navigate, setToast], ); const playSound = useCallback(() => { const audioElement = audioRef.current; audioElement?.play(); }, []); useEffect(() => { if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') { const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd); if (!quietActive) { if (showNotifications && notificationPermission('granted')) { notify(invites.length - perviousInviteLen); } if (notificationSound && inviteSoundId !== 'none') { playSound(); } } } }, [ mx, invites, perviousInviteLen, showNotifications, notificationSound, notify, playSound, quietHoursEnabled, quietHoursStart, quietHoursEnd, inviteSoundId, ]); return ( ); } function PresenceUpdater() { usePresenceUpdater(); return null; } function MessageNotifications() { const audioRef = useRef(null); const notifRef = useRef(undefined); const unreadCacheRef = 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 [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 notify = useCallback( ({ roomName, roomAvatar, username, roomId, eventId, body, }: { roomName: string; roomAvatar?: string; username: string; roomId: string; eventId: string; body?: 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; } const noti = new window.Notification(roomName, { icon: roomAvatar, badge: roomAvatar, body: body ? `${username}: ${body}`.slice(0, 120) : username, silent: true, }); noti.onclick = () => { window.focus(); navigate(roomPath); noti.close(); notifRef.current = undefined; }; notifRef.current?.close(); notifRef.current = noti; }, [navigate, setToast, mDirects], ); const playSound = useCallback(() => { const audioElement = audioRef.current; audioElement?.play(); }, []); 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; const unreadInfo = getUnreadInfo(room); const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId); unreadCacheRef.current.set(room.roomId, unreadInfo); if (unreadInfo.total === 0) return; if ( cachedUnreadInfo && unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo)) ) { return; } const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd); if (!quietActive) { 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: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender, roomId: room.roomId, eventId, body: (mEvent.getContent().body as string | undefined) ?? '', }); } if (notificationSound && messageSoundId !== 'none') { playSound(); } } }; mx.on(RoomEvent.Timeline, handleTimelineEvent); return () => { mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); }; }, [ mx, notificationSound, notificationSelected, showNotifications, playSound, notify, selectedRoomId, useAuthentication, quietHoursEnabled, quietHoursStart, quietHoursEnd, messageSoundId, ]); 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()); useEffect(() => { const check = () => { const now = Date.now(); reminders.forEach((r) => { const key = `${r.eventId}-${r.timestamp}`; if (r.timestamp <= now && !firedRef.current.has(key)) { firedRef.current.add(key); const room = mx.getRoom(r.roomId); const hashPath = mDirects.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, }); removeReminder(r.eventId, r.timestamp); } }); }; 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, reminders, setToast, removeReminder, mDirects]); 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 to install.`, roomName: 'System', roomId: '', onClick: install, }); }, [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; } export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { return ( <> {children} ); }