Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a40319e8c8 | ||
| 45bc475340 |
@@ -172,7 +172,7 @@ class MatrixSyncService : Service() {
|
||||
val token = prefs.getString(EXTRA_TOKEN, null) ?: return
|
||||
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
ensureMessageChannel(nm)
|
||||
ensureMessageChannels(nm)
|
||||
|
||||
val notifyCtx = loadNotifyContext(homeserver, token, myUserId)
|
||||
|
||||
@@ -225,7 +225,15 @@ class MatrixSyncService : Service() {
|
||||
?.let { downloadBitmap(it, token) }
|
||||
} else null
|
||||
|
||||
showMessageNotification(nm, profile.displayName, body, profile.avatar, inlineImage)
|
||||
showMessageNotification(
|
||||
nm,
|
||||
roomId,
|
||||
profile.displayName,
|
||||
body,
|
||||
profile.avatar,
|
||||
inlineImage,
|
||||
resolveGroupInfo(roomId, notifyCtx),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,27 +453,100 @@ class MatrixSyncService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private data class NotificationGroupInfo(
|
||||
val groupId: String,
|
||||
val groupName: String,
|
||||
val roomName: String,
|
||||
val kind: String,
|
||||
)
|
||||
|
||||
private fun resolveGroupInfo(roomId: String, ctx: NotifyContext): NotificationGroupInfo {
|
||||
val mapped = loadNotificationGroupMap()[roomId]
|
||||
if (mapped != null) return mapped
|
||||
|
||||
return if (ctx.directRoomIds.contains(roomId)) {
|
||||
NotificationGroupInfo(
|
||||
groupId = GROUP_DIRECTS,
|
||||
groupName = "Direct messages",
|
||||
roomName = roomId,
|
||||
kind = "direct",
|
||||
)
|
||||
} else {
|
||||
NotificationGroupInfo(
|
||||
groupId = GROUP_HOME,
|
||||
groupName = "Home",
|
||||
roomName = roomId,
|
||||
kind = "home",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadNotificationGroupMap(): Map<String, NotificationGroupInfo> {
|
||||
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||
val raw = prefs.getString(KEY_NOTIFICATION_GROUPS, null) ?: return emptyMap()
|
||||
return try {
|
||||
val root = JSONObject(raw)
|
||||
val out = mutableMapOf<String, NotificationGroupInfo>()
|
||||
val keys = root.keys()
|
||||
while (keys.hasNext()) {
|
||||
val roomId = keys.next()
|
||||
val obj = root.optJSONObject(roomId) ?: continue
|
||||
out[roomId] = NotificationGroupInfo(
|
||||
groupId = obj.optString("groupId").ifBlank { GROUP_HOME },
|
||||
groupName = obj.optString("groupName").ifBlank { "Home" },
|
||||
roomName = obj.optString("roomName").ifBlank { roomId },
|
||||
kind = obj.optString("kind").ifBlank { "home" },
|
||||
)
|
||||
}
|
||||
out
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse notification group map: ${e.message}")
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelIdForKind(kind: String): String = when (kind) {
|
||||
"direct" -> CHANNEL_DIRECTS
|
||||
"space" -> CHANNEL_SPACES
|
||||
else -> CHANNEL_HOME
|
||||
}
|
||||
|
||||
private fun showMessageNotification(
|
||||
nm: NotificationManager,
|
||||
roomId: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
largeIcon: Bitmap? = null,
|
||||
inlineImage: Bitmap? = null,
|
||||
groupInfo: NotificationGroupInfo,
|
||||
) {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
?: Intent(this, MainActivity::class.java)
|
||||
val roomNotifId = notificationIdForRoom(roomId)
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0, launchIntent,
|
||||
this, roomNotifId, launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
|
||||
val isDm = groupInfo.kind == "direct"
|
||||
val title = if (isDm) sender else groupInfo.roomName.ifBlank { sender }
|
||||
val text = if (isDm) body else "$sender: $body"
|
||||
val channelId = channelIdForKind(groupInfo.kind)
|
||||
|
||||
val builder = NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||
.setContentTitle(sender)
|
||||
.setContentText(body)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pi)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.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)
|
||||
|
||||
@@ -475,9 +556,36 @@ class MatrixSyncService : Service() {
|
||||
.bigPicture(inlineImage)
|
||||
.bigLargeIcon(null as Bitmap?)
|
||||
)
|
||||
} else {
|
||||
builder.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(text)
|
||||
.setSummaryText(groupInfo.groupName)
|
||||
)
|
||||
}
|
||||
|
||||
nm.notify(System.currentTimeMillis().toInt(), builder.build())
|
||||
// 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 {
|
||||
@@ -511,21 +619,26 @@ class MatrixSyncService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureMessageChannel(nm: NotificationManager) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_MESSAGES, "Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "Matrix message notifications"
|
||||
setSound(
|
||||
soundUri,
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build(),
|
||||
)
|
||||
private fun ensureMessageChannels(nm: NotificationManager) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -537,16 +650,79 @@ class MatrixSyncService : Service() {
|
||||
const val EXTRA_TOKEN = "access_token"
|
||||
const val EXTRA_USER_ID = "user_id"
|
||||
const val EXTRA_DEVICE_ID = "device_id"
|
||||
const val EXTRA_ROOM_ID = "room_id"
|
||||
const val EXTRA_GROUP_ID = "group_id"
|
||||
const val EXTRA_TRIGGER_REASON = "trigger_reason"
|
||||
const val KEY_NOTIFICATION_GROUPS = "notification_groups"
|
||||
const val PREFS = "matrix_sync_prefs"
|
||||
const val KEY_SINCE = "since_token"
|
||||
private const val KEY_LAST_WAKE_MS = "last_wake_ms"
|
||||
private const val NOTIF_ID_STATUS = 1001
|
||||
private const val CHANNEL_STATUS = "sync_status"
|
||||
private const val CHANNEL_MESSAGES = "messages_paarrot"
|
||||
private const val CHANNEL_DIRECTS = "messages_directs"
|
||||
private const val CHANNEL_SPACES = "messages_spaces"
|
||||
private const val CHANNEL_HOME = "messages_home"
|
||||
private const val GROUP_DIRECTS = "paarrot_directs"
|
||||
private const val GROUP_HOME = "paarrot_home"
|
||||
private const val MIN_WAKE_INTERVAL_MS = 7_500L
|
||||
const val MODE_ONE_SHOT = "one_shot"
|
||||
|
||||
/** Same hashing scheme as JS `notificationIdForRoom` so clear-on-read hits both paths. */
|
||||
fun notificationIdForRoom(roomId: String): Int {
|
||||
var hash = 0
|
||||
for (ch in roomId) {
|
||||
hash = (hash shl 5) - hash + ch.code
|
||||
}
|
||||
// Match JS Math.abs(...); avoid Int.MIN_VALUE abs edge case.
|
||||
val positive = if (hash == Int.MIN_VALUE) 0 else kotlin.math.abs(hash)
|
||||
var id = positive % 2147483647
|
||||
if (id == 0 || id == NOTIF_ID_STATUS) {
|
||||
id = NOTIF_ID_STATUS + 1
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
fun notificationIdForGroupSummary(groupId: String): Int =
|
||||
notificationIdForRoom("summary:$groupId")
|
||||
|
||||
/** Cancel the tray notification posted for [roomId], if any. */
|
||||
fun clearRoomNotifications(context: Context, roomId: String) {
|
||||
if (roomId.isBlank()) return
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val roomNotifId = notificationIdForRoom(roomId)
|
||||
|
||||
var groupId: String? = null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
groupId = nm.activeNotifications
|
||||
.firstOrNull { it.id == roomNotifId }
|
||||
?.notification?.extras?.getString(EXTRA_GROUP_ID)
|
||||
}
|
||||
|
||||
nm.cancel(roomNotifId)
|
||||
|
||||
// Also drop any legacy stacked notifications that still carry this room id.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
for (active in nm.activeNotifications) {
|
||||
val tagged = active.notification.extras.getString(EXTRA_ROOM_ID)
|
||||
if (tagged == roomId && active.id != roomNotifId) {
|
||||
nm.cancel(active.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (!groupId.isNullOrBlank()) {
|
||||
val summaryId = notificationIdForGroupSummary(groupId)
|
||||
val siblingsRemain = nm.activeNotifications.any { active ->
|
||||
active.id != summaryId &&
|
||||
active.notification.extras.getString(EXTRA_GROUP_ID) == groupId
|
||||
}
|
||||
if (!siblingsRemain) {
|
||||
nm.cancel(summaryId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
|
||||
@Volatile
|
||||
var appInForeground = false
|
||||
|
||||
@@ -17,6 +17,8 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
||||
* - `stop()` — clear persisted credentials and unregister UnifiedPush
|
||||
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
|
||||
* - `getStatus()` — returns current fetch and UnifiedPush state
|
||||
* - `clearRoomNotifications({ roomId })` — dismiss native tray notifs for a room
|
||||
* - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting
|
||||
*/
|
||||
@CapacitorPlugin(name = "MatrixBackgroundSync")
|
||||
class SyncServicePlugin : Plugin() {
|
||||
@@ -97,6 +99,31 @@ class SyncServicePlugin : Plugin() {
|
||||
call.resolve(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels native tray notifications for a Matrix room after it has been marked as read.
|
||||
*/
|
||||
@PluginMethod
|
||||
fun clearRoomNotifications(call: PluginCall) {
|
||||
val roomId = call.getString("roomId")
|
||||
?: return call.reject("roomId required")
|
||||
MatrixSyncService.clearRoomNotifications(context, roomId)
|
||||
call.resolve(JSObject().put("cleared", true))
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists room → space/group metadata from the JS layer so background
|
||||
* notifications can nest under Direct messages / Space name / Home.
|
||||
*/
|
||||
@PluginMethod
|
||||
fun setNotificationGroups(call: PluginCall) {
|
||||
val rooms = call.getObject("rooms")
|
||||
?: return call.reject("rooms required")
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(MatrixSyncService.KEY_NOTIFICATION_GROUPS, rooms.toString())
|
||||
.apply()
|
||||
call.resolve(JSObject().put("success", true))
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFS = "sync_service_prefs"
|
||||
}
|
||||
|
||||
@@ -35,12 +35,17 @@ import {
|
||||
isCapacitorNative,
|
||||
sendNotification,
|
||||
setupNotificationTapListener,
|
||||
clearNotificationsForRoom,
|
||||
NOTIF_GROUP_DIRECTS,
|
||||
NOTIF_GROUP_HOME,
|
||||
type NotificationGroupInfo,
|
||||
} from '../../utils/tauri';
|
||||
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
||||
import {
|
||||
startBackgroundSync,
|
||||
stopBackgroundSync,
|
||||
setAppForegroundState,
|
||||
syncNotificationGroupMap,
|
||||
} from '../../utils/backgroundSync';
|
||||
import {
|
||||
TUploadItem,
|
||||
@@ -226,6 +231,20 @@ function MessageNotifications() {
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
const previousUnreadRoomsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Dismiss OS notifications when a room's unread is cleared (local read or receipt).
|
||||
useEffect(() => {
|
||||
const currentRooms = new Set(roomToUnread.keys());
|
||||
for (const roomId of previousUnreadRoomsRef.current) {
|
||||
if (!currentRooms.has(roomId)) {
|
||||
void clearNotificationsForRoom(roomId);
|
||||
}
|
||||
}
|
||||
previousUnreadRoomsRef.current = currentRooms;
|
||||
}, [roomToUnread]);
|
||||
|
||||
const notify = useCallback(
|
||||
({
|
||||
roomName,
|
||||
@@ -244,8 +263,42 @@ function MessageNotifications() {
|
||||
eventId: string;
|
||||
isDm: boolean;
|
||||
}) => {
|
||||
const notificationTitle = username;
|
||||
const notificationBody = messageBody || 'New message';
|
||||
let group: NotificationGroupInfo;
|
||||
if (isDm) {
|
||||
group = {
|
||||
groupId: NOTIF_GROUP_DIRECTS,
|
||||
groupName: 'Direct messages',
|
||||
kind: 'direct',
|
||||
roomName,
|
||||
};
|
||||
} else {
|
||||
const orphanParents = getOrphanParents(roomToParents, roomId);
|
||||
if (orphanParents.length > 0 && mx) {
|
||||
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
|
||||
const spaceRoom = mx.getRoom(parentSpace);
|
||||
group = {
|
||||
groupId: parentSpace,
|
||||
groupName: spaceRoom?.name || 'Space',
|
||||
kind: 'space',
|
||||
roomName,
|
||||
};
|
||||
} else {
|
||||
group = {
|
||||
groupId: NOTIF_GROUP_HOME,
|
||||
groupName: 'Home',
|
||||
kind: 'home',
|
||||
roomName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// DMs: sender as title. Rooms: room name as title, sender in body.
|
||||
const notificationTitle = isDm ? username : roomName || username;
|
||||
const notificationBody = isDm
|
||||
? messageBody || 'New message'
|
||||
: messageBody
|
||||
? `${username}: ${messageBody}`
|
||||
: `${username} sent a message`;
|
||||
|
||||
/** Replicates TitleBar click navigation logic */
|
||||
const navigateToRoom = () => {
|
||||
@@ -291,6 +344,8 @@ function MessageNotifications() {
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
path: roomPath,
|
||||
roomId,
|
||||
group,
|
||||
onClick: () => {
|
||||
if (!window.closed) navigate(roomPath);
|
||||
},
|
||||
@@ -449,6 +504,9 @@ function MessageNotifications() {
|
||||
*/
|
||||
function BackgroundSyncSetup() {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
|
||||
// Try to make a simple fetch to verify component is rendering
|
||||
try {
|
||||
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
|
||||
@@ -468,6 +526,49 @@ function BackgroundSyncSetup() {
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
// Keep native background notifier informed of space → room grouping.
|
||||
useEffect(() => {
|
||||
const rooms: Record<
|
||||
string,
|
||||
{ groupId: string; groupName: string; roomName: string; kind: 'direct' | 'space' | 'home' }
|
||||
> = {};
|
||||
|
||||
for (const room of mx.getRooms()) {
|
||||
if (room.isSpaceRoom()) continue;
|
||||
const roomId = room.roomId;
|
||||
const roomName = room.name || roomId;
|
||||
if (mDirects.has(roomId)) {
|
||||
rooms[roomId] = {
|
||||
groupId: NOTIF_GROUP_DIRECTS,
|
||||
groupName: 'Direct messages',
|
||||
roomName,
|
||||
kind: 'direct',
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const orphanParents = getOrphanParents(roomToParents, roomId);
|
||||
if (orphanParents.length > 0) {
|
||||
const parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
|
||||
const spaceRoom = mx.getRoom(parentSpace);
|
||||
rooms[roomId] = {
|
||||
groupId: parentSpace,
|
||||
groupName: spaceRoom?.name || 'Space',
|
||||
roomName,
|
||||
kind: 'space',
|
||||
};
|
||||
} else {
|
||||
rooms[roomId] = {
|
||||
groupId: NOTIF_GROUP_HOME,
|
||||
groupName: 'Home',
|
||||
roomName,
|
||||
kind: 'home',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void syncNotificationGroupMap(rooms);
|
||||
}, [mx, mDirects, roomToParents]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ type UnifiedPushStatus = {
|
||||
distributors: string[] | string;
|
||||
};
|
||||
|
||||
type ClearRoomNotificationsResult = {
|
||||
cleared: boolean;
|
||||
};
|
||||
|
||||
type UnifiedPushEndpointEvent = {
|
||||
endpoint: string;
|
||||
previousEndpoint: string;
|
||||
@@ -48,6 +52,18 @@ interface MatrixBackgroundSyncPlugin {
|
||||
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
||||
/** Returns fetch state and current UnifiedPush registration details. */
|
||||
getStatus(): Promise<UnifiedPushStatus>;
|
||||
/** Cancel native tray notifications posted for a Matrix room. */
|
||||
clearRoomNotifications(options: { roomId: string }): Promise<ClearRoomNotificationsResult>;
|
||||
/**
|
||||
* Persist room → space/group metadata so background notifications can nest
|
||||
* under Direct messages / Space name / Home.
|
||||
*/
|
||||
setNotificationGroups(options: {
|
||||
rooms: Record<
|
||||
string,
|
||||
{ groupId: string; groupName: string; roomName: string; kind: string }
|
||||
>;
|
||||
}): Promise<{ success: boolean }>;
|
||||
addListener(
|
||||
eventName: 'unifiedPushNewEndpoint',
|
||||
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
||||
@@ -516,3 +532,35 @@ export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | und
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears native (MatrixSyncService) tray notifications for a room after it is read.
|
||||
* No-op when background sync / native Android is unavailable.
|
||||
*/
|
||||
export const clearNativeRoomNotifications = async (roomId: string): Promise<void> => {
|
||||
if (!isBackgroundSyncSupported() || !roomId) return;
|
||||
|
||||
try {
|
||||
await MatrixBackgroundSync.clearRoomNotifications({ roomId });
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] clearRoomNotifications failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Syncs space/DM grouping metadata to the native layer for background notifications.
|
||||
*/
|
||||
export const syncNotificationGroupMap = async (
|
||||
rooms: Record<
|
||||
string,
|
||||
{ groupId: string; groupName: string; roomName: string; kind: string }
|
||||
>
|
||||
): Promise<void> => {
|
||||
if (!isBackgroundSyncSupported()) return;
|
||||
|
||||
try {
|
||||
await MatrixBackgroundSync.setNotificationGroups({ rooms });
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] setNotificationGroups failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -351,24 +351,7 @@ const ensureNotificationChannel = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
const ensureCapacitorNotificationChannel = async (): Promise<void> => {
|
||||
if (notificationChannelCreated || !isAndroid()) return;
|
||||
|
||||
try {
|
||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||
await LocalNotifications.createChannel({
|
||||
id: 'messages',
|
||||
name: 'Messages',
|
||||
description: 'Message notifications from Matrix',
|
||||
importance: 5,
|
||||
sound: 'default',
|
||||
visibility: 1,
|
||||
vibration: true,
|
||||
lights: true,
|
||||
});
|
||||
notificationChannelCreated = true;
|
||||
} catch (err) {
|
||||
console.warn('Failed to create Capacitor notification channel:', err);
|
||||
}
|
||||
await ensureCapacitorNotificationChannels();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -422,6 +405,225 @@ export const getSystemNotificationPermissionState = async (): Promise<Permission
|
||||
return 'denied';
|
||||
};
|
||||
|
||||
/** Foreground sync status notification — must not collide with room message IDs. */
|
||||
const ANDROID_STATUS_NOTIFICATION_ID = 1001;
|
||||
|
||||
/** Android notification group keys for rooms outside a space. */
|
||||
export const NOTIF_GROUP_DIRECTS = 'paarrot_directs';
|
||||
export const NOTIF_GROUP_HOME = 'paarrot_home';
|
||||
|
||||
/** Capacitor / native channel ids (under the Paarrot app). */
|
||||
export const NOTIF_CHANNEL_DIRECTS = 'messages_directs';
|
||||
export const NOTIF_CHANNEL_SPACES = 'messages_spaces';
|
||||
export const NOTIF_CHANNEL_HOME = 'messages_home';
|
||||
/** Legacy channel — kept so older installs still receive. */
|
||||
export const NOTIF_CHANNEL_MESSAGES_LEGACY = 'messages';
|
||||
|
||||
export type NotificationGroupKind = 'direct' | 'space' | 'home';
|
||||
|
||||
export type NotificationGroupInfo = {
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
kind: NotificationGroupKind;
|
||||
roomName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable Android notification id for a Matrix room.
|
||||
* Re-using the same id replaces the previous tray entry instead of stacking.
|
||||
*/
|
||||
export const notificationIdForRoom = (roomId: string): number => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < roomId.length; i += 1) {
|
||||
hash = (hash << 5) - hash + roomId.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
let id = Math.abs(hash) % 2147483647;
|
||||
if (id === 0 || id === ANDROID_STATUS_NOTIFICATION_ID) {
|
||||
id = ANDROID_STATUS_NOTIFICATION_ID + 1;
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
/** Stable id for a space/group summary notification. */
|
||||
export const notificationIdForGroupSummary = (groupId: string): number =>
|
||||
notificationIdForRoom(`summary:${groupId}`);
|
||||
|
||||
const channelIdForKind = (kind: NotificationGroupKind): string => {
|
||||
switch (kind) {
|
||||
case 'direct':
|
||||
return NOTIF_CHANNEL_DIRECTS;
|
||||
case 'space':
|
||||
return NOTIF_CHANNEL_SPACES;
|
||||
default:
|
||||
return NOTIF_CHANNEL_HOME;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCapacitorNotificationChannels = async (): Promise<void> => {
|
||||
if (!isAndroid()) return;
|
||||
|
||||
try {
|
||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||
const channels: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
id: NOTIF_CHANNEL_DIRECTS,
|
||||
name: 'Direct messages',
|
||||
description: 'One-to-one Matrix conversations',
|
||||
},
|
||||
{
|
||||
id: NOTIF_CHANNEL_SPACES,
|
||||
name: 'Spaces',
|
||||
description: 'Messages from rooms inside Spaces',
|
||||
},
|
||||
{
|
||||
id: NOTIF_CHANNEL_HOME,
|
||||
name: 'Other rooms',
|
||||
description: 'Rooms that are not in a Space',
|
||||
},
|
||||
{
|
||||
id: NOTIF_CHANNEL_MESSAGES_LEGACY,
|
||||
name: 'Messages',
|
||||
description: 'Legacy message channel',
|
||||
},
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
channels.map((channel) =>
|
||||
LocalNotifications.createChannel({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
description: channel.description,
|
||||
importance: 5,
|
||||
sound: 'default',
|
||||
visibility: 1,
|
||||
vibration: true,
|
||||
lights: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
notificationChannelCreated = true;
|
||||
} catch (err) {
|
||||
console.warn('Failed to create Capacitor notification channels:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const notificationBelongsToRoom = (
|
||||
notification: { id?: number; extra?: Record<string, unknown> },
|
||||
roomId: string
|
||||
): boolean => {
|
||||
if (notification.id === notificationIdForRoom(roomId)) return true;
|
||||
const extra = notification.extra;
|
||||
if (!extra || typeof extra !== 'object') return false;
|
||||
if (extra.roomId === roomId) return true;
|
||||
const path = extra.path;
|
||||
if (typeof path === 'string') {
|
||||
return path.includes(encodeURIComponent(roomId)) || path.includes(roomId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const clearCapacitorGroupSummaryIfEmpty = async (
|
||||
LocalNotifications: {
|
||||
getDeliveredNotifications: () => Promise<{ notifications: Array<{ id?: number; extra?: Record<string, unknown>; group?: string }> }>;
|
||||
cancel: (opts: { notifications: Array<{ id: number }> }) => Promise<void>;
|
||||
removeDeliveredNotifications: (opts: { notifications: unknown[] }) => Promise<void>;
|
||||
},
|
||||
groupId: string
|
||||
): Promise<void> => {
|
||||
const summaryId = notificationIdForGroupSummary(groupId);
|
||||
const delivered = await LocalNotifications.getDeliveredNotifications().catch(() => null);
|
||||
if (!delivered?.notifications) return;
|
||||
|
||||
const siblings = delivered.notifications.filter((n) => {
|
||||
if (n.id === summaryId) return false;
|
||||
if (n.group === groupId) return true;
|
||||
return n.extra?.groupId === groupId;
|
||||
});
|
||||
|
||||
if (siblings.length > 0) return;
|
||||
|
||||
await LocalNotifications.cancel({ notifications: [{ id: summaryId }] }).catch(() => undefined);
|
||||
const summary = delivered.notifications.find((n) => n.id === summaryId);
|
||||
if (summary) {
|
||||
await LocalNotifications.removeDeliveredNotifications({
|
||||
notifications: [summary],
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Dismiss system notifications tied to a room (after mark-as-read / receipt).
|
||||
* Clears Capacitor LocalNotifications and native MatrixSyncService tray entries.
|
||||
* Also drops the space/group summary when no child notifications remain.
|
||||
*/
|
||||
export const clearNotificationsForRoom = async (roomId: string): Promise<void> => {
|
||||
if (!roomId) return;
|
||||
|
||||
if (isCapacitorNative()) {
|
||||
try {
|
||||
const { LocalNotifications } = await import('@capacitor/local-notifications');
|
||||
const id = notificationIdForRoom(roomId);
|
||||
|
||||
const deliveredBefore = await LocalNotifications.getDeliveredNotifications().catch(() => null);
|
||||
const roomNotif = deliveredBefore?.notifications?.find((n: { id?: number; extra?: Record<string, unknown> }) =>
|
||||
notificationBelongsToRoom(n, roomId)
|
||||
);
|
||||
const groupId =
|
||||
(typeof roomNotif?.extra?.groupId === 'string' && roomNotif.extra.groupId) ||
|
||||
(typeof (roomNotif as { group?: string } | undefined)?.group === 'string'
|
||||
? (roomNotif as { group?: string }).group
|
||||
: undefined);
|
||||
|
||||
await LocalNotifications.cancel({ notifications: [{ id }] }).catch(() => undefined);
|
||||
|
||||
if (deliveredBefore?.notifications?.length) {
|
||||
const matching = deliveredBefore.notifications.filter((n: { id?: number; extra?: Record<string, unknown> }) =>
|
||||
notificationBelongsToRoom(n, roomId)
|
||||
);
|
||||
if (matching.length > 0) {
|
||||
await LocalNotifications.removeDeliveredNotifications({
|
||||
notifications: matching,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupId) {
|
||||
await clearCapacitorGroupSummaryIfEmpty(LocalNotifications, groupId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to clear Capacitor notifications for room:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
const { clearNativeRoomNotifications } = await import('./backgroundSync');
|
||||
await clearNativeRoomNotifications(roomId);
|
||||
} catch (err) {
|
||||
console.warn('Failed to clear native room notifications:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTauri() && !isElectron()) {
|
||||
try {
|
||||
const { cancel, removeActive } = await import('@tauri-apps/plugin-notification');
|
||||
const id = notificationIdForRoom(roomId);
|
||||
if (typeof cancel === 'function') {
|
||||
await cancel([id]).catch(() => undefined);
|
||||
}
|
||||
if (typeof removeActive === 'function') {
|
||||
await removeActive([{ id }]).catch(() => undefined);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to clear Tauri notifications for room:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a notification using Tauri's notification plugin
|
||||
* Falls back to browser Notification API if not in Tauri
|
||||
@@ -431,9 +633,20 @@ export const sendNotification = async (options: {
|
||||
body: string;
|
||||
icon?: string;
|
||||
path?: string;
|
||||
roomId?: string;
|
||||
group?: NotificationGroupInfo;
|
||||
onClick?: () => void;
|
||||
}): Promise<void> => {
|
||||
const { title, body, icon, path, onClick } = options;
|
||||
const { title, body, icon, path, roomId, group, onClick } = options;
|
||||
const extra = {
|
||||
...(path ? { path } : {}),
|
||||
...(roomId ? { roomId } : {}),
|
||||
...(group?.groupId ? { groupId: group.groupId, groupName: group.groupName, groupKind: group.kind } : {}),
|
||||
};
|
||||
const hasExtra = Object.keys(extra).length > 0;
|
||||
const channelId = group ? channelIdForKind(group.kind) : NOTIF_CHANNEL_HOME;
|
||||
const groupId = group?.groupId;
|
||||
const groupName = group?.groupName;
|
||||
|
||||
// Use Electron's native notification API if in Electron
|
||||
if (isElectron()) {
|
||||
@@ -475,11 +688,26 @@ export const sendNotification = async (options: {
|
||||
await tauriSendNotification({
|
||||
title,
|
||||
body,
|
||||
// Stable per-room id replaces prior tray entry instead of stacking
|
||||
id: roomId ? notificationIdForRoom(roomId) : undefined,
|
||||
// Use the channel on Android
|
||||
channelId: isAndroid() ? 'messages' : undefined,
|
||||
// Store path in extra data for notification tap handling
|
||||
extra: path ? { path } : undefined,
|
||||
channelId: isAndroid() ? channelId : undefined,
|
||||
group: groupId,
|
||||
// Store path/roomId in extra data for tap handling + clear-on-read
|
||||
extra: hasExtra ? extra : undefined,
|
||||
});
|
||||
|
||||
if (isAndroid() && groupId && groupName) {
|
||||
await tauriSendNotification({
|
||||
title: groupName,
|
||||
body: 'New messages',
|
||||
id: notificationIdForGroupSummary(groupId),
|
||||
channelId,
|
||||
group: groupId,
|
||||
groupSummary: true,
|
||||
extra: { groupId, groupName, isGroupSummary: true },
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
@@ -496,21 +724,53 @@ export const sendNotification = async (options: {
|
||||
}
|
||||
|
||||
if (perm.display === 'granted') {
|
||||
await ensureCapacitorNotificationChannel();
|
||||
await ensureCapacitorNotificationChannels();
|
||||
|
||||
// One notification per room: same id updates the existing tray entry.
|
||||
const id = roomId
|
||||
? notificationIdForRoom(roomId)
|
||||
: Math.floor(Date.now() % 2147483647);
|
||||
|
||||
const roomNotification: Record<string, unknown> = {
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
channelId: isAndroid() ? channelId : undefined,
|
||||
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
|
||||
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
|
||||
extra: hasExtra ? extra : undefined,
|
||||
threadIdentifier: groupId,
|
||||
};
|
||||
|
||||
if (isAndroid() && groupId) {
|
||||
roomNotification.group = groupId;
|
||||
if (groupName) {
|
||||
roomNotification.summaryText = groupName;
|
||||
}
|
||||
if (group?.roomName && group.roomName !== title) {
|
||||
roomNotification.largeBody = body;
|
||||
}
|
||||
}
|
||||
|
||||
const notifications: Array<Record<string, unknown>> = [roomNotification];
|
||||
|
||||
// Nest rooms under a space / Directs / Home summary in the shade.
|
||||
if (isAndroid() && groupId && groupName) {
|
||||
notifications.push({
|
||||
id: notificationIdForGroupSummary(groupId),
|
||||
title: groupName,
|
||||
body: 'New messages',
|
||||
channelId,
|
||||
smallIcon: ANDROID_NOTIFICATION_SMALL_ICON,
|
||||
iconColor: ANDROID_NOTIFICATION_ICON_COLOR,
|
||||
group: groupId,
|
||||
groupSummary: true,
|
||||
extra: { groupId, groupName, isGroupSummary: true },
|
||||
});
|
||||
}
|
||||
|
||||
const id = Math.floor(Date.now() % 2147483647);
|
||||
await LocalNotifications.schedule({
|
||||
notifications: [
|
||||
{
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
channelId: isAndroid() ? 'messages' : undefined,
|
||||
smallIcon: isAndroid() ? ANDROID_NOTIFICATION_SMALL_ICON : undefined,
|
||||
iconColor: isAndroid() ? ANDROID_NOTIFICATION_ICON_COLOR : undefined,
|
||||
extra: path ? { path } : undefined,
|
||||
},
|
||||
],
|
||||
notifications: notifications as any,
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.133",
|
||||
"version": "4.11.134",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
Reference in New Issue
Block a user