Files
cinny/src/app/pages/client/ClientNonUIFeatures.tsx
T
jared a33d28a7ae style: apply Prettier formatting to remaining files
Pure formatting reflows (multi-line wrapping of long lines/imports/tables);
no behavior change. Clears the working tree of pending prettier diffs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:41:57 -04:00

516 lines
16 KiB
TypeScript

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<HTMLAudioElement>(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 (
<audio ref={audioRef} style={{ display: 'none' }}>
<source src={soundSrc ?? InviteSound} type="audio/ogg" />
</audio>
);
}
function PresenceUpdater() {
usePresenceUpdater();
return null;
}
function MessageNotifications() {
const audioRef = useRef<HTMLAudioElement>(null);
const notifRef = useRef<Notification | undefined>(undefined);
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(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 (
<audio ref={audioRef} style={{ display: 'none' }}>
<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());
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<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 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 (
<>
<SystemEmojiFeature />
<PageZoomFeature />
<FaviconUpdater />
<PresenceUpdater />
<InviteNotifications />
<MessageNotifications />
<ReminderMonitor />
<TauriUpdateFeature />
<LotusDenoiseFeature />
<DeepLinkNavigator />
{children}
</>
);
}