Show decrypted notification bodies and authenticated sender avatars.
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.
This commit is contained in:
@@ -10,6 +10,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
@@ -220,9 +221,10 @@ class MatrixSyncService : Service() {
|
|||||||
val inlineImage: Bitmap? = if (
|
val inlineImage: Bitmap? = if (
|
||||||
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file")
|
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file")
|
||||||
) {
|
) {
|
||||||
content.optString("url").takeIf { it.startsWith("mxc://") }
|
content.optString("url").takeIf { it.startsWith("mxc://") }?.let { mxc ->
|
||||||
?.let { mxcToDownloadUrl(it, homeserver) }
|
mxcToDownloadUrls(mxc, homeserver)
|
||||||
?.let { downloadBitmap(it, token) }
|
.firstNotNullOfOrNull { downloadBitmap(it, token) }
|
||||||
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
showMessageNotification(
|
showMessageNotification(
|
||||||
@@ -404,9 +406,7 @@ class MatrixSyncService : Service() {
|
|||||||
conn.disconnect()
|
conn.disconnect()
|
||||||
}
|
}
|
||||||
displayNameCache[mxid] = displayName
|
displayNameCache[mxid] = displayName
|
||||||
val avatar = avatarMxc
|
val avatar = avatarMxc?.let { downloadAvatarBitmap(it, homeserver, token, size = 96) }
|
||||||
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
|
|
||||||
?.let { downloadBitmap(it, token) }
|
|
||||||
avatarCache[mxid] = avatar
|
avatarCache[mxid] = avatar
|
||||||
UserProfile(displayName, avatar)
|
UserProfile(displayName, avatar)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -417,24 +417,47 @@ class MatrixSyncService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? {
|
private fun mxcToThumbnailUrls(mxcUrl: String, homeserver: String, size: Int): List<String> {
|
||||||
val withoutScheme = mxcUrl.removePrefix("mxc://")
|
val withoutScheme = mxcUrl.removePrefix("mxc://")
|
||||||
val slash = withoutScheme.indexOf('/')
|
val slash = withoutScheme.indexOf('/')
|
||||||
if (slash < 0) return null
|
if (slash < 0) return emptyList()
|
||||||
val serverName = withoutScheme.substring(0, slash)
|
val serverName = withoutScheme.substring(0, slash)
|
||||||
val mediaId = withoutScheme.substring(slash + 1)
|
val mediaId = withoutScheme.substring(slash + 1)
|
||||||
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
|
val query = "width=$size&height=$size&method=crop"
|
||||||
|
// Prefer MSC3916 authenticated media, fall back to legacy media repo.
|
||||||
|
return listOf(
|
||||||
|
"$homeserver/_matrix/client/v1/media/thumbnail/$serverName/$mediaId?$query",
|
||||||
|
"$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?$query",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? {
|
private fun mxcToDownloadUrls(mxcUrl: String, homeserver: String): List<String> {
|
||||||
val withoutScheme = mxcUrl.removePrefix("mxc://")
|
val withoutScheme = mxcUrl.removePrefix("mxc://")
|
||||||
val slash = withoutScheme.indexOf('/')
|
val slash = withoutScheme.indexOf('/')
|
||||||
if (slash < 0) return null
|
if (slash < 0) return emptyList()
|
||||||
val serverName = withoutScheme.substring(0, slash)
|
val serverName = withoutScheme.substring(0, slash)
|
||||||
val mediaId = withoutScheme.substring(slash + 1)
|
val mediaId = withoutScheme.substring(slash + 1)
|
||||||
return "$homeserver/_matrix/media/v3/download/$serverName/$mediaId"
|
return listOf(
|
||||||
|
"$homeserver/_matrix/client/v1/media/download/$serverName/$mediaId",
|
||||||
|
"$homeserver/_matrix/media/v3/download/$serverName/$mediaId",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun downloadAvatarBitmap(
|
||||||
|
mxcUrl: String,
|
||||||
|
homeserver: String,
|
||||||
|
token: String,
|
||||||
|
size: Int,
|
||||||
|
): Bitmap? {
|
||||||
|
for (url in mxcToThumbnailUrls(mxcUrl, homeserver, size)) {
|
||||||
|
downloadBitmap(url, token)?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? =
|
||||||
|
mxcToDownloadUrls(mxcUrl, homeserver).firstOrNull()
|
||||||
|
|
||||||
private fun downloadBitmap(urlString: String, token: String): Bitmap? {
|
private fun downloadBitmap(urlString: String, token: String): Bitmap? {
|
||||||
return try {
|
return try {
|
||||||
val conn = URL(urlString).openConnection() as HttpURLConnection
|
val conn = URL(urlString).openConnection() as HttpURLConnection
|
||||||
@@ -505,11 +528,7 @@ class MatrixSyncService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun channelIdForKind(kind: String): String = when (kind) {
|
private fun channelIdForKind(kind: String): String = channelIdForKindStatic(kind)
|
||||||
"direct" -> CHANNEL_DIRECTS
|
|
||||||
"space" -> CHANNEL_SPACES
|
|
||||||
else -> CHANNEL_HOME
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showMessageNotification(
|
private fun showMessageNotification(
|
||||||
nm: NotificationManager,
|
nm: NotificationManager,
|
||||||
@@ -520,72 +539,20 @@ class MatrixSyncService : Service() {
|
|||||||
inlineImage: Bitmap? = null,
|
inlineImage: Bitmap? = null,
|
||||||
groupInfo: NotificationGroupInfo,
|
groupInfo: NotificationGroupInfo,
|
||||||
) {
|
) {
|
||||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
|
||||||
?: Intent(this, MainActivity::class.java)
|
|
||||||
val roomNotifId = notificationIdForRoom(roomId)
|
|
||||||
val pi = PendingIntent.getActivity(
|
|
||||||
this, roomNotifId, launchIntent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
val isDm = groupInfo.kind == "direct"
|
val isDm = groupInfo.kind == "direct"
|
||||||
val title = if (isDm) sender else groupInfo.roomName.ifBlank { sender }
|
val title = if (isDm) sender else groupInfo.roomName.ifBlank { sender }
|
||||||
val text = if (isDm) body else "$sender: $body"
|
val text = if (isDm) body else "$sender: $body"
|
||||||
val channelId = channelIdForKind(groupInfo.kind)
|
postMessageNotification(
|
||||||
|
this,
|
||||||
val builder = NotificationCompat.Builder(this, channelId)
|
roomId = roomId,
|
||||||
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
title = title,
|
||||||
.setContentTitle(title)
|
body = text,
|
||||||
.setContentText(text)
|
groupId = groupInfo.groupId,
|
||||||
.setAutoCancel(true)
|
groupName = groupInfo.groupName,
|
||||||
.setContentIntent(pi)
|
kind = groupInfo.kind,
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
largeIcon = largeIcon,
|
||||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
inlineImage = inlineImage,
|
||||||
.setGroup(groupInfo.groupId)
|
)
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setSubText(groupInfo.groupName)
|
|
||||||
|
|
||||||
builder.extras.putString(EXTRA_ROOM_ID, roomId)
|
|
||||||
builder.extras.putString(EXTRA_GROUP_ID, groupInfo.groupId)
|
|
||||||
|
|
||||||
if (largeIcon != null) builder.setLargeIcon(largeIcon)
|
|
||||||
|
|
||||||
if (inlineImage != null) {
|
|
||||||
builder.setStyle(
|
|
||||||
NotificationCompat.BigPictureStyle()
|
|
||||||
.bigPicture(inlineImage)
|
|
||||||
.bigLargeIcon(null as Bitmap?)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
builder.setStyle(
|
|
||||||
NotificationCompat.BigTextStyle()
|
|
||||||
.bigText(text)
|
|
||||||
.setSummaryText(groupInfo.groupName)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stable per-room id replaces the previous tray entry instead of stacking.
|
|
||||||
nm.notify(roomNotifId, builder.build())
|
|
||||||
|
|
||||||
// Nest under Direct messages / Space name / Home in the notification shade.
|
|
||||||
val summaryId = notificationIdForGroupSummary(groupInfo.groupId)
|
|
||||||
val summary = NotificationCompat.Builder(this, channelId)
|
|
||||||
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
|
||||||
.setContentTitle(groupInfo.groupName)
|
|
||||||
.setContentText("New messages")
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setContentIntent(pi)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
|
||||||
.setGroup(groupInfo.groupId)
|
|
||||||
.setGroupSummary(true)
|
|
||||||
.setStyle(
|
|
||||||
NotificationCompat.InboxStyle()
|
|
||||||
.setBigContentTitle(groupInfo.groupName)
|
|
||||||
.setSummaryText(groupInfo.groupName)
|
|
||||||
)
|
|
||||||
summary.extras.putString(EXTRA_GROUP_ID, groupInfo.groupId)
|
|
||||||
nm.notify(summaryId, summary.build())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildStatusNotification(): Notification {
|
private fun buildStatusNotification(): Notification {
|
||||||
@@ -620,28 +587,7 @@ class MatrixSyncService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureMessageChannels(nm: NotificationManager) {
|
private fun ensureMessageChannels(nm: NotificationManager) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
ensureMessageChannels(this)
|
||||||
|
|
||||||
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
|
|
||||||
val soundAttrs = AudioAttributes.Builder()
|
|
||||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val channels = listOf(
|
|
||||||
Triple(CHANNEL_DIRECTS, "Direct messages", "One-to-one Matrix conversations"),
|
|
||||||
Triple(CHANNEL_SPACES, "Spaces", "Messages from rooms inside Spaces"),
|
|
||||||
Triple(CHANNEL_HOME, "Other rooms", "Rooms that are not in a Space"),
|
|
||||||
Triple(CHANNEL_MESSAGES, "Messages", "Legacy message channel"),
|
|
||||||
)
|
|
||||||
|
|
||||||
for ((id, name, description) in channels) {
|
|
||||||
val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH).apply {
|
|
||||||
this.description = description
|
|
||||||
setSound(soundUri, soundAttrs)
|
|
||||||
}
|
|
||||||
nm.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -686,6 +632,129 @@ class MatrixSyncService : Service() {
|
|||||||
fun notificationIdForGroupSummary(groupId: String): Int =
|
fun notificationIdForGroupSummary(groupId: String): Int =
|
||||||
notificationIdForRoom("summary:$groupId")
|
notificationIdForRoom("summary:$groupId")
|
||||||
|
|
||||||
|
private fun channelIdForKindStatic(kind: String): String = when (kind) {
|
||||||
|
"direct" -> CHANNEL_DIRECTS
|
||||||
|
"space" -> CHANNEL_SPACES
|
||||||
|
else -> CHANNEL_HOME
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ensureMessageChannels(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val soundUri = Uri.parse("android.resource://${context.packageName}/${R.raw.paarrot_notification}")
|
||||||
|
val soundAttrs = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val channels = listOf(
|
||||||
|
Triple(CHANNEL_DIRECTS, "Direct messages", "One-to-one Matrix conversations"),
|
||||||
|
Triple(CHANNEL_SPACES, "Spaces", "Messages from rooms inside Spaces"),
|
||||||
|
Triple(CHANNEL_HOME, "Other rooms", "Rooms that are not in a Space"),
|
||||||
|
Triple(CHANNEL_MESSAGES, "Messages", "Legacy message channel"),
|
||||||
|
)
|
||||||
|
|
||||||
|
for ((id, name, description) in channels) {
|
||||||
|
val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH).apply {
|
||||||
|
this.description = description
|
||||||
|
setSound(soundUri, soundAttrs)
|
||||||
|
}
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeBase64Bitmap(base64: String?): Bitmap? {
|
||||||
|
if (base64.isNullOrBlank()) return null
|
||||||
|
return try {
|
||||||
|
val raw = if (base64.contains(',')) base64.substringAfter(',') else base64
|
||||||
|
val bytes = Base64.decode(raw, Base64.DEFAULT)
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to decode notification icon: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a room notification (+ space/DM group summary) for both background sync
|
||||||
|
* and JS-driven Capacitor notifications (with optional avatar bitmap).
|
||||||
|
*/
|
||||||
|
fun postMessageNotification(
|
||||||
|
context: Context,
|
||||||
|
roomId: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
groupId: String,
|
||||||
|
groupName: String,
|
||||||
|
kind: String,
|
||||||
|
largeIcon: Bitmap? = null,
|
||||||
|
inlineImage: Bitmap? = 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 roomNotifId = notificationIdForRoom(roomId)
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
context, roomNotifId, launchIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val channelId = channelIdForKindStatic(kind)
|
||||||
|
val builder = NotificationCompat.Builder(context, channelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||||
|
.setGroup(groupId)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSubText(groupName)
|
||||||
|
|
||||||
|
builder.extras.putString(EXTRA_ROOM_ID, roomId)
|
||||||
|
builder.extras.putString(EXTRA_GROUP_ID, groupId)
|
||||||
|
|
||||||
|
if (largeIcon != null) builder.setLargeIcon(largeIcon)
|
||||||
|
|
||||||
|
if (inlineImage != null) {
|
||||||
|
builder.setStyle(
|
||||||
|
NotificationCompat.BigPictureStyle()
|
||||||
|
.bigPicture(inlineImage)
|
||||||
|
.bigLargeIcon(null as Bitmap?)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
builder.setStyle(
|
||||||
|
NotificationCompat.BigTextStyle()
|
||||||
|
.bigText(body)
|
||||||
|
.setSummaryText(groupName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nm.notify(roomNotifId, builder.build())
|
||||||
|
|
||||||
|
val summaryId = notificationIdForGroupSummary(groupId)
|
||||||
|
val summary = NotificationCompat.Builder(context, channelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
|
.setContentTitle(groupName)
|
||||||
|
.setContentText("New messages")
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||||
|
.setGroup(groupId)
|
||||||
|
.setGroupSummary(true)
|
||||||
|
.setStyle(
|
||||||
|
NotificationCompat.InboxStyle()
|
||||||
|
.setBigContentTitle(groupName)
|
||||||
|
.setSummaryText(groupName)
|
||||||
|
)
|
||||||
|
summary.extras.putString(EXTRA_GROUP_ID, groupId)
|
||||||
|
nm.notify(summaryId, summary.build())
|
||||||
|
}
|
||||||
|
|
||||||
/** Cancel the tray notification posted for [roomId], if any. */
|
/** Cancel the tray notification posted for [roomId], if any. */
|
||||||
fun clearRoomNotifications(context: Context, roomId: String) {
|
fun clearRoomNotifications(context: Context, roomId: String) {
|
||||||
if (roomId.isBlank()) return
|
if (roomId.isBlank()) return
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
|||||||
* - `getStatus()` — returns current fetch and UnifiedPush state
|
* - `getStatus()` — returns current fetch and UnifiedPush state
|
||||||
* - `clearRoomNotifications({ roomId })` — dismiss native tray notifs for a room
|
* - `clearRoomNotifications({ roomId })` — dismiss native tray notifs for a room
|
||||||
* - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting
|
* - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting
|
||||||
|
* - `showNotification({ title, body, roomId, groupId, groupName, kind, largeIconBase64 })`
|
||||||
*/
|
*/
|
||||||
@CapacitorPlugin(name = "MatrixBackgroundSync")
|
@CapacitorPlugin(name = "MatrixBackgroundSync")
|
||||||
class SyncServicePlugin : Plugin() {
|
class SyncServicePlugin : Plugin() {
|
||||||
@@ -124,6 +125,34 @@ class SyncServicePlugin : Plugin() {
|
|||||||
call.resolve(JSObject().put("success", true))
|
call.resolve(JSObject().put("success", true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a message notification from the JS layer (supports dynamic avatar icons).
|
||||||
|
* Capacitor LocalNotifications cannot load authenticated/dynamic large icons, so
|
||||||
|
* we post through the same native NotificationManager path as background sync.
|
||||||
|
*/
|
||||||
|
@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 largeIconBase64 = call.getString("largeIconBase64")
|
||||||
|
|
||||||
|
MatrixSyncService.postMessageNotification(
|
||||||
|
context = context,
|
||||||
|
roomId = roomId,
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
groupId = groupId,
|
||||||
|
groupName = groupName,
|
||||||
|
kind = kind,
|
||||||
|
largeIcon = MatrixSyncService.decodeBase64Bitmap(largeIconBase64),
|
||||||
|
)
|
||||||
|
call.resolve(JSObject().put("shown", true))
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val PREFS = "sync_service_prefs"
|
const val PREFS = "sync_service_prefs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useAtomValue, useStore } from 'jotai';
|
import { useAtomValue, useStore } from 'jotai';
|
||||||
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { RoomEvent, RoomEventHandlerMap, MatrixClient, MatrixEvent, MatrixEventEvent } from 'matrix-js-sdk';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||||
import LogoSVG from '../../../../public/res/svg/paarrot.svg';
|
import LogoSVG from '../../../../public/res/svg/paarrot.svg';
|
||||||
import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg';
|
import LogoUnreadSVG from '../../../../public/res/svg/paarrot-unread.svg';
|
||||||
@@ -15,6 +15,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath, getInboxInvitesPath } from '../pathUtils';
|
import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath, getInboxInvitesPath } from '../pathUtils';
|
||||||
import {
|
import {
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
|
getMemberAvatarMxc,
|
||||||
getNotificationType,
|
getNotificationType,
|
||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
@@ -22,7 +23,13 @@ import {
|
|||||||
guessPerfectParent,
|
guessPerfectParent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp, getCanonicalAliasOrRoomId, encryptFile } from '../../utils/matrix';
|
import {
|
||||||
|
getMxIdLocalPart,
|
||||||
|
mxcUrlToHttp,
|
||||||
|
getCanonicalAliasOrRoomId,
|
||||||
|
encryptFile,
|
||||||
|
downloadMedia,
|
||||||
|
} from '../../utils/matrix';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
@@ -62,6 +69,92 @@ import {
|
|||||||
materializeSharedFile,
|
materializeSharedFile,
|
||||||
} from '../../utils/androidShare';
|
} 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.
|
* Applies the selected emoji style font to the document.
|
||||||
* - System: Uses the native OS emoji font
|
* - System: Uses the native OS emoji font
|
||||||
@@ -249,6 +342,7 @@ function MessageNotifications() {
|
|||||||
({
|
({
|
||||||
roomName,
|
roomName,
|
||||||
roomAvatar,
|
roomAvatar,
|
||||||
|
iconBase64,
|
||||||
username,
|
username,
|
||||||
messageBody,
|
messageBody,
|
||||||
roomId,
|
roomId,
|
||||||
@@ -257,6 +351,7 @@ function MessageNotifications() {
|
|||||||
}: {
|
}: {
|
||||||
roomName: string;
|
roomName: string;
|
||||||
roomAvatar?: string;
|
roomAvatar?: string;
|
||||||
|
iconBase64?: string;
|
||||||
username: string;
|
username: string;
|
||||||
messageBody?: string;
|
messageBody?: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -346,6 +441,8 @@ function MessageNotifications() {
|
|||||||
path: roomPath,
|
path: roomPath,
|
||||||
roomId,
|
roomId,
|
||||||
group,
|
group,
|
||||||
|
icon: roomAvatar,
|
||||||
|
iconBase64,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (!window.closed) navigate(roomPath);
|
if (!window.closed) navigate(roomPath);
|
||||||
},
|
},
|
||||||
@@ -443,34 +540,35 @@ function MessageNotifications() {
|
|||||||
showNotifications &&
|
showNotifications &&
|
||||||
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
|
||||||
) {
|
) {
|
||||||
const avatarMxc =
|
void (async () => {
|
||||||
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
await waitForEventDecryption(mx, mEvent);
|
||||||
const content = mEvent.getContent();
|
|
||||||
|
const avatarMxc =
|
||||||
let messageBody: string | undefined;
|
getMemberAvatarMxc(room, sender) ??
|
||||||
if (mEvent.getType() === 'm.reaction') {
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ??
|
||||||
// For reactions, show "reacted with {emoji}"
|
room.getMxcAvatarUrl();
|
||||||
const reactionKey = content['m.relates_to']?.key;
|
const roomAvatar = avatarMxc
|
||||||
if (reactionKey) {
|
|
||||||
messageBody = `reacted with ${reactionKey}`;
|
|
||||||
} else {
|
|
||||||
messageBody = 'reacted to a message';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
messageBody = typeof content.body === 'string' ? content.body : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
notify({
|
|
||||||
roomName: room.name ?? 'Unknown',
|
|
||||||
roomAvatar: avatarMxc
|
|
||||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined,
|
: undefined;
|
||||||
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
|
|
||||||
messageBody,
|
let iconBase64: string | undefined;
|
||||||
roomId: room.roomId,
|
if (roomAvatar && isCapacitorNative()) {
|
||||||
eventId,
|
iconBase64 = await mediaUrlToBase64(roomAvatar, mx.getAccessToken());
|
||||||
isDm,
|
}
|
||||||
});
|
|
||||||
|
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) {
|
if (notificationSound) {
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ interface MatrixBackgroundSyncPlugin {
|
|||||||
{ groupId: string; groupName: string; roomName: string; kind: string }
|
{ groupId: string; groupName: string; roomName: string; kind: string }
|
||||||
>;
|
>;
|
||||||
}): Promise<{ success: boolean }>;
|
}): Promise<{ success: boolean }>;
|
||||||
|
/** Post a message notification with optional avatar (base64) from the JS layer. */
|
||||||
|
showNotification(options: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
roomId: string;
|
||||||
|
groupId: string;
|
||||||
|
groupName: string;
|
||||||
|
kind: string;
|
||||||
|
largeIconBase64?: string;
|
||||||
|
}): Promise<{ shown: boolean }>;
|
||||||
addListener(
|
addListener(
|
||||||
eventName: 'unifiedPushNewEndpoint',
|
eventName: 'unifiedPushNewEndpoint',
|
||||||
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
||||||
@@ -564,3 +574,27 @@ export const syncNotificationGroupMap = async (
|
|||||||
console.warn('[BackgroundSync] setNotificationGroups failed:', err);
|
console.warn('[BackgroundSync] setNotificationGroups failed:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a native tray notification (supports dynamic/authenticated avatar icons).
|
||||||
|
* Prefer this over Capacitor LocalNotifications on Android.
|
||||||
|
*/
|
||||||
|
export const showNativeNotification = async (options: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
roomId: string;
|
||||||
|
groupId: string;
|
||||||
|
groupName: string;
|
||||||
|
kind: string;
|
||||||
|
largeIconBase64?: string;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
if (!isBackgroundSyncSupported()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MatrixBackgroundSync.showNotification(options);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] showNotification failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -632,12 +632,13 @@ export const sendNotification = async (options: {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
iconBase64?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
group?: NotificationGroupInfo;
|
group?: NotificationGroupInfo;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const { title, body, icon, path, roomId, group, onClick } = options;
|
const { title, body, icon, iconBase64, path, roomId, group, onClick } = options;
|
||||||
const extra = {
|
const extra = {
|
||||||
...(path ? { path } : {}),
|
...(path ? { path } : {}),
|
||||||
...(roomId ? { roomId } : {}),
|
...(roomId ? { roomId } : {}),
|
||||||
@@ -693,6 +694,7 @@ export const sendNotification = async (options: {
|
|||||||
// Use the channel on Android
|
// Use the channel on Android
|
||||||
channelId: isAndroid() ? channelId : undefined,
|
channelId: isAndroid() ? channelId : undefined,
|
||||||
group: groupId,
|
group: groupId,
|
||||||
|
icon,
|
||||||
// Store path/roomId in extra data for tap handling + clear-on-read
|
// Store path/roomId in extra data for tap handling + clear-on-read
|
||||||
extra: hasExtra ? extra : undefined,
|
extra: hasExtra ? extra : undefined,
|
||||||
});
|
});
|
||||||
@@ -716,6 +718,26 @@ export const sendNotification = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCapacitorNative()) {
|
if (isCapacitorNative()) {
|
||||||
|
// Prefer native NotificationManager so we can set dynamic/authenticated avatars.
|
||||||
|
// Capacitor LocalNotifications only supports drawable resource largeIcon names.
|
||||||
|
if (roomId && groupId && groupName && group) {
|
||||||
|
try {
|
||||||
|
const { showNativeNotification } = await import('./backgroundSync');
|
||||||
|
const shown = await showNativeNotification({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
roomId,
|
||||||
|
groupId,
|
||||||
|
groupName,
|
||||||
|
kind: group.kind,
|
||||||
|
largeIconBase64: iconBase64,
|
||||||
|
});
|
||||||
|
if (shown) return;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Native showNotification failed, falling back to LocalNotifications:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||||
let perm = await LocalNotifications.checkPermissions();
|
let perm = await LocalNotifications.checkPermissions();
|
||||||
|
|||||||
Reference in New Issue
Block a user