cinny/src/app/pages/client/ClientNonUIFeatures.tsx
夜坂雅 c6a8fb1117
Add authenticated media support (#1930)
* chore: Bump matrix-js-sdk to 34.4.0

* feat: Authenticated media support

* chore: Use Vite PWA for service worker support

* fix: Fix Vite PWA SW entry point

Forget this. :P

* fix: Also add Nginx rewrite for sw.js

* fix: Correct Nginx rewrite

* fix: Add Netlify redirect for sw.js

Otherwise the generic SPA rewrite to index.html would take effect, breaking Service Worker.

* fix: Account for subpath when regisering service worker

* chore: Correct types
2024-09-07 19:15:55 +05:30

271 lines
8.1 KiB
TypeScript

import { useAtomValue } 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/svg/cinny.svg';
import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg';
import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg';
import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
import { setFavicon } from '../../utils/dom';
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 { getInboxInvitesPath, getInboxNotificationsPath } from '../pathUtils';
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 { useSpecVersions } from '../../hooks/useSpecVersions';
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 notification = false;
let highlight = false;
roomToUnread.forEach((unread) => {
if (unread.total > 0) {
notification = true;
}
if (unread.highlight > 0) {
highlight = true;
}
});
if (notification) {
setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG);
} else {
setFavicon(LogoSVG);
}
}, [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 notify = useCallback(
(count: number) => {
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]
);
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play();
}, []);
useEffect(() => {
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
if (showNotifications && Notification.permission === 'granted') {
notify(invites.length - perviousInviteLen);
}
if (notificationSound) {
playSound();
}
}
}, [mx, invites, perviousInviteLen, showNotifications, notificationSound, notify, playSound]);
return (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio ref={audioRef} style={{ display: 'none' }}>
<source src={InviteSound} type="audio/ogg" />
</audio>
);
}
function MessageNotifications() {
const audioRef = useRef<HTMLAudioElement>(null);
const notifRef = useRef<Notification>();
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
const mx = useMatrixClient();
const { versions } = useSpecVersions();
const useAuthentication = versions.includes('v1.11');
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const navigate = useNavigate();
const notificationSelected = useInboxNotificationsSelected();
const selectedRoomId = useSelectedRoom();
const notify = useCallback(
({
roomName,
roomAvatar,
username,
}: {
roomName: string;
roomAvatar?: string;
username: string;
roomId: string;
eventId: string;
}) => {
const noti = new window.Notification(roomName, {
icon: roomAvatar,
badge: roomAvatar,
body: `New inbox notification from ${username}`,
silent: true,
});
noti.onclick = () => {
if (!window.closed) navigate(getInboxNotificationsPath());
noti.close();
notifRef.current = undefined;
};
notifRef.current?.close();
notifRef.current = noti;
},
[navigate]
);
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;
}
if (showNotifications && Notification.permission === '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,
});
}
if (notificationSound) {
playSound();
}
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [
mx,
notificationSound,
notificationSelected,
showNotifications,
playSound,
notify,
selectedRoomId,
]);
return (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio ref={audioRef} style={{ display: 'none' }}>
<source src={NotificationSound} type="audio/ogg" />
</audio>
);
}
type ClientNonUIFeaturesProps = {
children: ReactNode;
};
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
<SystemEmojiFeature />
<PageZoomFeature />
<FaviconUpdater />
<InviteNotifications />
<MessageNotifications />
{children}
</>
);
}