922 lines
33 KiB
TypeScript
922 lines
33 KiB
TypeScript
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<HTMLAudioElement>(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<Set<string>>(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<typeof setTimeout> | 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 (
|
|
<audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
|
|
<source src={soundSrc ?? InviteSound} type="audio/ogg" />
|
|
</audio>
|
|
);
|
|
}
|
|
|
|
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<HTMLAudioElement>(null);
|
|
// Notify dedupe, keyed `${roomId}|${threadId ?? 'main'}` -> last notified
|
|
// eventId, so the main timeline and each thread dedupe independently.
|
|
const lastNotifiedEventRef = useRef<Map<string, string>>(new Map());
|
|
// Per-thread dedupe (thread-detection gate): threadId -> last notified eventId.
|
|
const lastNotifiedThreadRef = useRef<Map<string, string>>(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 (
|
|
<audio ref={audioRef} style={{ display: 'none' }} aria-hidden="true">
|
|
<source src={soundSrc ?? NotificationSound} type="audio/ogg" />
|
|
</audio>
|
|
);
|
|
}
|
|
|
|
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<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
|
|
// 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<Set<string>>(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<string | null>(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 <KeyboardShortcutsDialog />;
|
|
}
|
|
|
|
function MsgDraftHydrator(): null {
|
|
useHydrateMsgDrafts();
|
|
return null;
|
|
}
|
|
|
|
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|
return (
|
|
<>
|
|
<MsgDraftHydrator />
|
|
<SystemEmojiFeature />
|
|
<PageZoomFeature />
|
|
<FaviconUpdater />
|
|
<PresenceUpdater />
|
|
<MuteTimerRestore />
|
|
<StatusExpiryMonitor />
|
|
<InviteNotifications />
|
|
<MessageNotifications />
|
|
<ReminderMonitor />
|
|
<RetentionSweeper />
|
|
<TauriUpdateFeature />
|
|
<TauriDesktopFeatures />
|
|
<LotusDenoiseFeature />
|
|
<DeepLinkNavigator />
|
|
<KeyboardShortcutsFeature />
|
|
{children}
|
|
</>
|
|
);
|
|
}
|