Wait for E2EE cleartext before building tray text, and post Android notifications natively so dynamic avatars fetched with secure media auth can appear as large icons.
981 lines
31 KiB
TypeScript
981 lines
31 KiB
TypeScript
import { useAtomValue, useStore } from 'jotai';
|
|
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { RoomEvent, RoomEventHandlerMap, MatrixClient, MatrixEvent, MatrixEventEvent } from 'matrix-js-sdk';
|
|
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
|
import LogoSVG from '../../../../public/res/svg/paarrot.svg';
|
|
import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg';
|
|
import LogoHighlightSVG from '../../../../public/res/svg/paarrot-highlight.svg';
|
|
import { notificationPermission, setFavicon } from '../../utils/dom';
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
import { EmojiStyle, settingsAtom } from '../../state/settings';
|
|
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
|
import { usePreviousValue } from '../../hooks/usePreviousValue';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath, getInboxInvitesPath } from '../pathUtils';
|
|
import {
|
|
getMemberDisplayName,
|
|
getMemberAvatarMxc,
|
|
getNotificationType,
|
|
getUnreadInfo,
|
|
isNotificationEvent,
|
|
getOrphanParents,
|
|
guessPerfectParent,
|
|
} from '../../utils/room';
|
|
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
|
import {
|
|
getMxIdLocalPart,
|
|
mxcUrlToHttp,
|
|
getCanonicalAliasOrRoomId,
|
|
encryptFile,
|
|
downloadMedia,
|
|
} from '../../utils/matrix';
|
|
import { mDirectAtom } from '../../state/mDirectList';
|
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
|
import {
|
|
isTauri,
|
|
isElectron,
|
|
isCapacitorNative,
|
|
sendNotification,
|
|
setupNotificationTapListener,
|
|
clearNotificationsForRoom,
|
|
NOTIF_GROUP_DIRECTS,
|
|
NOTIF_GROUP_HOME,
|
|
type NotificationGroupInfo,
|
|
} from '../../utils/tauri';
|
|
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
|
import {
|
|
startBackgroundSync,
|
|
stopBackgroundSync,
|
|
setAppForegroundState,
|
|
syncNotificationGroupMap,
|
|
} from '../../utils/backgroundSync';
|
|
import {
|
|
TUploadItem,
|
|
roomIdToUploadItemsAtomFamily,
|
|
} from '../../state/room/roomInputDrafts';
|
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
|
import { safeFile } from '../../utils/mimeTypes';
|
|
import {
|
|
AndroidSharePayload,
|
|
clearPendingAndroidShare,
|
|
getPendingAndroidShare,
|
|
isAndroidShareSupported,
|
|
listenForAndroidShares,
|
|
materializeSharedFile,
|
|
} from '../../utils/androidShare';
|
|
|
|
/** Wait briefly for an encrypted event to decrypt before reading its body. */
|
|
async function waitForEventDecryption(mx: MatrixClient, mEvent: MatrixEvent) {
|
|
if (!mEvent.isEncrypted() || mEvent.getClearContent() || mEvent.isDecryptionFailure()) {
|
|
return;
|
|
}
|
|
|
|
const crypto = mx.getCrypto();
|
|
if (crypto) {
|
|
try {
|
|
await mEvent.attemptDecryption(crypto as any, { isRetry: true });
|
|
} catch {
|
|
// Decryption may complete asynchronously via the Decrypted listener below.
|
|
}
|
|
}
|
|
|
|
if (mEvent.getClearContent() || mEvent.isDecryptionFailure()) return;
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const timeout = window.setTimeout(() => {
|
|
mEvent.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
|
|
resolve();
|
|
}, 2000);
|
|
|
|
const onDecrypted = () => {
|
|
window.clearTimeout(timeout);
|
|
mEvent.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
|
|
resolve();
|
|
};
|
|
|
|
mEvent.once(MatrixEventEvent.Decrypted, onDecrypted);
|
|
});
|
|
}
|
|
|
|
/** Build a human-readable notification body from (possibly decrypted) event content. */
|
|
function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
|
|
const content = mEvent.getClearContent() ?? mEvent.getContent();
|
|
const eventType = mEvent.getType();
|
|
|
|
if (eventType === 'm.reaction') {
|
|
const reactionKey = content['m.relates_to']?.key;
|
|
return reactionKey ? `reacted with ${reactionKey}` : 'reacted to a message';
|
|
}
|
|
|
|
if (eventType === 'm.room.encrypted' || mEvent.isDecryptionFailure()) {
|
|
return 'Encrypted message';
|
|
}
|
|
|
|
const rawBody = typeof content.body === 'string' ? content.body : undefined;
|
|
switch (content.msgtype) {
|
|
case 'm.image':
|
|
return rawBody ? `📷 ${rawBody}` : '📷 Photo';
|
|
case 'm.video':
|
|
return rawBody ? `🎥 ${rawBody}` : '🎥 Video';
|
|
case 'm.audio':
|
|
return rawBody ? `🎵 ${rawBody}` : '🎵 Audio';
|
|
case 'm.file':
|
|
return rawBody ? `📎 ${rawBody}` : '📎 File';
|
|
case 'm.sticker':
|
|
return rawBody ? `🖼️ ${rawBody}` : '🖼️ Sticker';
|
|
default:
|
|
return rawBody;
|
|
}
|
|
}
|
|
|
|
/** Download an authenticated media URL to a base64 string for native notification icons. */
|
|
async function mediaUrlToBase64(
|
|
url: string,
|
|
accessToken: string | null | undefined
|
|
): Promise<string | undefined> {
|
|
try {
|
|
const blob = await downloadMedia(url, accessToken);
|
|
const buffer = await blob.arrayBuffer();
|
|
const bytes = new Uint8Array(buffer);
|
|
let binary = '';
|
|
const chunk = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
}
|
|
const mime = blob.type || 'image/jpeg';
|
|
return `data:${mime};base64,${btoa(binary)}`;
|
|
} catch (err) {
|
|
console.warn('[Notifications] Failed to fetch avatar for notification icon:', err);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies the selected emoji style font to the document.
|
|
* - System: Uses the native OS emoji font
|
|
* - Apple: Uses Apple Color Emoji (bundled font)
|
|
* - Twemoji: Uses Twitter's Twemoji font
|
|
*/
|
|
function EmojiStyleFeature() {
|
|
const [emojiStyle] = useSetting(settingsAtom, 'emojiStyle');
|
|
|
|
switch (emojiStyle) {
|
|
case EmojiStyle.Apple:
|
|
document.documentElement.style.setProperty('--font-emoji', 'AppleColorEmoji');
|
|
break;
|
|
case EmojiStyle.Twemoji:
|
|
document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
|
|
break;
|
|
case EmojiStyle.System:
|
|
default:
|
|
document.documentElement.style.setProperty('--font-emoji', 'SystemEmoji');
|
|
break;
|
|
}
|
|
|
|
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 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 body = `You have ${count} new invitation request.`;
|
|
const invitesPath = getInboxInvitesPath();
|
|
|
|
// Flash taskbar icon for desktop notifications (only visible when window is not focused)
|
|
if (isElectron() && (window as any).electron?.window?.flashFrame) {
|
|
(window as any).electron.window.flashFrame(true)
|
|
.then((result: { success: boolean }) => {
|
|
console.log('[InviteNotifications] flashFrame result:', result);
|
|
})
|
|
.catch((err: Error) => {
|
|
console.error('[InviteNotifications] flashFrame error:', err);
|
|
});
|
|
}
|
|
|
|
if ((isTauri() && !isElectron()) || isCapacitorNative()) {
|
|
sendNotification({
|
|
title: 'Invitation',
|
|
body,
|
|
path: invitesPath,
|
|
onClick: () => {
|
|
if (!window.closed) navigate(invitesPath);
|
|
},
|
|
});
|
|
} else {
|
|
const noti = new window.Notification('Invitation', {
|
|
icon: LogoSVG,
|
|
badge: LogoSVG,
|
|
body,
|
|
silent: true,
|
|
});
|
|
|
|
noti.onclick = () => {
|
|
window.focus();
|
|
if (!window.closed) navigate(invitesPath);
|
|
noti.close();
|
|
// Stop flashing when user clicks notification
|
|
if ((window as any).electron?.window?.flashFrame) {
|
|
(window as any).electron.window.flashFrame(false);
|
|
}
|
|
};
|
|
}
|
|
},
|
|
[navigate]
|
|
);
|
|
|
|
const playSound = useCallback(() => {
|
|
console.log('[InviteNotifications] playSound called, isElectron:', isElectron());
|
|
if (isElectron() && (window as any).electron?.audio?.playNotificationSound) {
|
|
console.log('[InviteNotifications] Using Electron audio API');
|
|
(window as any).electron.audio.playNotificationSound('invite')
|
|
.then((result: any) => console.log('[InviteNotifications] Sound result:', result))
|
|
.catch((err: any) => console.error('[InviteNotifications] Sound error:', err));
|
|
} else {
|
|
console.log('[InviteNotifications] Using HTML5 Audio fallback');
|
|
new Audio('./sound/invite.ogg').play().catch((err) => {
|
|
console.error('[Audio] Failed to play invite sound:', err);
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
|
if (showNotifications && notificationPermission('granted')) {
|
|
notify(invites.length - perviousInviteLen);
|
|
}
|
|
|
|
if (notificationSound) {
|
|
playSound();
|
|
}
|
|
}
|
|
}, [mx, invites, perviousInviteLen, showNotifications, notificationSound, notify, playSound]);
|
|
|
|
return null;
|
|
}
|
|
|
|
function MessageNotifications() {
|
|
const notifRef = useRef<Notification>();
|
|
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 mDirects = useAtomValue(mDirectAtom);
|
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
|
|
|
const navigate = useNavigate();
|
|
const notificationSelected = useInboxNotificationsSelected();
|
|
const selectedRoomId = useSelectedRoom();
|
|
|
|
// Set up notification tap listener for mobile
|
|
useEffect(() => {
|
|
setupNotificationTapListener((path) => {
|
|
navigate(path);
|
|
});
|
|
}, [navigate]);
|
|
|
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
|
const previousUnreadRoomsRef = useRef<Set<string>>(new Set());
|
|
|
|
// Dismiss OS notifications when a room's unread is cleared (local read or receipt).
|
|
useEffect(() => {
|
|
const currentRooms = new Set(roomToUnread.keys());
|
|
for (const roomId of previousUnreadRoomsRef.current) {
|
|
if (!currentRooms.has(roomId)) {
|
|
void clearNotificationsForRoom(roomId);
|
|
}
|
|
}
|
|
previousUnreadRoomsRef.current = currentRooms;
|
|
}, [roomToUnread]);
|
|
|
|
const notify = useCallback(
|
|
({
|
|
roomName,
|
|
roomAvatar,
|
|
iconBase64,
|
|
username,
|
|
messageBody,
|
|
roomId,
|
|
eventId,
|
|
isDm,
|
|
}: {
|
|
roomName: string;
|
|
roomAvatar?: string;
|
|
iconBase64?: string;
|
|
username: string;
|
|
messageBody?: string;
|
|
roomId: string;
|
|
eventId: string;
|
|
isDm: boolean;
|
|
}) => {
|
|
let group: NotificationGroupInfo;
|
|
if (isDm) {
|
|
group = {
|
|
groupId: NOTIF_GROUP_DIRECTS,
|
|
groupName: 'Direct messages',
|
|
kind: 'direct',
|
|
roomName,
|
|
};
|
|
} else {
|
|
const orphanParents = getOrphanParents(roomToParents, roomId);
|
|
if (orphanParents.length > 0 && mx) {
|
|
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
|
|
const spaceRoom = mx.getRoom(parentSpace);
|
|
group = {
|
|
groupId: parentSpace,
|
|
groupName: spaceRoom?.name || 'Space',
|
|
kind: 'space',
|
|
roomName,
|
|
};
|
|
} else {
|
|
group = {
|
|
groupId: NOTIF_GROUP_HOME,
|
|
groupName: 'Home',
|
|
kind: 'home',
|
|
roomName,
|
|
};
|
|
}
|
|
}
|
|
|
|
// DMs: sender as title. Rooms: room name as title, sender in body.
|
|
const notificationTitle = isDm ? username : roomName || username;
|
|
const notificationBody = isDm
|
|
? messageBody || 'New message'
|
|
: messageBody
|
|
? `${username}: ${messageBody}`
|
|
: `${username} sent a message`;
|
|
|
|
/** Replicates TitleBar click navigation logic */
|
|
const navigateToRoom = () => {
|
|
if (!mx) return;
|
|
try {
|
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
|
if (mDirects.has(roomId)) {
|
|
navigate(getDirectRoomPath(roomIdOrAlias, eventId));
|
|
return;
|
|
}
|
|
const orphanParents = getOrphanParents(roomToParents, roomId);
|
|
if (orphanParents.length > 0) {
|
|
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
|
|
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
|
|
navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId));
|
|
return;
|
|
}
|
|
navigate(getHomeRoomPath(roomIdOrAlias, eventId));
|
|
} catch (err) {
|
|
console.error('[Notifications] Navigate error:', err);
|
|
}
|
|
};
|
|
|
|
// Flash taskbar icon for desktop notifications (only visible when window is not focused)
|
|
console.log('[Notifications] Attempting flashFrame');
|
|
if (isElectron() && (window as any).electron?.window?.flashFrame) {
|
|
(window as any).electron.window.flashFrame(true)
|
|
.then((result: { success: boolean }) => {
|
|
console.log('[Notifications] flashFrame result:', result);
|
|
})
|
|
.catch((err: Error) => {
|
|
console.error('[Notifications] flashFrame error:', err);
|
|
});
|
|
} else {
|
|
console.warn('[Notifications] flashFrame not available, isElectron:', isElectron());
|
|
}
|
|
|
|
if ((isTauri() && !isElectron()) || isCapacitorNative()) {
|
|
const roomPath = isDm
|
|
? getDirectRoomPath(roomId, eventId)
|
|
: getHomeRoomPath(roomId, eventId);
|
|
sendNotification({
|
|
title: notificationTitle,
|
|
body: notificationBody,
|
|
path: roomPath,
|
|
roomId,
|
|
group,
|
|
icon: roomAvatar,
|
|
iconBase64,
|
|
onClick: () => {
|
|
if (!window.closed) navigate(roomPath);
|
|
},
|
|
});
|
|
} else {
|
|
// Use renderer window.Notification — works in both Electron and browser.
|
|
// Main-process Notification click events are unreliable on Windows without
|
|
// full COM/AUMID registration, so we keep everything in the renderer.
|
|
const iconUrl = roomAvatar ?? '/res/android/android-chrome-192x192.png';
|
|
|
|
const noti = new window.Notification(notificationTitle, {
|
|
icon: iconUrl,
|
|
body: notificationBody,
|
|
silent: true,
|
|
});
|
|
|
|
noti.onclick = () => {
|
|
// Tell main process to bring window to front
|
|
(window as any).electron?.window?.focus();
|
|
if (!window.closed) navigateToRoom();
|
|
noti.close();
|
|
notifRef.current = undefined;
|
|
// Stop flashing when user clicks notification
|
|
if ((window as any).electron?.window?.flashFrame) {
|
|
(window as any).electron.window.flashFrame(false);
|
|
}
|
|
};
|
|
|
|
notifRef.current?.close();
|
|
notifRef.current = noti;
|
|
}
|
|
},
|
|
[mx, navigate, mDirects, roomToParents]
|
|
);
|
|
|
|
const playSound = useCallback(() => {
|
|
console.log('[MessageNotifications] playSound called, isElectron:', isElectron());
|
|
if (isElectron() && (window as any).electron?.audio?.playNotificationSound) {
|
|
console.log('[MessageNotifications] Using Electron audio API');
|
|
(window as any).electron.audio.playNotificationSound('message')
|
|
.then((result: any) => console.log('[MessageNotifications] Sound result:', result))
|
|
.catch((err: any) => console.error('[MessageNotifications] Sound error:', err));
|
|
} else {
|
|
console.log('[MessageNotifications] Using HTML5 Audio fallback');
|
|
new Audio('./sound/notification.ogg').play().catch((err) => {
|
|
console.error('[Audio] Failed to play notification sound:', err);
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
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;
|
|
}
|
|
|
|
// Match TitleBar / room bell settings: Default and Mentions rooms only notify on highlights.
|
|
const notificationType = getNotificationType(mx, room.roomId);
|
|
const isDm = mDirects.has(room.roomId);
|
|
const mentionsOnly =
|
|
notificationType === NotificationType.MentionsAndKeywords ||
|
|
(notificationType === NotificationType.Default && !isDm);
|
|
if (mentionsOnly && unreadInfo.highlight === 0) return;
|
|
|
|
if (
|
|
showNotifications &&
|
|
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
|
) {
|
|
void (async () => {
|
|
await waitForEventDecryption(mx, mEvent);
|
|
|
|
const avatarMxc =
|
|
getMemberAvatarMxc(room, sender) ??
|
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ??
|
|
room.getMxcAvatarUrl();
|
|
const roomAvatar = avatarMxc
|
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
|
: undefined;
|
|
|
|
let iconBase64: string | undefined;
|
|
if (roomAvatar && isCapacitorNative()) {
|
|
iconBase64 = await mediaUrlToBase64(roomAvatar, mx.getAccessToken());
|
|
}
|
|
|
|
const messageBody = notificationBodyFromEvent(mEvent);
|
|
|
|
notify({
|
|
roomName: room.name ?? 'Unknown',
|
|
roomAvatar,
|
|
iconBase64,
|
|
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
|
|
messageBody,
|
|
roomId: room.roomId,
|
|
eventId,
|
|
isDm,
|
|
});
|
|
})();
|
|
}
|
|
|
|
if (notificationSound) {
|
|
playSound();
|
|
}
|
|
};
|
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
|
return () => {
|
|
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
|
};
|
|
}, [
|
|
mx,
|
|
notificationSound,
|
|
notificationSelected,
|
|
showNotifications,
|
|
playSound,
|
|
notify,
|
|
selectedRoomId,
|
|
useAuthentication,
|
|
mDirects,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Configures native Android push-ping background sync on login, keeps it
|
|
* informed of foreground state so it doesn't double-fire notifications,
|
|
* and clears it cleanly on unmount (logout).
|
|
* Only active on Android Capacitor builds.
|
|
*/
|
|
function BackgroundSyncSetup() {
|
|
const mx = useMatrixClient();
|
|
const mDirects = useAtomValue(mDirectAtom);
|
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
|
|
|
// Try to make a simple fetch to verify component is rendering
|
|
try {
|
|
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
|
|
} catch {}
|
|
|
|
useEffect(() => {
|
|
console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId());
|
|
startBackgroundSync(mx);
|
|
|
|
const onVisibility = () => setAppForegroundState(!document.hidden);
|
|
document.addEventListener('visibilitychange', onVisibility);
|
|
setAppForegroundState(!document.hidden);
|
|
|
|
return () => {
|
|
document.removeEventListener('visibilitychange', onVisibility);
|
|
stopBackgroundSync();
|
|
};
|
|
}, [mx]);
|
|
|
|
// Keep native background notifier informed of space → room grouping.
|
|
useEffect(() => {
|
|
const rooms: Record<
|
|
string,
|
|
{ groupId: string; groupName: string; roomName: string; kind: 'direct' | 'space' | 'home' }
|
|
> = {};
|
|
|
|
for (const room of mx.getRooms()) {
|
|
if (room.isSpaceRoom()) continue;
|
|
const roomId = room.roomId;
|
|
const roomName = room.name || roomId;
|
|
if (mDirects.has(roomId)) {
|
|
rooms[roomId] = {
|
|
groupId: NOTIF_GROUP_DIRECTS,
|
|
groupName: 'Direct messages',
|
|
roomName,
|
|
kind: 'direct',
|
|
};
|
|
continue;
|
|
}
|
|
const orphanParents = getOrphanParents(roomToParents, roomId);
|
|
if (orphanParents.length > 0) {
|
|
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
|
|
const spaceRoom = mx.getRoom(parentSpace);
|
|
rooms[roomId] = {
|
|
groupId: parentSpace,
|
|
groupName: spaceRoom?.name || 'Space',
|
|
roomName,
|
|
kind: 'space',
|
|
};
|
|
} else {
|
|
rooms[roomId] = {
|
|
groupId: NOTIF_GROUP_HOME,
|
|
groupName: 'Home',
|
|
roomName,
|
|
kind: 'home',
|
|
};
|
|
}
|
|
}
|
|
|
|
void syncNotificationGroupMap(rooms);
|
|
}, [mx, mDirects, roomToParents]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Initializes the Paarrot API for Electron integration
|
|
* Registers the navigate function and sets up IPC handlers
|
|
*/
|
|
function PaarrotAPIInitializer() {
|
|
const navigate = useNavigate();
|
|
const mx = useMatrixClient();
|
|
|
|
useEffect(() => {
|
|
// Register navigate function for Paarrot API
|
|
setPaarrotNavigate(navigate);
|
|
|
|
// Initialize Paarrot API handlers
|
|
initPaarrotAPI(mx);
|
|
|
|
console.log('Paarrot API: Initialized with navigate function');
|
|
}, [navigate, mx]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Stops taskbar icon flashing when window gains focus
|
|
*/
|
|
function TaskbarFlashStopper() {
|
|
useEffect(() => {
|
|
// Log what's available on mount
|
|
console.log('[TaskbarFlashStopper] window.electron:', (window as any).electron);
|
|
console.log('[TaskbarFlashStopper] Available APIs:', {
|
|
hasWindow: !!(window as any).electron?.window,
|
|
hasFlashFrame: !!(window as any).electron?.window?.flashFrame,
|
|
hasAudio: !!(window as any).electron?.audio,
|
|
hasPlaySound: !!(window as any).electron?.audio?.playNotificationSound,
|
|
});
|
|
|
|
const handleFocus = () => {
|
|
if ((window as any).electron?.window?.flashFrame) {
|
|
(window as any).electron.window.flashFrame(false);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('focus', handleFocus);
|
|
return () => {
|
|
window.removeEventListener('focus', handleFocus);
|
|
};
|
|
}, []);
|
|
|
|
return null;
|
|
}
|
|
|
|
function AndroidShareIntentHandler() {
|
|
const mx = useMatrixClient();
|
|
const store = useStore();
|
|
const navigate = useNavigate();
|
|
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
|
const mDirects = useAtomValue(mDirectAtom);
|
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
|
const selectedRoomId = useSelectedRoom();
|
|
const selectedRoomIdRef = useRef(selectedRoomId);
|
|
selectedRoomIdRef.current = selectedRoomId;
|
|
|
|
/** Queue shared files into the open room's composer upload board (Gboard / paste / share). */
|
|
const queueShareAsDrafts = useCallback(
|
|
async (share: AndroidSharePayload, roomId: string) => {
|
|
const room = mx.getRoom(roomId);
|
|
if (!room) return false;
|
|
|
|
if (share.files.length > 0) {
|
|
const originalFiles: File[] = [];
|
|
for (const sharedFile of share.files) {
|
|
originalFiles.push(await materializeSharedFile(sharedFile, share.receivedAt));
|
|
}
|
|
const safeFiles = originalFiles.map(safeFile);
|
|
const fileItems: TUploadItem[] = [];
|
|
|
|
if (room.hasEncryptionStateEvent()) {
|
|
const encryptFiles = fulfilledPromiseSettledResult(
|
|
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
|
|
);
|
|
encryptFiles.forEach((ef) =>
|
|
fileItems.push({
|
|
...ef,
|
|
metadata: { markedAsSpoiler: false },
|
|
})
|
|
);
|
|
} else {
|
|
safeFiles.forEach((f) =>
|
|
fileItems.push({
|
|
file: f,
|
|
originalFile: f,
|
|
encInfo: undefined,
|
|
metadata: { markedAsSpoiler: false },
|
|
})
|
|
);
|
|
}
|
|
|
|
store.set(roomIdToUploadItemsAtomFamily(roomId), {
|
|
type: 'PUT',
|
|
item: fileItems,
|
|
});
|
|
}
|
|
|
|
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
|
(value): value is string => !!value
|
|
);
|
|
if (nextParts.length > 0) {
|
|
await mx.sendMessage(roomId, {
|
|
msgtype: 'm.text' as const,
|
|
body: nextParts.join('\n'),
|
|
});
|
|
}
|
|
|
|
await clearPendingAndroidShare();
|
|
return true;
|
|
},
|
|
[mx, store]
|
|
);
|
|
|
|
const applyPendingShare = useCallback(
|
|
async (share: AndroidSharePayload, roomId: string) => {
|
|
const room = mx.getRoom(roomId);
|
|
if (!room) return false;
|
|
|
|
// Send text message if present
|
|
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
|
(value): value is string => !!value
|
|
);
|
|
if (nextParts.length > 0) {
|
|
const textContent = {
|
|
msgtype: 'm.text' as const,
|
|
body: nextParts.join('\n'),
|
|
};
|
|
await mx.sendMessage(roomId, textContent);
|
|
}
|
|
|
|
// Upload and send files if present
|
|
if (share.files.length > 0) {
|
|
for (const sharedFile of share.files) {
|
|
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
|
|
|
let fileToUpload = originalFile;
|
|
let encInfo: any = undefined;
|
|
|
|
if (room.hasEncryptionStateEvent()) {
|
|
const encrypted = await encryptFile(originalFile);
|
|
fileToUpload = encrypted.file;
|
|
encInfo = encrypted.encInfo;
|
|
}
|
|
|
|
// Upload file
|
|
const uploadResult = await mx.uploadContent(fileToUpload);
|
|
const mxc = uploadResult?.content_uri;
|
|
if (!mxc) continue;
|
|
|
|
// Determine message type and send
|
|
const fileType = originalFile.type;
|
|
let msgtype = 'm.file' as const;
|
|
if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
|
|
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
|
|
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
|
|
|
|
const fileContent: any = {
|
|
msgtype,
|
|
body: originalFile.name,
|
|
filename: originalFile.name,
|
|
info: {
|
|
mimetype: originalFile.type,
|
|
size: originalFile.size,
|
|
},
|
|
};
|
|
|
|
if (encInfo) {
|
|
fileContent.file = {
|
|
...encInfo,
|
|
url: mxc,
|
|
};
|
|
} else {
|
|
fileContent.url = mxc;
|
|
}
|
|
|
|
await mx.sendMessage(roomId, fileContent);
|
|
}
|
|
}
|
|
|
|
await clearPendingAndroidShare();
|
|
return true;
|
|
},
|
|
[mx]
|
|
);
|
|
|
|
const ingestShare = useCallback(
|
|
(share: AndroidSharePayload) => {
|
|
const roomId = selectedRoomIdRef.current;
|
|
// Already in a room: attach to composer (Gboard GIF / paste / in-app share).
|
|
if (roomId && share.files.length > 0) {
|
|
queueShareAsDrafts(share, roomId).catch((err) => {
|
|
console.error('[AndroidShare] Failed to queue share into room:', err);
|
|
setPendingShare(share);
|
|
});
|
|
return;
|
|
}
|
|
setPendingShare(share);
|
|
},
|
|
[queueShareAsDrafts]
|
|
);
|
|
|
|
const handlePickRoom = useCallback(
|
|
(roomId: string) => {
|
|
if (!pendingShare) return;
|
|
|
|
applyPendingShare(pendingShare, roomId)
|
|
.then((applied) => {
|
|
if (applied) {
|
|
setPendingShare(null);
|
|
|
|
// Navigate to the selected room
|
|
const isDirect = mDirects.has(roomId);
|
|
if (isDirect) {
|
|
navigate(getDirectRoomPath(roomId));
|
|
} else {
|
|
const parents = roomToParents.get(roomId);
|
|
const parent = parents && parents.length > 0 ? parents[0] : undefined;
|
|
if (parent) {
|
|
navigate(getSpaceRoomPath(parent, roomId));
|
|
} else {
|
|
navigate(getHomeRoomPath(roomId));
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error('[AndroidShare] Failed to apply share after pick:', err);
|
|
setPendingShare(null);
|
|
});
|
|
},
|
|
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
|
|
);
|
|
|
|
const handleDismiss = useCallback(() => {
|
|
clearPendingAndroidShare().catch(() => {});
|
|
setPendingShare(null);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isAndroidShareSupported()) return;
|
|
|
|
let mounted = true;
|
|
let listenerHandle: { remove: () => Promise<void> } | undefined;
|
|
|
|
getPendingAndroidShare()
|
|
.then((share) => {
|
|
if (mounted && share) {
|
|
ingestShare(share);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error('[AndroidShare] Failed to get pending share:', err);
|
|
});
|
|
|
|
listenForAndroidShares((share) => {
|
|
if (mounted) {
|
|
ingestShare(share);
|
|
}
|
|
})
|
|
.then((handle) => {
|
|
listenerHandle = handle;
|
|
})
|
|
.catch((err) => {
|
|
console.error('[AndroidShare] Failed to listen for shares:', err);
|
|
});
|
|
|
|
return () => {
|
|
mounted = false;
|
|
void listenerHandle?.remove();
|
|
};
|
|
}, [ingestShare]);
|
|
|
|
if (!pendingShare) return null;
|
|
|
|
return (
|
|
<ShareRoomPicker
|
|
share={pendingShare}
|
|
onPick={handlePickRoom}
|
|
onDismiss={handleDismiss}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type ClientNonUIFeaturesProps = {
|
|
children: ReactNode;
|
|
};
|
|
|
|
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|
return (
|
|
<>
|
|
<EmojiStyleFeature />
|
|
<PageZoomFeature />
|
|
<FaviconUpdater />
|
|
<InviteNotifications />
|
|
<MessageNotifications />
|
|
<BackgroundSyncSetup />
|
|
<PaarrotAPIInitializer />
|
|
<TaskbarFlashStopper />
|
|
<AndroidShareIntentHandler />
|
|
{children}
|
|
</>
|
|
);
|
|
}
|