Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3940441d9b | ||
| cfabcc5fbf |
@@ -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();
|
|
||||||
|
|
||||||
let messageBody: string | undefined;
|
const avatarMxc =
|
||||||
if (mEvent.getType() === 'm.reaction') {
|
getMemberAvatarMxc(room, sender) ??
|
||||||
// For reactions, show "reacted with {emoji}"
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ??
|
||||||
const reactionKey = content['m.relates_to']?.key;
|
room.getMxcAvatarUrl();
|
||||||
if (reactionKey) {
|
const roomAvatar = avatarMxc
|
||||||
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();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.134",
|
"version": "4.11.135",
|
||||||
"description": "Paarrot - A Matrix client based on Cinny",
|
"description": "Paarrot - A Matrix client based on Cinny",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user