diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index fdeefd7..07dd92f 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -295,3 +295,9 @@ - Wired realtime receive-message handling to trigger local notification via `NotificationDispatcher` when chat is not active. - Added chat title lookup helper in `ChatDao` for notification titles. - Added explicit realtime stop in `ChatViewModel.onCleared()` to avoid stale collectors. + +### Step 49 - Mention override for muted chats +- Extended realtime receive-message model/parsing with `isMention` flag support. +- Added muted-chat guard in realtime notification flow: muted chats stay silent unless message is a mention. +- Routed mention notifications to mentions channel/priority via `NotificationDispatcher`. +- Added parser unit test for mention-flag mapping. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt index 863a213..eb4f652 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt @@ -84,6 +84,9 @@ interface ChatDao { @Query("SELECT display_title FROM chats WHERE id = :chatId LIMIT 1") suspend fun getChatDisplayTitle(chatId: Long): String? + @Query("SELECT muted FROM chats WHERE id = :chatId LIMIT 1") + suspend fun isChatMuted(chatId: Long): Boolean? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertChats(chats: List) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParser.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParser.kt index ddcb8e7..0c87ea6 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParser.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParser.kt @@ -37,6 +37,11 @@ class RealtimeEventParser @Inject constructor( text = messageObject?.get("text").stringOrNull(), type = messageObject?.get("type").stringOrNull(), createdAt = messageObject?.get("created_at").stringOrNull(), + isMention = messageObject?.get("is_mention").boolOrNull() + ?: payload["is_mention"].boolOrNull() + ?: messageObject?.get("mentions_me").boolOrNull() + ?: payload["mentions_me"].boolOrNull() + ?: false, ) } @@ -119,4 +124,13 @@ class RealtimeEventParser @Inject constructor( private fun JsonElement?.longOrNull(): Long? { return this?.jsonPrimitive?.contentOrNull?.toLongOrNull() } + + private fun JsonElement?.boolOrNull(): Boolean? { + val raw = this?.jsonPrimitive?.contentOrNull?.trim()?.lowercase() ?: return null + return when (raw) { + "true", "1" -> true + "false", "0" -> false + else -> null + } + } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeEvent.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeEvent.kt index ba12d95..bfe932d 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeEvent.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeEvent.kt @@ -11,6 +11,7 @@ sealed interface RealtimeEvent { val text: String?, val type: String?, val createdAt: String?, + val isMention: Boolean, ) : RealtimeEvent data class MessageUpdated( diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt index c0ad26d..9878e0c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt @@ -75,7 +75,8 @@ class HandleRealtimeEventsUseCase @Inject constructor( ) chatDao.incrementUnread(chatId = event.chatId) val activeChatId = activeChatTracker.activeChatId.value - if (activeChatId != event.chatId) { + val muted = chatDao.isChatMuted(event.chatId) == true + if (activeChatId != event.chatId && (!muted || event.isMention)) { val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message" val body = event.text?.takeIf { it.isNotBlank() } ?: when (event.type?.lowercase()) { @@ -92,6 +93,7 @@ class HandleRealtimeEventsUseCase @Inject constructor( messageId = event.messageId, title = title, body = body, + isMention = event.isMention, ) ) } diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt index 74b427f..a62ba8d 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepositoryTest.kt @@ -141,6 +141,10 @@ class NetworkChatRepositoryTest { return chats.value.firstOrNull { it.id == chatId }?.displayTitle } + override suspend fun isChatMuted(chatId: Long): Boolean? { + return chats.value.firstOrNull { it.id == chatId }?.muted + } + override suspend fun upsertChats(chats: List) { val merged = this.chats.value.associateBy { it.id }.toMutableMap() chats.forEach { merged[it.id] = it } diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParserTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParserTest.kt index 6404212..aff5a64 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParserTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/realtime/RealtimeEventParserTest.kt @@ -2,6 +2,7 @@ package ru.daemonlord.messenger.data.realtime import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent @@ -70,6 +71,33 @@ class RealtimeEventParserTest { assertEquals(5L, mapped.senderId) assertEquals(100L, mapped.replyToMessageId) assertEquals("hi", mapped.text) + assertFalse(mapped.isMention) + } + + @Test + fun parseReceiveMessage_withMentionFlag_mapsMention() { + val payload = """ + { + "event": "receive_message", + "payload": { + "chat_id": 88, + "message": { + "id": 9002, + "chat_id": 88, + "sender_id": 5, + "type": "text", + "text": "@you hi", + "created_at": "2026-03-08T12:00:00Z", + "is_mention": true + } + } + } + """.trimIndent() + + val event = parser.parse(payload) + assertTrue(event is RealtimeEvent.ReceiveMessage) + val mapped = event as RealtimeEvent.ReceiveMessage + assertTrue(mapped.isMention) } @Test diff --git a/docs/android-checklist.md b/docs/android-checklist.md index 9829e8a..e6513c9 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -92,7 +92,7 @@ - [x] Локальные уведомления для foreground - [x] Notification channels (Android) - [x] Deep links: open chat/message -- [ ] Mention override для muted чатов +- [x] Mention override для muted чатов ## 13. UI/UX и темы - [ ] Светлая/темная тема (читаемая)