diff --git a/.gitmodules b/.gitmodules index b1fe27c..b71896d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "cinny"] path = cinny - url = http://synbox.ruv.wtf:8418/litruv/cinny.git + url = https://git.ruv.wtf/litruv/cinny.git 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 378917d..3f5af64 100644 --- a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt +++ b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt @@ -151,6 +151,21 @@ class MatrixSyncService : Service() { } } + private enum class RoomNotifyMode { + MUTE, + ALL_MESSAGES, + MENTIONS_AND_KEYWORDS, + } + + private data class NotifyContext( + val myUserId: String, + val myDisplayName: String?, + val myLocalpart: String?, + val keywords: List, + val directRoomIds: Set, + val pushRules: JSONObject?, + ) + private fun processRoomEvents(sync: JSONObject, myUserId: String) { val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE) val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return @@ -159,10 +174,20 @@ class MatrixSyncService : Service() { val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager ensureMessageChannel(nm) + val notifyCtx = loadNotifyContext(homeserver, token, myUserId) + val roomIds = joinedRooms.keys().asSequence().toList() for (roomId in roomIds) { - val timeline = joinedRooms.optJSONObject(roomId) - ?.optJSONObject("timeline") ?: continue + val roomData = joinedRooms.optJSONObject(roomId) ?: continue + val mode = resolveRoomNotifyMode(roomId, notifyCtx) + + // Homeserver unread counts already apply push rules — skip rooms with nothing to notify. + val unread = roomData.optJSONObject("unread_notifications") + val notificationCount = unread?.optInt("notification_count", 0) ?: 0 + if (mode == RoomNotifyMode.MUTE || notificationCount <= 0) continue + + val mentionsOnly = mode == RoomNotifyMode.MENTIONS_AND_KEYWORDS + val timeline = roomData.optJSONObject("timeline") ?: continue val events = timeline.optJSONArray("events") ?: continue for (i in 0 until events.length()) { @@ -174,6 +199,8 @@ class MatrixSyncService : Service() { if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue val content = event.optJSONObject("content") ?: continue + if (mentionsOnly && !isSpecialMessage(content, notifyCtx)) continue + val msgtype = content.optString("msgtype") val rawBody = content.optString("body") val body = when (msgtype) { @@ -203,6 +230,144 @@ class MatrixSyncService : Service() { } } + /** Load push rules, DM list, keywords, and own display name for notification filtering. */ + private fun loadNotifyContext(homeserver: String, token: String, myUserId: String): NotifyContext { + val pushRules = fetchJson("$homeserver/_matrix/client/v3/pushrules/", token) + val accountDataDirect = fetchJson("$homeserver/_matrix/client/v3/user/${urlEncode(myUserId)}/account_data/m.direct", token) + val profile = fetchJson("$homeserver/_matrix/client/v3/profile/${urlEncode(myUserId)}", token) + + val directRoomIds = mutableSetOf() + accountDataDirect?.keys()?.forEach { key -> + val rooms = accountDataDirect.optJSONArray(key) ?: return@forEach + for (i in 0 until rooms.length()) { + rooms.optString(i).takeIf { it.isNotBlank() }?.let { directRoomIds.add(it) } + } + } + + val keywords = mutableListOf() + val contentRules = pushRules?.optJSONObject("global")?.optJSONArray("content") + if (contentRules != null) { + for (i in 0 until contentRules.length()) { + val rule = contentRules.optJSONObject(i) ?: continue + if (rule.optBoolean("enabled", true).not()) continue + if (!actionsIncludeNotify(rule.optJSONArray("actions"))) continue + rule.optString("pattern").takeIf { it.isNotBlank() }?.let { keywords.add(it) } + } + } + + return NotifyContext( + myUserId = myUserId, + myDisplayName = profile?.optString("displayname")?.takeIf { it.isNotBlank() }, + myLocalpart = myUserId.substringAfter("@").substringBefore(":").takeIf { it.isNotBlank() }, + keywords = keywords, + directRoomIds = directRoomIds, + pushRules = pushRules, + ) + } + + /** + * Mirrors Cinny/Paarrot room notification modes: + * Mute override → mute; room notify → all; room dont_notify → mentions; + * unset DM → all; unset room → mentions (app Default). + */ + private fun resolveRoomNotifyMode(roomId: String, ctx: NotifyContext): RoomNotifyMode { + val global = ctx.pushRules?.optJSONObject("global") + // If push rules are unavailable, rely on homeserver unread_notifications only. + ?: return RoomNotifyMode.ALL_MESSAGES + + val overrides = global.optJSONArray("override") + if (overrides != null) { + for (i in 0 until overrides.length()) { + val rule = overrides.optJSONObject(i) ?: continue + if (rule.optString("rule_id") != roomId) continue + if (rule.optBoolean("enabled", true).not()) continue + if (!actionsIncludeNotify(rule.optJSONArray("actions"))) { + return RoomNotifyMode.MUTE + } + } + } + + val roomRules = global.optJSONArray("room") + if (roomRules != null) { + for (i in 0 until roomRules.length()) { + val rule = roomRules.optJSONObject(i) ?: continue + if (rule.optString("rule_id") != roomId) continue + if (rule.optBoolean("enabled", true).not()) continue + return if (actionsIncludeNotify(rule.optJSONArray("actions"))) { + RoomNotifyMode.ALL_MESSAGES + } else { + RoomNotifyMode.MENTIONS_AND_KEYWORDS + } + } + } + + return if (ctx.directRoomIds.contains(roomId)) { + RoomNotifyMode.ALL_MESSAGES + } else { + RoomNotifyMode.MENTIONS_AND_KEYWORDS + } + } + + private fun actionsIncludeNotify(actions: org.json.JSONArray?): Boolean { + if (actions == null) return false + for (i in 0 until actions.length()) { + when (val action = actions.opt(i)) { + is String -> if (action == "notify") return true + is JSONObject -> if (action.has("set_tweak")) { /* tweaks only */ } + } + } + return false + } + + private fun isSpecialMessage(content: JSONObject, ctx: NotifyContext): Boolean { + val mentions = content.optJSONObject("m.mentions") + if (mentions != null) { + val userIds = mentions.optJSONArray("user_ids") + if (userIds != null) { + for (i in 0 until userIds.length()) { + if (userIds.optString(i) == ctx.myUserId) return true + } + } + if (mentions.optBoolean("room", false)) return true + } + + val body = content.optString("body").lowercase() + if (body.isBlank()) return false + if (body.contains("@room")) return true + ctx.myDisplayName?.lowercase()?.takeIf { it.isNotBlank() }?.let { + if (body.contains(it)) return true + } + ctx.myLocalpart?.lowercase()?.let { + if (body.contains(it)) return true + } + for (keyword in ctx.keywords) { + if (body.contains(keyword.lowercase())) return true + } + return false + } + + private fun urlEncode(value: String): String = URLEncoder.encode(value, "UTF-8") + + private fun fetchJson(urlString: String, token: String): JSONObject? { + return try { + val conn = URL(urlString).openConnection() as HttpURLConnection + try { + conn.requestMethod = "GET" + conn.setRequestProperty("Authorization", "Bearer $token") + conn.setRequestProperty("Accept", "application/json") + conn.connectTimeout = 4_000 + conn.readTimeout = 6_000 + if (conn.responseCode != 200) return null + JSONObject(conn.inputStream.bufferedReader().readText()) + } finally { + conn.disconnect() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to fetch $urlString: ${e.message}") + null + } + } + private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile { val cachedName = displayNameCache[mxid] if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid]) diff --git a/cinny b/cinny index 61d4190..b52926f 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 61d41900cc4b2efc34bf38adf5e3b0fcf79d7926 +Subproject commit b52926f7d874a408fc444056f3c48903738c69f4 diff --git a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx index b9745e9..b79ce28 100644 --- a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx @@ -376,6 +376,14 @@ function MessageNotifications() { return; } + // Match TitleBar / room bell settings: Default and Mentions rooms only notify on highlights. + const notificationType = getNotificationType(mx, room.roomId); + const isDm = mDirects.has(room.roomId); + const mentionsOnly = + notificationType === NotificationType.MentionsAndKeywords || + (notificationType === NotificationType.Default && !isDm); + if (mentionsOnly && unreadInfo.highlight === 0) return; + if ( showNotifications && ((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted')) @@ -397,7 +405,6 @@ function MessageNotifications() { messageBody = typeof content.body === 'string' ? content.body : undefined; } - const isDm = room.getJoinedMemberCount() === 2 && !room.isSpaceRoom(); notify({ roomName: room.name ?? 'Unknown', roomAvatar: avatarMxc @@ -428,6 +435,7 @@ function MessageNotifications() { notify, selectedRoomId, useAuthentication, + mDirects, ]); return null;