Fix Android notification avatars, E2EE wake alerts, and tap navigation.
Show circular sender avatars with MessagingStyle, notify on encrypted background sync events, deep-link tray taps into the right room, and suppress Firefox open-link contextual actions.
This commit is contained in:
@@ -29,6 +29,8 @@ import {
|
||||
getCanonicalAliasOrRoomId,
|
||||
encryptFile,
|
||||
downloadMedia,
|
||||
downloadEncryptedMedia,
|
||||
decryptFile,
|
||||
} from '../../utils/matrix';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
@@ -140,6 +142,15 @@ async function mediaUrlToBase64(
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const blob = await downloadMedia(url, accessToken);
|
||||
return blobToBase64(blob);
|
||||
} catch (err) {
|
||||
console.warn('[Notifications] Failed to fetch media for notification:', err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function blobToBase64(blob: Blob): Promise<string | undefined> {
|
||||
try {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
@@ -149,12 +160,73 @@ async function mediaUrlToBase64(
|
||||
}
|
||||
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);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a sender display name from room membership, user directory, then MXID. */
|
||||
function resolveSenderDisplayName(mx: MatrixClient, room: { roomId: string }, sender: string): string {
|
||||
return (
|
||||
getMemberDisplayName(room as any, sender) ??
|
||||
mx.getUser(sender)?.displayName ??
|
||||
getMxIdLocalPart(sender) ??
|
||||
sender
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch image attachment preview (plaintext or encrypted) for the notification shade. */
|
||||
async function fetchNotificationImageBase64(
|
||||
mx: MatrixClient,
|
||||
mEvent: MatrixEvent,
|
||||
useAuthentication: boolean
|
||||
): Promise<string | undefined> {
|
||||
const content = (mEvent.getClearContent() ?? mEvent.getContent()) as Record<string, any>;
|
||||
if (content.msgtype !== 'm.image' && mEvent.getType() !== 'm.sticker') return undefined;
|
||||
|
||||
const accessToken = mx.getAccessToken();
|
||||
const info = content.info ?? {};
|
||||
|
||||
try {
|
||||
// Prefer a smaller thumbnail when available.
|
||||
if (info.thumbnail_file?.url) {
|
||||
const mediaUrl =
|
||||
mxcUrlToHttp(mx, info.thumbnail_file.url, useAuthentication) ?? info.thumbnail_file.url;
|
||||
const blob = await downloadEncryptedMedia(
|
||||
mediaUrl,
|
||||
(encBuf) =>
|
||||
decryptFile(encBuf, info.thumbnail_info?.mimetype ?? 'image/jpeg', info.thumbnail_file),
|
||||
accessToken
|
||||
);
|
||||
return blobToBase64(blob);
|
||||
}
|
||||
if (typeof info.thumbnail_url === 'string') {
|
||||
const mediaUrl =
|
||||
mxcUrlToHttp(mx, info.thumbnail_url, useAuthentication, 512, 512, 'scale') ??
|
||||
mxcUrlToHttp(mx, info.thumbnail_url, useAuthentication);
|
||||
if (mediaUrl) return mediaUrlToBase64(mediaUrl, accessToken);
|
||||
}
|
||||
if (content.file?.url) {
|
||||
const mediaUrl = mxcUrlToHttp(mx, content.file.url, useAuthentication) ?? content.file.url;
|
||||
const blob = await downloadEncryptedMedia(
|
||||
mediaUrl,
|
||||
(encBuf) => decryptFile(encBuf, info.mimetype ?? 'image/jpeg', content.file),
|
||||
accessToken
|
||||
);
|
||||
return blobToBase64(blob);
|
||||
}
|
||||
if (typeof content.url === 'string') {
|
||||
const mediaUrl =
|
||||
mxcUrlToHttp(mx, content.url, useAuthentication, 512, 512, 'scale') ??
|
||||
mxcUrlToHttp(mx, content.url, useAuthentication);
|
||||
if (mediaUrl) return mediaUrlToBase64(mediaUrl, accessToken);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Notifications] Failed to fetch image attachment for notification:', err);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the selected emoji style font to the document.
|
||||
* - System: Uses the native OS emoji font
|
||||
@@ -319,10 +391,37 @@ function MessageNotifications() {
|
||||
|
||||
// Set up notification tap listener for mobile
|
||||
useEffect(() => {
|
||||
setupNotificationTapListener((path) => {
|
||||
navigate(path);
|
||||
});
|
||||
}, [navigate]);
|
||||
const openFromNotification = (target: string) => {
|
||||
if (!target) return;
|
||||
|
||||
if (target.startsWith('__room__:')) {
|
||||
const roomId = target.slice('__room__:'.length);
|
||||
if (!mx || !roomId) return;
|
||||
try {
|
||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
||||
if (mDirects.has(roomId)) {
|
||||
navigate(getDirectRoomPath(roomIdOrAlias));
|
||||
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));
|
||||
return;
|
||||
}
|
||||
navigate(getHomeRoomPath(roomIdOrAlias));
|
||||
} catch (err) {
|
||||
console.error('[Notifications] Navigate from roomId error:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(target);
|
||||
};
|
||||
|
||||
setupNotificationTapListener(openFromNotification);
|
||||
}, [navigate, mx, mDirects, roomToParents]);
|
||||
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
const previousUnreadRoomsRef = useRef<Set<string>>(new Set());
|
||||
@@ -343,6 +442,7 @@ function MessageNotifications() {
|
||||
roomName,
|
||||
roomAvatar,
|
||||
iconBase64,
|
||||
bigPictureBase64,
|
||||
username,
|
||||
messageBody,
|
||||
roomId,
|
||||
@@ -352,6 +452,7 @@ function MessageNotifications() {
|
||||
roomName: string;
|
||||
roomAvatar?: string;
|
||||
iconBase64?: string;
|
||||
bigPictureBase64?: string;
|
||||
username: string;
|
||||
messageBody?: string;
|
||||
roomId: string;
|
||||
@@ -394,6 +495,8 @@ function MessageNotifications() {
|
||||
: messageBody
|
||||
? `${username}: ${messageBody}`
|
||||
: `${username} sent a message`;
|
||||
const messageText = messageBody || 'New message';
|
||||
const conversationTitle = isDm ? undefined : roomName || undefined;
|
||||
|
||||
/** Replicates TitleBar click navigation logic */
|
||||
const navigateToRoom = () => {
|
||||
@@ -438,11 +541,15 @@ function MessageNotifications() {
|
||||
sendNotification({
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
senderName: username,
|
||||
messageText,
|
||||
conversationTitle,
|
||||
path: roomPath,
|
||||
roomId,
|
||||
group,
|
||||
icon: roomAvatar,
|
||||
iconBase64,
|
||||
bigPictureBase64,
|
||||
onClick: () => {
|
||||
if (!window.closed) navigate(roomPath);
|
||||
},
|
||||
@@ -551,10 +658,16 @@ function MessageNotifications() {
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
let iconBase64: string | undefined;
|
||||
if (roomAvatar && isCapacitorNative()) {
|
||||
iconBase64 = await mediaUrlToBase64(roomAvatar, mx.getAccessToken());
|
||||
}
|
||||
const username = resolveSenderDisplayName(mx, room, sender);
|
||||
|
||||
const [iconBase64, bigPictureBase64] = await Promise.all([
|
||||
roomAvatar && isCapacitorNative()
|
||||
? mediaUrlToBase64(roomAvatar, mx.getAccessToken())
|
||||
: Promise.resolve(undefined),
|
||||
isCapacitorNative()
|
||||
? fetchNotificationImageBase64(mx, mEvent, useAuthentication)
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const messageBody = notificationBodyFromEvent(mEvent);
|
||||
|
||||
@@ -562,7 +675,8 @@ function MessageNotifications() {
|
||||
roomName: room.name ?? 'Unknown',
|
||||
roomAvatar,
|
||||
iconBase64,
|
||||
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
|
||||
bigPictureBase64,
|
||||
username,
|
||||
messageBody,
|
||||
roomId: room.roomId,
|
||||
eventId,
|
||||
|
||||
@@ -66,14 +66,25 @@ interface MatrixBackgroundSyncPlugin {
|
||||
}): Promise<{ success: boolean }>;
|
||||
/** Post a message notification with optional avatar (base64) from the JS layer. */
|
||||
showNotification(options: {
|
||||
title: string;
|
||||
body: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
senderName?: string;
|
||||
messageText?: string;
|
||||
conversationTitle?: string;
|
||||
path?: string;
|
||||
roomId: string;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
kind: string;
|
||||
largeIconBase64?: string;
|
||||
bigPictureBase64?: string;
|
||||
}): Promise<{ shown: boolean }>;
|
||||
/** Pending navigation target from a native notification tap. */
|
||||
getPendingNotificationNav(): Promise<{ path?: string | null; roomId?: string | null }>;
|
||||
addListener(
|
||||
eventName: 'notificationOpened',
|
||||
listenerFunc: (event: { path?: string; roomId?: string }) => void
|
||||
): Promise<PluginListenerHandle>;
|
||||
addListener(
|
||||
eventName: 'unifiedPushNewEndpoint',
|
||||
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
||||
@@ -580,13 +591,18 @@ export const syncNotificationGroupMap = async (
|
||||
* Prefer this over Capacitor LocalNotifications on Android.
|
||||
*/
|
||||
export const showNativeNotification = async (options: {
|
||||
title: string;
|
||||
body: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
senderName?: string;
|
||||
messageText?: string;
|
||||
conversationTitle?: string;
|
||||
path?: string;
|
||||
roomId: string;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
kind: string;
|
||||
largeIconBase64?: string;
|
||||
bigPictureBase64?: string;
|
||||
}): Promise<boolean> => {
|
||||
if (!isBackgroundSyncSupported()) return false;
|
||||
|
||||
@@ -598,3 +614,30 @@ export const showNativeNotification = async (options: {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Consume a pending native notification navigation target, if any. */
|
||||
export const getPendingNotificationNav = async (): Promise<{
|
||||
path?: string | null;
|
||||
roomId?: string | null;
|
||||
}> => {
|
||||
if (!isBackgroundSyncSupported()) return {};
|
||||
try {
|
||||
return await MatrixBackgroundSync.getPendingNotificationNav();
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] getPendingNotificationNav failed:', err);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/** Subscribe to native notification taps that open the app. */
|
||||
export const listenForNotificationOpens = async (
|
||||
listener: (event: { path?: string; roomId?: string }) => void
|
||||
): Promise<PluginListenerHandle | undefined> => {
|
||||
if (!isBackgroundSyncSupported()) return undefined;
|
||||
try {
|
||||
return await MatrixBackgroundSync.addListener('notificationOpened', listener);
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] notificationOpened listener failed:', err);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -207,6 +207,36 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void
|
||||
} catch (err) {
|
||||
console.warn('Failed to set up Capacitor notification tap listener:', err);
|
||||
}
|
||||
|
||||
// Native NotificationManager posts (MatrixBackgroundSync.showNotification) open the
|
||||
// Activity with extras — LocalNotifications never sees those taps.
|
||||
try {
|
||||
const {
|
||||
getPendingNotificationNav,
|
||||
listenForNotificationOpens,
|
||||
} = await import('./backgroundSync');
|
||||
|
||||
const deliver = (path?: string | null, roomId?: string | null) => {
|
||||
if (path && notificationTapCallback) {
|
||||
notificationTapCallback(path);
|
||||
return;
|
||||
}
|
||||
if (roomId && notificationTapCallback) {
|
||||
// Encode a synthetic marker path that ClientNonUIFeatures can resolve.
|
||||
notificationTapCallback(`__room__:${roomId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const pending = await getPendingNotificationNav();
|
||||
deliver(pending.path, pending.roomId);
|
||||
|
||||
await listenForNotificationOpens((event) => {
|
||||
void focusWindow();
|
||||
deliver(event.path, event.roomId);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Failed to set up native notification open listener:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -633,12 +663,29 @@ export const sendNotification = async (options: {
|
||||
body: string;
|
||||
icon?: string;
|
||||
iconBase64?: string;
|
||||
bigPictureBase64?: string;
|
||||
senderName?: string;
|
||||
messageText?: string;
|
||||
conversationTitle?: string;
|
||||
path?: string;
|
||||
roomId?: string;
|
||||
group?: NotificationGroupInfo;
|
||||
onClick?: () => void;
|
||||
}): Promise<void> => {
|
||||
const { title, body, icon, iconBase64, path, roomId, group, onClick } = options;
|
||||
const {
|
||||
title,
|
||||
body,
|
||||
icon,
|
||||
iconBase64,
|
||||
bigPictureBase64,
|
||||
senderName,
|
||||
messageText,
|
||||
conversationTitle,
|
||||
path,
|
||||
roomId,
|
||||
group,
|
||||
onClick,
|
||||
} = options;
|
||||
const extra = {
|
||||
...(path ? { path } : {}),
|
||||
...(roomId ? { roomId } : {}),
|
||||
@@ -726,11 +773,16 @@ export const sendNotification = async (options: {
|
||||
const shown = await showNativeNotification({
|
||||
title,
|
||||
body,
|
||||
senderName: senderName ?? title,
|
||||
messageText: messageText ?? body,
|
||||
conversationTitle,
|
||||
path,
|
||||
roomId,
|
||||
groupId,
|
||||
groupName,
|
||||
kind: group.kind,
|
||||
largeIconBase64: iconBase64,
|
||||
bigPictureBase64,
|
||||
});
|
||||
if (shown) return;
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user