Files
cinny/src/app/pages/client/ClientNonUIFeatures.tsx
T
Lotus CIandClaude Opus 5.5 02d86caeb0
CI / Build & Quality Checks (pull_request) Successful in 6m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 10m28s
feat: offline outbox — unsent messages survive reload and retry (#112)
Until now a send that failed (offline, homeserver down, a blip) went
straight to "Failed to send": nothing retried it, and a reload dropped it
without trace (chronological pending ordering keeps local echoes in memory
only).

- Outbox (utils/outbox.ts + features/outbox/OutboxFeature): own message
  sends (text, stickers, reactions, polls; not call signalling or
  redactions) are mirrored to localStorage from their first local echo until
  the server confirms them or the user cancels.
- After a reload they come back as local echoes via room.addPendingEvent,
  same shape as the SDK's own. Recent ones (< 1 h) are sent again with the
  same txnId; older ones come back as "Failed to send" for the user to
  retry or cancel. Ones the server already has (transaction id seen in
  /sync) are dropped, so no duplicates.
- Retries: network failures (ConnectionError, 408/429/5xx) are re-sent when
  the connection returns (sync recovers or the browser goes back online),
  and after a blip while online (5 s, backing off, max 10 per message).
  Oldest first, in order per room. 4xx / consent / encryption failures are
  left to the user.
- UI: a network failure while offline shows a clock, "Queued. Will send
  when you're back online" (thread view too), not the red ✕. The ✕ is now
  a button: click to retry.
- Logout wipes the outbox with the other plaintext caches (the content is
  decrypted, like drafts).

Tested end to end against a local Synapse (Chromium): offline → queued →
sent once on reconnect; homeserver unreachable → queued → sent once; failed
send → reload → sent once and shown once; server accepted but response lost
→ reload → no duplicate; 2 h old entry → failed, not sent, click ✕ → sent;
cancel → gone after reload; encrypted room → restored message goes out as
m.room.encrypted with no plaintext and decrypts; one-off failure retried by
itself in ~5 s; no page errors. Unit tests for the pure parts; Playwright
20 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 21:02:45 -04:00

1119 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useAtomValue, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Icons } from 'folds';
import {
ClientEvent,
ClientEventHandlerMap,
MatrixEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
SyncState,
Thread,
ThreadEvent,
} from 'matrix-js-sdk';
import { useNotificationsQuiet } from '../../hooks/useNotificationsQuiet';
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 {
setLotusPermalinkBase,
setStripTrackingOnRender,
} from '../../plugins/react-custom-html-parser';
import { useSettingsSync } from '../../hooks/useSettingsSync';
import { usePwaInstallPrompt } from '../../hooks/usePwaInstallPrompt';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import { OutboxFeature } from '../../features/outbox/OutboxFeature';
import { useSearchCacheInvalidation } from '../../utils/searchCacheInvalidation';
import { ClockSkewMonitor } from '../../utils/clockSkew';
import { clockSkewAtom } from '../../state/clockSkew';
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/muteTimers';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { noteLocalStatusWrite } from '../../utils/ownStatusSync';
import { setPresenceWithRetry } from '../../utils/presenceWrite';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
import { isNetworkUpdateError } from '../../utils/updateErrors';
import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri';
import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt';
import { ConsentRequiredPrompt } from '../../components/ConsentRequiredPrompt';
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 { lotusLinkBases } from '../../hooks/useLotusLinkBase';
import {
getThreadNotificationMode,
shouldNotifyThreadReply,
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
// 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 LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus.png');
const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-unread.png');
const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-highlight.png');
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
const IS_WINDOWS =
typeof navigator !== 'undefined' &&
/Windows/i.test(
(navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ??
navigator.userAgent,
);
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');
}
// Windows has no flag emoji glyphs (🇺🇸 shows as "US"): use Twemoji for the
// flag letters only, keeping every other emoji native.
document.documentElement.style.setProperty(
'--font-flags',
IS_WINDOWS ? 'TwemojiFlags' : 'TwemojiFlags_DISABLED',
);
return null;
}
// [Gitea #103] Mirror the privacy toggle into the html parser's module flag.
function LotusPermalinkFeature() {
const { hashRouter, webAppUrl } = useClientConfig();
useEffect(() => {
// [Gitea #248] In the desktop app, links to the public web app count too.
setLotusPermalinkBase(lotusLinkBases(getOriginBaseUrl(hashRouter), isTauriApp(), webAppUrl));
return () => setLotusPermalinkBase(undefined);
}, [hashRouter, webAppUrl]);
return null;
}
function TrackingParamsFeature() {
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
useEffect(() => {
setStripTrackingOnRender(stripTracking);
}, [stripTracking]);
return null;
}
// [Gitea #104] Mirror user preferences to account data and apply remote snapshots.
function SettingsSyncFeature() {
useSettingsSync();
return null;
}
// [Gitea #116] Offer to install the PWA (Chromium prompt / iOS instructions).
function PwaInstallFeature() {
usePwaInstallPrompt();
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';
}
// [Gitea #154] Installed-PWA app-icon badge (Android Chrome, desktop
// Chrome/Edge). Same number as the title so the two never disagree:
// highlights only, mirroring the desktop app's native `set_badge_count`
// (which owns the badge under Tauri, so skip there). Plain tabs and
// browsers without the Badging API simply have no `setAppBadge`.
if (!isTauriApp()) {
const nav = navigator as Navigator & {
setAppBadge?: (count?: number) => Promise<void>;
clearAppBadge?: () => Promise<void>;
};
if (totalHighlight > 0) {
nav.setAppBadge?.(totalHighlight).catch(() => {});
} else {
nav.clearAppBadge?.().catch(() => {});
}
}
}, [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 quietActive = useNotificationsQuiet();
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;
// Rejects under the autoplay policy until the user has interacted with the
// page (e.g. messages arriving right after launch); nothing to do then.
audioElement?.play().catch(() => undefined);
}, []);
// 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;
if (quietActive) return;
if (showNotifications && notificationPermission('granted')) {
notify(newCount);
}
if (notificationSound && inviteSoundId !== 'none') {
playSound();
}
}, [
invites,
showNotifications,
notificationSound,
notify,
playSound,
quietActive,
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);
// [Gitea #227] The clear must survive a failed write. Synapse rate-limits
// presence to ~1 write / 10 s per user (#226) and the heartbeat spends that
// budget, so a bare setPresence here 429s often — and because the keys used
// to be removed BEFORE the request, a single failure dropped the auto-clear
// permanently and the status stayed set forever. Retry the write (honouring
// Retry-After), and only forget the status once the server has taken it.
let clearing = false;
const check = () => {
if (clearing) return;
const stored = localStorage.getItem(expiryKey);
if (!stored) return;
const ts = parseInt(stored, 10);
if (!ts || Date.now() < ts) return;
clearing = true;
setPresenceWithRetry(mx, {
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
status_msg: '',
})
.then(() => {
noteLocalStatusWrite();
localStorage.removeItem(msgKey);
localStorage.removeItem(expiryKey);
})
.catch(() => undefined)
.finally(() => {
clearing = false;
});
};
check();
// Poll often enough that a rate-limited retry lands promptly, but not so
// often that a persistent failure hammers the server.
const interval = setInterval(check, 15_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 quietActive = useNotificationsQuiet();
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,
// [Gitea #203] Inline reply from the notification (Chrome/Android
// text-input actions). The SW posts the reply itself — it holds the
// access token for authenticated media already — so this works with
// the tab in the background or closed. Not offered for encrypted
// rooms: the SW cannot encrypt.
...(encrypted
? {}
: {
// `actions` is SW-notification-only and not in lib.dom's
// NotificationOptions typing.
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
}),
data: { path: roomPath, roomId, threadId: threadId || undefined },
},
() => {
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;
// Rejects under the autoplay policy until the user has interacted with the
// page (e.g. messages arriving right after launch); nothing to do then.
audioElement?.play().catch(() => undefined);
}, []);
// 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);
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,
quietActive,
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 roomName = room?.name ?? 'Unknown Room';
// Reminders for E2EE rooms carry no text (account data is server-
// readable), so resolve the body from the local timeline at fire time.
const localBody = room?.findEventById(r.eventId)?.getContent()?.body;
const body =
r.message ??
(typeof localBody === 'string' && localBody
? localBody.slice(0, 120)
: `Reminder for a message in ${roomName}`);
const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(r.roomId, r.eventId);
setToast({
id: `reminder-${key}`,
displayName: 'Reminder',
body,
roomName,
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';
const UPDATE_PROGRESS_TOAST = 'tauri-update-progress';
const UPDATE_FAILED_TOAST = 'tauri-update-failed';
function TauriUpdateFeature() {
const { isTauri, status, check, install } = useTauriUpdater();
useTauriUpdateProgress();
const setToast = useSetAtom(toastQueueAtom);
const dismissToast = useSetAtom(dismissToastAtom);
const firedRef = useRef<string | null>(null);
// Installing from the toast used to fail invisibly: the toast was gone and
// the only trace was an error line in Settings. Say what is happening, and
// on failure leave a toast that retries and points at the manual installer.
const installFromToast = useCallback(async () => {
dismissToast(UPDATE_FAILED_TOAST);
setToast({
id: UPDATE_PROGRESS_TOAST,
iconSrc: Icons.Download,
displayName: 'Downloading update',
body: 'Lotus Chat will restart by itself when it’s done. You can keep chatting.',
roomName: 'System',
roomId: '',
onClick: () => undefined,
});
const failure = await install();
dismissToast(UPDATE_PROGRESS_TOAST);
if (!failure) return;
// Short on purpose; the full explanation and a Download installer button
// are in Settings → General → App Updates.
const busy = failure.phase !== 'install' && isNetworkUpdateError(failure.message);
setToast({
id: UPDATE_FAILED_TOAST,
iconSrc: Icons.Warning,
displayName: 'Update didn’t install',
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App Updates.`,
roomName: 'System',
roomId: '',
sticky: true,
onClick: () => {
installFromToast();
},
});
}, [install, setToast, dismissToast]);
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: () => {
installFromToast();
},
sticky: true,
});
}, [status, setToast, installFromToast]);
// [cinny-desktop #6] Mirror a pending update into the tray ("Restart to
// update" + tooltip) so a dismissed toast isn't the only reminder. Kept while
// installing or after a failed attempt (the update is still pending); cleared
// once a check says we're current.
useEffect(() => {
if (!isTauri) return;
if (status.state === 'available') {
invokeTauri('set_tray_update_ready', { version: status.version });
} else if (status.state === 'up-to-date') {
invokeTauri('set_tray_update_ready', { version: null });
}
}, [isTauri, status]);
useTauriEvent('lotus-tray-install-update', () => {
if (firedRef.current) dismissToast(`tauri-update-${firedRef.current}`);
installFromToast();
});
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;
}
// Gitea #14 — keeps the opt-in persistent search cache from outliving the
// plaintext it indexed (redactions + leaving/being banned from a room).
function SearchCacheInvalidationFeature(): null {
useSearchCacheInvalidation();
return null;
}
/**
* [Gitea #158] Feeds every LIVE timeline event (origin_server_ts + unsigned.age
* vs the SDK's localTimestamp) to the skew monitor. `data.liveEvent` is false
* for events replayed from the IndexedDB cache — whose `age` is stale — so
* only network deliveries (including the initial sync) count.
*/
function ClockSkewFeature() {
const mx = useMatrixClient();
const setSkew = useSetAtom(clockSkewAtom);
useEffect(() => {
const monitor = new ClockSkewMonitor();
const unsub = monitor.subscribe(setSkew);
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (
mEvent,
_room,
_toStart,
_removed,
data,
) => {
if (!data.liveEvent) return;
monitor.sample(mEvent.getTs(), mEvent.getAge(), mEvent.localTimestamp);
};
mx.on(RoomEvent.Timeline, onTimeline);
return () => {
mx.off(RoomEvent.Timeline, onTimeline);
unsub();
};
}, [mx, setSkew]);
return null;
}
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
<MsgDraftHydrator />
<SearchCacheInvalidationFeature />
<SystemEmojiFeature />
<PageZoomFeature />
<LotusPermalinkFeature />
<TrackingParamsFeature />
<SettingsSyncFeature />
<PwaInstallFeature />
<FaviconUpdater />
<PresenceUpdater />
<ClockSkewFeature />
<MuteTimerRestore />
<StatusExpiryMonitor />
<InviteNotifications />
<MessageNotifications />
<ReminderMonitor />
<RetentionSweeper />
<TauriUpdateFeature />
<TauriDesktopFeatures />
<CloseBehaviorPrompt />
<ConsentRequiredPrompt />
<OutboxFeature />
<LotusDenoiseFeature />
<DeepLinkNavigator />
<KeyboardShortcutsFeature />
{children}
</>
);
}