diff --git a/android/app/src/main/java/com/paarrot/app/MainActivity.java b/android/app/src/main/java/com/paarrot/app/MainActivity.java index 629c605..7511f19 100644 --- a/android/app/src/main/java/com/paarrot/app/MainActivity.java +++ b/android/app/src/main/java/com/paarrot/app/MainActivity.java @@ -15,6 +15,19 @@ public class MainActivity extends BridgeActivity { setupImageKeyboardSupport(); } + @Override + public void onResume() { + super.onResume(); + // Keep native sync suppression in sync with the real Activity lifecycle. + MatrixSyncService.appInForeground = true; + } + + @Override + public void onPause() { + MatrixSyncService.appInForeground = false; + super.onPause(); + } + /** Advertise image MIME types and receive Gboard / paste / drag-drop media. */ private void setupImageKeyboardSupport() { if (bridge == null) return; diff --git a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt index a7fcf43..45b4c8d 100644 --- a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt +++ b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt @@ -14,7 +14,14 @@ import android.util.Base64 import android.util.Log import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.graphics.drawable.IconCompat import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -69,7 +76,7 @@ class MatrixSyncService : Service() { serviceScope.launch { try { - runSingleSyncFetch(homeserver, token, userId) + runSingleSyncFetch(homeserver, token, userId, triggerReason) Log.d(TAG, "One-shot sync completed (reason=$triggerReason)") } finally { stopForegroundCompat() @@ -85,7 +92,12 @@ class MatrixSyncService : Service() { job.cancel() } - private suspend fun runSingleSyncFetch(homeserver: String, token: String, userId: String) { + private suspend fun runSingleSyncFetch( + homeserver: String, + token: String, + userId: String, + triggerReason: String, + ) { val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val since = prefs.getString(KEY_SINCE, null) val isFirstSync = since == null @@ -102,8 +114,17 @@ class MatrixSyncService : Service() { if (nextBatch != null) { prefs.edit().putString(KEY_SINCE, nextBatch).apply() - if (!isFirstSync && !appInForeground) { + // Push wakes should still notify even if the WebView leftover + // "foreground" flag is stale; JS path is suppressed while backgrounded. + val fromPush = triggerReason.startsWith("unifiedpush") + val suppressBecauseUi = appInForeground && !fromPush + if (!isFirstSync && !suppressBecauseUi) { processRoomEvents(json, userId) + } else { + Log.d( + TAG, + "Skipping notifications (firstSync=$isFirstSync, foreground=$appInForeground, reason=$triggerReason)", + ) } } } @@ -127,7 +148,8 @@ class MatrixSyncService : Service() { } private fun buildSyncUrl(base: String, since: String?): String { - val filter = """{"room":{"timeline":{"limit":10,"types":["m.room.message"]},"state":{"types":[]},"account_data":{"types":[]},"ephemeral":{"types":[]}},"account_data":{"types":[]},"presence":{"types":[]}}""" + // Include encrypted events — most DMs/rooms are E2EE and never emit plaintext m.room.message. + val filter = """{"room":{"timeline":{"limit":10,"types":["m.room.message","m.room.encrypted","m.sticker"]},"state":{"types":[]},"account_data":{"types":[]},"ephemeral":{"types":[]}},"account_data":{"types":[]},"presence":{"types":[]}}""" val encodedFilter = URLEncoder.encode(filter, "UTF-8") val sinceParam = if (since != null) "&since=${URLEncoder.encode(since, "UTF-8")}" else "" return "$base/_matrix/client/v3/sync?timeout=12000&filter=$encodedFilter$sinceParam" @@ -185,41 +207,71 @@ class MatrixSyncService : Service() { // Homeserver unread counts already apply push rules — skip rooms with nothing to notify. val unread = roomData.optJSONObject("unread_notifications") val notificationCount = unread?.optInt("notification_count", 0) ?: 0 + val highlightCount = unread?.optInt("highlight_count", 0) ?: 0 if (mode == RoomNotifyMode.MUTE || notificationCount <= 0) continue val mentionsOnly = mode == RoomNotifyMode.MENTIONS_AND_KEYWORDS + if (mentionsOnly && highlightCount <= 0) { + // Mentions-only rooms: without a highlight, skip (encrypted bodies can't be scanned). + continue + } + val timeline = roomData.optJSONObject("timeline") ?: continue val events = timeline.optJSONArray("events") ?: continue + var notifiedForRoom = false for (i in 0 until events.length()) { val event = events.optJSONObject(i) ?: continue val eventId = event.optString("event_id") + val eventType = event.optString("type") - if (event.optString("type") != "m.room.message") continue + val isMessageLike = + eventType == "m.room.message" || + eventType == "m.room.encrypted" || + eventType == "m.sticker" + if (!isMessageLike) continue if (event.optString("sender") == myUserId) continue if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue - val content = event.optJSONObject("content") ?: continue - if (mentionsOnly && !isSpecialMessage(content, notifyCtx)) continue + val content = event.optJSONObject("content") ?: JSONObject() - val msgtype = content.optString("msgtype") - val rawBody = content.optString("body") - val body = when (msgtype) { - "m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo" - "m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video" - "m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio" - "m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File" - "m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker" - else -> rawBody.takeIf { it.isNotBlank() } ?: continue + // Plaintext mention/keyword filter; encrypted events already passed highlight_count. + if ( + mentionsOnly && + eventType != "m.room.encrypted" && + !isSpecialMessage(content, notifyCtx) + ) { + continue } + val body = when { + eventType == "m.room.encrypted" -> "Encrypted message" + eventType == "m.sticker" -> { + val raw = content.optString("body") + if (raw.isNotBlank()) "🖼️ $raw" else "🖼️ Sticker" + } + else -> { + val msgtype = content.optString("msgtype") + val rawBody = content.optString("body") + when (msgtype) { + "m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo" + "m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video" + "m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio" + "m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File" + "m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker" + else -> rawBody.takeIf { it.isNotBlank() } + } + } + } ?: continue + val sender = event.optString("sender") val profile = resolveProfile(sender, homeserver, token) - // For unencrypted image/sticker messages, try to download a preview bitmap. - // Encrypted messages have a `file` object instead of a top-level `url`. + val msgtype = content.optString("msgtype") val inlineImage: Bitmap? = if ( - (msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file") + eventType != "m.room.encrypted" && + (msgtype == "m.image" || msgtype == "m.sticker" || eventType == "m.sticker") && + !content.has("file") ) { content.optString("url").takeIf { it.startsWith("mxc://") }?.let { mxc -> mxcToDownloadUrls(mxc, homeserver) @@ -236,6 +288,21 @@ class MatrixSyncService : Service() { inlineImage, resolveGroupInfo(roomId, notifyCtx), ) + notifiedForRoom = true + } + + // Fallback: HS says there are notifications but timeline filter missed usable events. + if (!notifiedForRoom && notificationCount > 0) { + val groupInfo = resolveGroupInfo(roomId, notifyCtx) + showMessageNotification( + nm, + roomId, + groupInfo.roomName.ifBlank { "New message" }, + if (mentionsOnly) "New mention" else "New message", + null, + null, + groupInfo, + ) } } } @@ -540,18 +607,18 @@ class MatrixSyncService : Service() { groupInfo: NotificationGroupInfo, ) { val isDm = groupInfo.kind == "direct" - val title = if (isDm) sender else groupInfo.roomName.ifBlank { sender } - val text = if (isDm) body else "$sender: $body" postMessageNotification( this, roomId = roomId, - title = title, - body = text, + senderName = sender, + messageText = body, + conversationTitle = if (isDm) null else groupInfo.roomName.ifBlank { null }, groupId = groupInfo.groupId, groupName = groupInfo.groupName, kind = groupInfo.kind, largeIcon = largeIcon, inlineImage = inlineImage, + path = null, ) } @@ -675,37 +742,107 @@ class MatrixSyncService : Service() { } } + /** Crop a bitmap into a circle for the notification avatar slot. */ + fun toCircularBitmap(bitmap: Bitmap): Bitmap { + val size = minOf(bitmap.width, bitmap.height) + val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val radius = size / 2f + canvas.drawCircle(radius, radius, radius, paint) + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) + val left = (bitmap.width - size) / 2 + val top = (bitmap.height - size) / 2 + canvas.drawBitmap( + bitmap, + Rect(left, top, left + size, top + size), + Rect(0, 0, size, size), + paint, + ) + return output + } + + /** Soften URLs so Android Assistant doesn't add "Open link in Firefox" actions. */ + fun sanitizeNotificationText(text: String): String = + text.replace(Regex("""https?://\S+""", RegexOption.IGNORE_CASE), "🔗 link") + /** * Posts a room notification (+ space/DM group summary) for both background sync - * and JS-driven Capacitor notifications (with optional avatar bitmap). + * and JS-driven Capacitor notifications. + * + * Avatar goes in the circular Person / largeIcon slot on the left. + * Optional image attachments use BigPictureStyle while keeping that circular avatar. */ fun postMessageNotification( context: Context, roomId: String, - title: String, - body: String, + senderName: String, + messageText: String, + conversationTitle: String?, groupId: String, groupName: String, kind: String, largeIcon: Bitmap? = null, inlineImage: Bitmap? = null, + path: String? = null, + // Back-compat for older call sites that passed title/body. + title: String? = null, + body: String? = null, ) { val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager ensureMessageChannels(context) - val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) - ?: Intent(context, MainActivity::class.java) + val resolvedSender = senderName.ifBlank { title ?: "Someone" } + val resolvedMessage = sanitizeNotificationText( + messageText.ifBlank { body ?: "New message" }, + ) + val isDm = kind == "direct" + val resolvedConversation = + conversationTitle?.takeIf { it.isNotBlank() } + ?: title?.takeIf { !isDm && it != resolvedSender } + + val launchIntent = Intent(context, MainActivity::class.java).apply { + action = NotificationNavStore.ACTION_OPEN_NOTIFICATION + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + putExtra(EXTRA_ROOM_ID, roomId) + if (!path.isNullOrBlank()) { + putExtra(NotificationNavStore.EXTRA_NAV_PATH, path) + } + } val roomNotifId = notificationIdForRoom(roomId) val pi = PendingIntent.getActivity( context, roomNotifId, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + val avatar = largeIcon?.let { toCircularBitmap(it) } + val personBuilder = Person.Builder() + .setName(resolvedSender) + .setKey(resolvedSender) + if (avatar != null) { + personBuilder.setIcon(IconCompat.createWithBitmap(avatar)) + } + val senderPerson = personBuilder.build() + val channelId = channelIdForKindStatic(kind) + val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) { + resolvedSender + } else { + resolvedConversation + } + val collapsedText = if (isDm || resolvedConversation.isNullOrBlank()) { + resolvedMessage + } else { + "$resolvedSender: $resolvedMessage" + } + val builder = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_stat_paarrot) - .setContentTitle(title) - .setContentText(body) + .setContentTitle(collapsedTitle) + .setContentText(collapsedText) .setAutoCancel(true) .setContentIntent(pi) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -713,24 +850,42 @@ class MatrixSyncService : Service() { .setGroup(groupId) .setOnlyAlertOnce(true) .setSubText(groupName) + .setShortcutId(roomId) + // Prevent system "Open link in Chrome/Firefox" contextual actions on URL bodies. + .setAllowSystemGeneratedContextualActions(false) builder.extras.putString(EXTRA_ROOM_ID, roomId) builder.extras.putString(EXTRA_GROUP_ID, groupId) + if (!path.isNullOrBlank()) { + builder.extras.putString(NotificationNavStore.EXTRA_NAV_PATH, path) + } - if (largeIcon != null) builder.setLargeIcon(largeIcon) + // Circular avatar on the left (collapsed + MessagingStyle Person). + if (avatar != null) builder.setLargeIcon(avatar) if (inlineImage != null) { + // Expanded image attachment — keep avatar in the circular large-icon slot. builder.setStyle( NotificationCompat.BigPictureStyle() .bigPicture(inlineImage) - .bigLargeIcon(null as Bitmap?) + .bigLargeIcon(avatar) + .setBigContentTitle(collapsedTitle) + .setSummaryText(collapsedText) ) } else { - builder.setStyle( - NotificationCompat.BigTextStyle() - .bigText(body) - .setSummaryText(groupName) - ) + val messaging = NotificationCompat.MessagingStyle(senderPerson) + .addMessage( + NotificationCompat.MessagingStyle.Message( + resolvedMessage, + System.currentTimeMillis(), + senderPerson, + ) + ) + if (!resolvedConversation.isNullOrBlank() && !isDm) { + messaging.conversationTitle = resolvedConversation + messaging.isGroupConversation = true + } + builder.setStyle(messaging) } nm.notify(roomNotifId, builder.build()) @@ -746,6 +901,7 @@ class MatrixSyncService : Service() { .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setGroup(groupId) .setGroupSummary(true) + .setAllowSystemGeneratedContextualActions(false) .setStyle( NotificationCompat.InboxStyle() .setBigContentTitle(groupName) diff --git a/android/app/src/main/java/com/paarrot/app/NotificationNavStore.kt b/android/app/src/main/java/com/paarrot/app/NotificationNavStore.kt new file mode 100644 index 0000000..c33276e --- /dev/null +++ b/android/app/src/main/java/com/paarrot/app/NotificationNavStore.kt @@ -0,0 +1,42 @@ +package com.paarrot.app + +import android.content.Intent + +/** + * Bridges notification tap intents into the Capacitor JS layer so we can + * navigate to the correct room when a tray notification is opened. + */ +object NotificationNavStore { + const val EXTRA_NAV_PATH = "paarrot_nav_path" + const val ACTION_OPEN_NOTIFICATION = "com.paarrot.app.OPEN_NOTIFICATION" + + @Volatile + var pendingPath: String? = null + + @Volatile + var pendingRoomId: String? = null + + @Volatile + var plugin: SyncServicePlugin? = null + + /** Read navigation extras from a notification tap [intent] and notify JS. */ + fun handleIntent(intent: Intent?) { + if (intent == null) return + val path = intent.getStringExtra(EXTRA_NAV_PATH)?.takeIf { it.isNotBlank() } + val roomId = intent.getStringExtra(MatrixSyncService.EXTRA_ROOM_ID)?.takeIf { it.isNotBlank() } + if (path == null && roomId == null) return + + pendingPath = path + pendingRoomId = roomId + plugin?.emitNotificationOpened(path, roomId) + } + + /** Consume and clear the pending navigation target. */ + fun consume(): Pair { + val path = pendingPath + val roomId = pendingRoomId + pendingPath = null + pendingRoomId = null + return path to roomId + } +} diff --git a/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt b/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt index bbb50fd..eb7730e 100644 --- a/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt +++ b/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt @@ -31,13 +31,44 @@ class SyncServicePlugin : Plugin() { override fun load() { super.load() UnifiedPushManager.setPlugin(this) + NotificationNavStore.plugin = this + NotificationNavStore.handleIntent(activity?.intent) + } + + override fun handleOnNewIntent(intent: Intent?) { + super.handleOnNewIntent(intent) + if (intent != null) { + activity?.intent = intent + } + NotificationNavStore.handleIntent(intent) } override fun handleOnDestroy() { UnifiedPushManager.clearPlugin(this) + if (NotificationNavStore.plugin === this) { + NotificationNavStore.plugin = null + } super.handleOnDestroy() } + /** Emit a notificationOpened event toward the JS layer. */ + fun emitNotificationOpened(path: String?, roomId: String?) { + val payload = JSObject() + if (path != null) payload.put("path", path) + if (roomId != null) payload.put("roomId", roomId) + notifyListeners("notificationOpened", payload, true) + } + + /** Returns any pending notification navigation target from a tray tap. */ + @PluginMethod + fun getPendingNotificationNav(call: PluginCall) { + val (path, roomId) = NotificationNavStore.consume() + val result = JSObject() + result.put("path", path) + result.put("roomId", roomId) + call.resolve(result) + } + /** Persists Matrix credentials and starts UnifiedPush registration. */ @PluginMethod fun start(call: PluginCall) { @@ -132,23 +163,33 @@ class SyncServicePlugin : Plugin() { */ @PluginMethod fun showNotification(call: PluginCall) { - val title = call.getString("title") ?: return call.reject("title required") - val body = call.getString("body") ?: return call.reject("body required") val roomId = call.getString("roomId") ?: return call.reject("roomId required") val groupId = call.getString("groupId") ?: "paarrot_home" val groupName = call.getString("groupName") ?: "Home" val kind = call.getString("kind") ?: "home" + val senderName = call.getString("senderName") + ?: call.getString("title") + ?: "Someone" + val messageText = call.getString("messageText") + ?: call.getString("body") + ?: "New message" + val conversationTitle = call.getString("conversationTitle") + val path = call.getString("path") val largeIconBase64 = call.getString("largeIconBase64") + val bigPictureBase64 = call.getString("bigPictureBase64") MatrixSyncService.postMessageNotification( context = context, roomId = roomId, - title = title, - body = body, + senderName = senderName, + messageText = messageText, + conversationTitle = conversationTitle, groupId = groupId, groupName = groupName, kind = kind, largeIcon = MatrixSyncService.decodeBase64Bitmap(largeIconBase64), + inlineImage = MatrixSyncService.decodeBase64Bitmap(bigPictureBase64), + path = path, ) call.resolve(JSObject().put("shown", true)) } diff --git a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx index 44d3612..2784323 100644 --- a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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 { 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 { + 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 { + const content = (mEvent.getClearContent() ?? mEvent.getContent()) as Record; + 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>(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, diff --git a/overlay/src/app/utils/backgroundSync.ts b/overlay/src/app/utils/backgroundSync.ts index aa08cb7..fc50dc3 100644 --- a/overlay/src/app/utils/backgroundSync.ts +++ b/overlay/src/app/utils/backgroundSync.ts @@ -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; 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 => { 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 => { + if (!isBackgroundSyncSupported()) return undefined; + try { + return await MatrixBackgroundSync.addListener('notificationOpened', listener); + } catch (err) { + console.warn('[BackgroundSync] notificationOpened listener failed:', err); + return undefined; + } +}; diff --git a/overlay/src/app/utils/tauri.ts b/overlay/src/app/utils/tauri.ts index 7e7ee65..f03994c 100644 --- a/overlay/src/app/utils/tauri.ts +++ b/overlay/src/app/utils/tauri.ts @@ -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 => { - 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) {