a33d28a7ae
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>
516 lines
16 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|