From 45bc4753409b753a39b7378512fe6d93bff911f1 Mon Sep 17 00:00:00 2001 From: litruv Date: Fri, 24 Jul 2026 14:36:06 +1000 Subject: [PATCH] Group Android notifications by space and clear them on read. Use stable per-room notification IDs with Directs/Spaces/Home channels and space group summaries so the shade nests deeper than a flat Messages list, and dismiss those tray entries when a room is marked read. --- .../java/com/paarrot/app/MatrixSyncService.kt | 220 ++++++++++-- .../java/com/paarrot/app/SyncServicePlugin.kt | 27 ++ .../app/pages/client/ClientNonUIFeatures.tsx | 105 +++++- overlay/src/app/utils/backgroundSync.ts | 48 +++ overlay/src/app/utils/tauri.ts | 330 ++++++++++++++++-- 5 files changed, 671 insertions(+), 59 deletions(-) diff --git a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt index 3f5af64..9b1ecef 100644 --- a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt +++ b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt @@ -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 { + 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() + 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 diff --git a/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt b/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt index f2aeffd..6e51a1e 100644 --- a/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt +++ b/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt @@ -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" } diff --git a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx index b79ce28..52f5d05 100644 --- a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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>(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; } diff --git a/overlay/src/app/utils/backgroundSync.ts b/overlay/src/app/utils/backgroundSync.ts index 9f31676..a3bb2cf 100644 --- a/overlay/src/app/utils/backgroundSync.ts +++ b/overlay/src/app/utils/backgroundSync.ts @@ -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; /** Returns fetch state and current UnifiedPush registration details. */ getStatus(): Promise; + /** Cancel native tray notifications posted for a Matrix room. */ + clearRoomNotifications(options: { roomId: string }): Promise; + /** + * 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 => { + 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 => { + if (!isBackgroundSyncSupported()) return; + + try { + await MatrixBackgroundSync.setNotificationGroups({ rooms }); + } catch (err) { + console.warn('[BackgroundSync] setNotificationGroups failed:', err); + } +}; diff --git a/overlay/src/app/utils/tauri.ts b/overlay/src/app/utils/tauri.ts index 3703936..3a5d95c 100644 --- a/overlay/src/app/utils/tauri.ts +++ b/overlay/src/app/utils/tauri.ts @@ -351,24 +351,7 @@ const ensureNotificationChannel = async (): Promise => { }; const ensureCapacitorNotificationChannel = async (): Promise => { - 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 { + 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 => { + 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 }, + 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; group?: string }> }>; + cancel: (opts: { notifications: Array<{ id: number }> }) => Promise; + removeDeliveredNotifications: (opts: { notifications: unknown[] }) => Promise; + }, + groupId: string +): Promise => { + 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 => { + 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 }) => + 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 }) => + 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 => { - 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 = { + 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> = [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;