Fix Android notification avatars, E2EE wake alerts, and tap navigation.
Some checks failed
Build / increment-version (push) Successful in 5s
Build / build-android (push) Failing after 5m3s

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:
2026-07-24 15:03:03 +10:00
parent 3940441d9b
commit 157d707581
7 changed files with 518 additions and 57 deletions

View File

@@ -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,

View File

@@ -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;
}
};

View File

@@ -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) {