diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml index 67ef6a1..98d156d 100644 --- a/app/android/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -22,6 +22,7 @@ diff --git a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt index e91578a..0fa3bb3 100644 --- a/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt +++ b/app/android/src/main/kotlin/ru/fromchat/MainActivity.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.launch import ru.fromchat.api.ApiClient import ru.fromchat.api.schema.messages.MessagesResponse import ru.fromchat.config.ServerConfig +import ru.fromchat.notifications.NotificationLaunchCoordinator +import ru.fromchat.notifications.NotificationLaunchTarget import ru.fromchat.ui.App import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible @@ -133,6 +135,24 @@ class MainActivity : ComponentActivity() { profileLookupErrorMessage = launchState.profileLookupErrorMessage } + private fun deliverLaunchIntent(intent: Intent?) { + val launchState = parseLaunchStateFromIntent(intent) + applyLaunchState(launchState) + + val messageId = intent?.getIntExtra(EXTRA_MESSAGE_ID, -1) ?: -1 + if (messageId == -1 || intent?.hasExtra(EXTRA_NOTIFICATION_CHAT_TYPE) != true) { + return + } + + NotificationLaunchCoordinator.publish( + NotificationLaunchTarget( + dmConversationUserId = launchState.startAtDmConversationUserId, + scrollToMessageId = launchState.scrollToMessageId, + startAtPublicChat = launchState.startAtPublicChat, + ) + ) + } + private fun parseProfileDeepLink(intent: Intent?): ProfileDeepLinkTarget? { val data: Uri = intent?.data ?: return null Logger.d("ProfileDeepLink", "parseProfileDeepLink intentData=${data.toString()}") @@ -213,7 +233,7 @@ class MainActivity : ComponentActivity() { installSplashScreen() enableEdgeToEdge() - applyLaunchState(parseLaunchStateFromIntent(intent)) + deliverLaunchIntent(intent) setContent { App( scrollToMessageId = scrollToMessageId, @@ -241,7 +261,7 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - applyLaunchState(parseLaunchStateFromIntent(intent)) + deliverLaunchIntent(intent) } override fun onPause() { diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt index f2c6daf..ae2525a 100644 --- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt +++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt @@ -81,7 +81,9 @@ object NotificationHelper { context, if (targetDmUserId != null) -messageId else messageId, Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP putExtra(EXTRA_MESSAGE_ID, messageId) putExtra( EXTRA_NOTIFICATION_CHAT_TYPE, diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index a24b289..3ae7830 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -367,6 +367,8 @@ %1$s, %2$s и ещё %3$d печатают… Ещё +%1$d + %1$d + 99+ Неизвестно diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index cc1f75d..ea3174e 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -398,6 +398,8 @@ More +%1$d + %1$d + 99+ Your account was blocked diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index d987a04..5080ed4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -71,6 +71,7 @@ import ru.fromchat.api.schema.messages.dm.DmConversation import ru.fromchat.api.schema.messages.dm.DmConversationsResponse import ru.fromchat.api.schema.messages.dm.DmHistoryResponse import ru.fromchat.api.schema.messages.dm.EditDmRequest +import ru.fromchat.api.schema.messages.dm.DmMarkReadRequest import ru.fromchat.api.schema.messages.dm.SendDmFile import ru.fromchat.api.schema.messages.dm.SendDmRequest import ru.fromchat.api.schema.messages.dm.upload.DmUploadChunkRequest @@ -626,6 +627,13 @@ object ApiClient { .body() .conversations + suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) { + http.post("${ServerConfig.apiBaseUrl}/dm/conversations/$otherUserId/read") { + contentType(ContentType.Application.Json) + setBody(DmMarkReadRequest(upToEnvelopeId = upToEnvelopeId)) + } + } + suspend fun searchUsers(query: String): List { val trimmed = query.trim() if (trimmed.length < 2) return emptyList() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt index c41e18a..a3bb8f9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt @@ -102,10 +102,7 @@ object MessageCacheStore { ): ChatListPreviewState? = withContext(Dispatchers.Default) { val convId = conversationIdForPublic() val iid = instanceId() - val recent = db.messageDatabaseQueries - .selectRecentMessagesByConversation(iid, convId, limit) - .executeAsList() - .firstOrNull() ?: return@withContext null + val recent = resolvePreviewSourceMessageRow(iid, convId) ?: return@withContext null val message = enrichQueuedOutboundUi(listOf(recent.toAppMessage()), convId).firstOrNull() ?: return@withContext null buildChatListPreviewState(message, strings, ApiClient.user?.id) @@ -239,6 +236,7 @@ object MessageCacheStore { suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) { deleteMessageById(conversationIdForDm(otherUserId), messageId) + syncDmConversationPreviewFromCache(otherUserId) } suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) { @@ -308,6 +306,10 @@ object MessageCacheStore { val conversationId = conversationIdForDm(conv.user.id) val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: conv.user.username.trim() + val localUnread = db.messageDatabaseQueries + .countUnreadInboundDmMessages(iid, conversationId, conv.user.id.toLong()) + .executeAsOne() + .toInt() UpsertDmConversationRow( conversationId = conversationId, otherUserId = conv.user.id, @@ -318,7 +320,7 @@ object MessageCacheStore { currentUserId, previewStrings, ), - unreadCount = conv.unreadCount, + unreadCount = maxOf(conv.unreadCount, localUnread), updatedAt = conv.lastMessage.timestamp, ) } @@ -420,16 +422,34 @@ object MessageCacheStore { } } - suspend fun markDmConversationRead(otherUserId: Int) { + suspend fun markDmConversationReadLocally(otherUserId: Int, upToEnvelopeId: Int? = null) { val iid = instanceId() val convId = conversationIdForDm(otherUserId) withContext(Dispatchers.Default) { + if (upToEnvelopeId != null && upToEnvelopeId > 0) { + db.messageDatabaseQueries.markInboundDmMessagesReadUpTo( + instanceId = iid, + conversationId = convId, + userId = otherUserId.toLong(), + id = upToEnvelopeId.toLong(), + ) + } else { + db.messageDatabaseQueries.markAllInboundDmMessagesRead( + instanceId = iid, + conversationId = convId, + userId = otherUserId.toLong(), + ) + } + val unreadCount = db.messageDatabaseQueries + .countUnreadInboundDmMessages(iid, convId, otherUserId.toLong()) + .executeAsOne() db.messageDatabaseQueries.updateConversationUnreadCount( - unreadCount = 0L, + unreadCount = unreadCount, instanceId = iid, id = convId, ) } + DmConversationListNotifier.notifyChanged() } suspend fun selectUnreadPublicMessageIds(): List { @@ -567,6 +587,10 @@ object MessageCacheStore { .selectActiveDmConversationsForInstance(instanceId) .asFlow() .mapToList(Dispatchers.Default) + val messagesFlow = db.messageDatabaseQueries + .selectMessagesForInstance(instanceId) + .asFlow() + .mapToList(Dispatchers.Default) val pendingFlow = db.messageDatabaseQueries .selectAllPendingMessagesForInstance(instanceId) .asFlow() @@ -575,7 +599,8 @@ object MessageCacheStore { .selectPendingOutboxForInstance(instanceId) .asFlow() .mapToList(Dispatchers.Default) - return merge(conversationsFlow, pendingFlow, outboxFlow) + val notifierFlow = DmConversationListNotifier.events.map { Unit } + return merge(conversationsFlow, messagesFlow, pendingFlow, outboxFlow, notifierFlow) .mapLatest { loadCachedDmConversations() } } @@ -607,12 +632,9 @@ object MessageCacheStore { strings: ChatListPreviewStrings, currentUserId: Int?, ): ChatListPreviewState? { - val recent = db.messageDatabaseQueries - .selectRecentMessagesByConversation(instanceId, conversationId, 1) - .executeAsList() - .firstOrNull() ?: return null + val sourceRow = resolvePreviewSourceMessageRow(instanceId, conversationId) ?: return null val message = enrichQueuedOutboundUi( - listOf(recent.toAppMessage()), + listOf(sourceRow.toAppMessage()), conversationId, ).firstOrNull() ?: return null return buildChatListPreviewState(message, strings, currentUserId) @@ -625,6 +647,25 @@ object MessageCacheStore { } } + private fun resolvePreviewSourceMessageRow( + instanceId: String, + conversationId: String, + ): DbMessage? { + val latestSent = db.messageDatabaseQueries + .selectRecentMessagesByConversation(instanceId, conversationId, 1) + .executeAsList() + .firstOrNull() + val latestPending = db.messageDatabaseQueries + .selectLatestPendingMessageByConversation(instanceId, conversationId) + .executeAsOneOrNull() + return when { + latestPending == null -> latestSent + latestSent == null -> latestPending + latestPending.timestamp >= latestSent.timestamp -> latestPending + else -> latestSent + } + } + private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) { val iid = instanceId() val convId = conversationIdForDm(otherUserId) @@ -639,10 +680,7 @@ object MessageCacheStore { .executeAsOneOrNull() ?: return@withContext } - val recent = db.messageDatabaseQueries - .selectRecentMessagesByConversation(iid, convId, 1) - .executeAsList() - .firstOrNull() + val recent = resolvePreviewSourceMessageRow(iid, convId) val previewStrings = listPreviewStrings val preview = previewStrings?.let { strings -> recent?.toAppMessage()?.let { message -> @@ -654,6 +692,13 @@ object MessageCacheStore { } ?.let { truncateDmListPreview(it) } ?.takeIf { it.isNotEmpty() } + val unreadCount = db.messageDatabaseQueries + .countUnreadInboundDmMessages( + iid, + convId, + otherUserId.toLong(), + ) + .executeAsOne() db.messageDatabaseQueries.upsertConversation( instanceId = iid, id = row.id, @@ -662,7 +707,7 @@ object MessageCacheStore { displayName = row.displayName, lastMessageId = recent?.id ?: row.lastMessageId, lastMessagePreview = preview ?: row.lastMessagePreview, - unreadCount = row.unreadCount, + unreadCount = unreadCount, updatedAt = recent?.timestamp ?: row.updatedAt, archived = row.archived, ) @@ -670,6 +715,18 @@ object MessageCacheStore { DmConversationListNotifier.notifyChanged() } + suspend fun isInboundDmMessageRead(otherUserId: Int, envelopeId: Int): Boolean { + if (envelopeId <= 0) return true + val iid = instanceId() + val convId = conversationIdForDm(otherUserId) + return withContext(Dispatchers.Default) { + db.messageDatabaseQueries + .selectMessageById(iid, convId, envelopeId.toLong()) + .executeAsOneOrNull() + ?.isRead == 1L + } + } + private suspend fun clearConversationMessages(conversationId: String) { val iid = instanceId() withContext(Dispatchers.Default) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt index ff0e723..b34469b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt @@ -88,8 +88,18 @@ object MessageRepository { suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) = MessageCacheStore.ensureDmConversationRow(otherUserId, displayName) - suspend fun markDmConversationRead(otherUserId: Int) = - MessageCacheStore.markDmConversationRead(otherUserId) + suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) { + runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) } + MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId) + } + + suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) { + if (upToEnvelopeId <= 0) return + val convId = conversationIdForDm(otherUserId) + val alreadyRead = MessageCacheStore.isInboundDmMessageRead(otherUserId, upToEnvelopeId) + if (alreadyRead) return + markDmConversationRead(otherUserId, upToEnvelopeId) + } suspend fun markPublicConversationRead() { val localIds = MessageCacheStore.selectUnreadPublicMessageIds() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ActiveDmChatTracker.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ActiveDmChatTracker.kt new file mode 100644 index 0000000..4f96e32 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/ActiveDmChatTracker.kt @@ -0,0 +1,19 @@ +package ru.fromchat.api.local.messages + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Tracks which DM peer chat is currently open (for inbox read/unread routing). */ +object ActiveDmChatTracker { + private val _activeOtherUserId = MutableStateFlow(null) + val activeOtherUserId: StateFlow = _activeOtherUserId.asStateFlow() + + fun setActive(otherUserId: Int?) { + if (_activeOtherUserId.value == otherUserId) return + _activeOtherUserId.value = otherUserId + } + + fun isActive(otherUserId: Int): Boolean = + otherUserId > 0 && _activeOtherUserId.value == otherUserId +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt new file mode 100644 index 0000000..7a71b6b --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt @@ -0,0 +1,147 @@ +package ru.fromchat.api.local.messages + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement +import ru.fromchat.api.ApiClient +import ru.fromchat.api.crypto.decryptEnvelope +import ru.fromchat.api.local.db.parseDmMessageContent +import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.messages.ActiveDmChatTracker +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.messages.dm.DmEnvelope +import ru.fromchat.api.schema.websocket.types.DmDeletedData + +object DmInboundMessageProcessor { + suspend fun processNew(element: JsonElement) { + val envelope = runCatching { + ApiClient.json.decodeFromJsonElement(DmEnvelope.serializer(), element) + }.getOrNull() ?: return + + val currentUserId = ApiClient.user?.id ?: return + if (envelope.senderId != currentUserId && envelope.recipientId != currentUserId) return + + val otherUserId = if (envelope.senderId == currentUserId) { + envelope.recipientId + } else { + envelope.senderId + } + + withContext(Dispatchers.Default) { + val outcome = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() + val plaintext = outcome ?: "" + val isCorrupted = outcome == null + val message = buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId) + + if (envelope.senderId == currentUserId) { + val clientId = envelope.clientMessageId?.trim().orEmpty() + if (clientId.isNotEmpty()) { + MessageRepository.confirmDmMessage(otherUserId, clientId, message) + } else { + MessageRepository.upsertDmMessage(otherUserId, message) + } + } else { + val isRead = ActiveDmChatTracker.isActive(otherUserId) + val inbound = message.copy(is_read = isRead) + MessageRepository.upsertDmMessage(otherUserId, inbound) + } + } + } + + suspend fun processDeleted(element: JsonElement) { + val data = runCatching { + ApiClient.json.decodeFromJsonElement(DmDeletedData.serializer(), element) + }.getOrNull() ?: return + + val currentUserId = ApiClient.user?.id ?: return + if (data.senderId != currentUserId && data.recipientId != currentUserId) return + + val otherUserId = when (currentUserId) { + data.senderId -> data.recipientId + else -> data.senderId + } ?: return + + withContext(Dispatchers.Default) { + MessageRepository.deleteDmMessageById(otherUserId, data.id) + } + } + + suspend fun processEdited(element: JsonElement) { + val envelope = runCatching { + ApiClient.json.decodeFromJsonElement(DmEnvelope.serializer(), element) + }.getOrNull() ?: return + + val currentUserId = ApiClient.user?.id ?: return + if (envelope.senderId != currentUserId && envelope.recipientId != currentUserId) return + + val otherUserId = if (envelope.senderId == currentUserId) { + envelope.recipientId + } else { + envelope.senderId + } + + withContext(Dispatchers.Default) { + val existing = runCatching { MessageRepository.loadDmMessages(otherUserId) } + .getOrDefault(emptyList()) + .find { it.id == envelope.id } + val outcome = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() + val plaintext = outcome ?: "" + val isCorrupted = outcome == null + val dec = parseDmMessageContent(plaintext) + val updated = (existing ?: buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId)) + .copy( + content = dec.text, + is_edited = true, + files = envelope.files, + dmEnvelope = envelope, + fileThumbnails = dec.fileThumbnails ?: existing?.fileThumbnails, + fileAspectRatios = dec.fileAspectRatios ?: existing?.fileAspectRatios, + fileSizes = dec.fileSizes ?: existing?.fileSizes, + fileDimensions = dec.fileDimensions ?: existing?.fileDimensions, + isContentCorrupted = isCorrupted, + ) + MessageRepository.upsertDmMessage(otherUserId, updated) + } + } + + private fun buildMessage( + envelope: DmEnvelope, + plaintext: String, + isContentCorrupted: Boolean, + currentUserId: Int, + otherUserId: Int, + ): Message { + val dec = parseDmMessageContent(plaintext) + val cached = ProfileCache.get(otherUserId) + val username = if (envelope.senderId == currentUserId) { + "You" + } else { + cached?.displayName?.takeIf { it.isNotBlank() } + ?: cached?.username?.takeIf { it.isNotBlank() } + ?: envelope.senderUsername?.takeIf { it.isNotBlank() } + ?: "User $otherUserId" + } + return Message( + id = envelope.id, + user_id = envelope.senderId, + content = dec.text, + timestamp = envelope.timestamp, + is_read = envelope.senderId == currentUserId, + is_edited = false, + username = username, + profile_picture = null, + verified = null, + reply_to = null, + client_message_id = envelope.clientMessageId, + reactions = null, + files = envelope.files, + dmEnvelope = envelope, + fileThumbnails = dec.fileThumbnails, + fileAspectRatios = dec.fileAspectRatios, + fileSizes = dec.fileSizes, + fileDimensions = dec.fileDimensions, + isContentCorrupted = isContentCorrupted, + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboxCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboxCoordinator.kt new file mode 100644 index 0000000..37a7b24 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboxCoordinator.kt @@ -0,0 +1,51 @@ +package ru.fromchat.api.local.messages + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import ru.fromchat.api.local.db.store.DmConversationListNotifier +import ru.fromchat.api.schema.websocket.WebSocketMessage +import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData +import ru.fromchat.api.ApiClient + +/** Global DM inbox: persists WebSocket events into the local cache when no chat panel handles them. */ +object DmInboxCoordinator { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val dmTypes = setOf("dmNew", "dmDeleted", "dmEdited") + + fun handleMessage(message: WebSocketMessage) { + when (message.type) { + "updates" -> { + val data = message.data ?: return + val updates = runCatching { + ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data) + }.getOrNull() ?: return + updates.updates.forEach { update -> + if (update.type in dmTypes) { + handleMessage(WebSocketMessage(type = update.type, data = update.data)) + } + } + } + "dmNew" -> message.data?.let { element -> + scope.launch { + DmInboundMessageProcessor.processNew(element) + DmConversationListNotifier.notifyChanged() + } + } + "dmDeleted" -> message.data?.let { element -> + scope.launch { + DmInboundMessageProcessor.processDeleted(element) + DmConversationListNotifier.notifyChanged() + } + } + "dmEdited" -> message.data?.let { element -> + scope.launch { + DmInboundMessageProcessor.processEdited(element) + DmConversationListNotifier.notifyChanged() + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmMarkReadRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmMarkReadRequest.kt new file mode 100644 index 0000000..c5dab30 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmMarkReadRequest.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.messages.dm + +import kotlinx.serialization.Serializable + +@Serializable +data class DmMarkReadRequest( + val upToEnvelopeId: Int? = null, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/NotificationLaunchCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/NotificationLaunchCoordinator.kt new file mode 100644 index 0000000..f95f5f1 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/NotificationLaunchCoordinator.kt @@ -0,0 +1,27 @@ +package ru.fromchat.notifications + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +data class NotificationLaunchTarget( + val dmConversationUserId: Int? = null, + val scrollToMessageId: Int? = null, + val startAtPublicChat: Boolean = false, + val launchId: Long = 0, +) + +/** + * Delivers notification tap targets to [ru.fromchat.ui.App] while the process is already running. + * Each publish is a distinct event so navigation runs even when the same chat is tapped twice. + */ +object NotificationLaunchCoordinator { + private var nextLaunchId = 0L + private val pendingLaunchesFlow = MutableSharedFlow(extraBufferCapacity = 1) + val pendingLaunches: SharedFlow = pendingLaunchesFlow.asSharedFlow() + + fun publish(target: NotificationLaunchTarget) { + val launchId = ++nextLaunchId + pendingLaunchesFlow.tryEmit(target.copy(launchId = launchId)) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 5d30165..fafdc50 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -65,12 +65,14 @@ import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.UserStatusStore +import ru.fromchat.api.local.messages.DmInboxCoordinator import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.config.ServerConfig import ru.fromchat.legal.DocumentScreen import ru.fromchat.legal.DocumentType +import ru.fromchat.notifications.NotificationLaunchCoordinator import ru.fromchat.ui.auth.AuthScreen import ru.fromchat.ui.calls.CallOverlay import ru.fromchat.ui.chat.panels.dm.DmChatRoute @@ -167,15 +169,13 @@ private fun handlePresenceEvent(message: WebSocketMessage) { "suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(message) "statusUpdate" -> message.data?.jsonObject?.let(::handlePresenceStatus) "dmTyping", "stopDmTyping" -> message.data?.jsonObject?.let { handlePresenceTyping(message.type, it) } + "dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message) "call_signaling" -> CallStore.onWebSocketMessage(message) "updates" -> { val data = message.data ?: return val updates = ApiClient.json.decodeFromJsonElement(data) updates.updates.forEach { update -> - when (update.type) { - "suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(update) - else -> handlePresenceEvent(update) - } + handlePresenceEvent(WebSocketMessage(type = update.type, data = update.data)) } } } @@ -318,10 +318,8 @@ fun App( } } - // Handle startup/deep-link navigation targets (notification chat/profile) + // Handle startup/deep-link navigation targets (profile links) LaunchedEffect( - startAtDmConversationUserId, - startAtPublicChat, startAtProfileUserId, startAtProfileUsername, startDestination @@ -329,8 +327,7 @@ fun App( Logger.d( "ProfileDeepLink", "startup nav check: startDestination=$startDestination, startAtProfileUserId=$startAtProfileUserId, " + - "startAtProfileUsername=$startAtProfileUsername, startAtDmConversationUserId=$startAtDmConversationUserId, " + - "startAtPublicChat=$startAtPublicChat, scrollToMessageId=$scrollToMessageId" + "startAtProfileUsername=$startAtProfileUsername" ) if (startDestination == null || startDestination == "welcome") { return@LaunchedEffect @@ -350,23 +347,42 @@ fun App( "navigating by deep link username=$trimmedUsername" ) navController.navigate("profile/$trimmedUsername?fromDeepLink=true") - } else if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) { - Logger.d( - "ProfileDeepLink", - "navigating by notification chat route user=$startAtDmConversationUserId messageId=$scrollToMessageId" - ) - navController.navigate( - DmNav.chatRoute( - otherUserId = startAtDmConversationUserId, - sourceMessageId = scrollToMessageId + } + } + } + + LaunchedEffect(startDestination) { + if (startDestination == null || startDestination == "welcome") { + return@LaunchedEffect + } + + NotificationLaunchCoordinator.pendingLaunches.collect { target -> + when { + target.dmConversationUserId != null && target.dmConversationUserId > 0 -> { + Logger.d( + "NotificationLaunch", + "navigating to dm user=${target.dmConversationUserId} " + + "messageId=${target.scrollToMessageId} launchId=${target.launchId}" ) - ) { - launchSingleTop = true + navController.navigate( + DmNav.chatRoute( + otherUserId = target.dmConversationUserId, + sourceMessageId = target.scrollToMessageId, + ) + ) { + launchSingleTop = true + popUpTo("chat") { saveState = true } + } } - } else if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") { - Logger.d("ProfileDeepLink", "navigating to public chat route") - navController.navigate("chats/publicChat") { - launchSingleTop = true + + target.startAtPublicChat -> { + Logger.d( + "NotificationLaunch", + "navigating to public chat launchId=${target.launchId}" + ) + navController.navigate(PublicChatNav.CHAT_ROUTE) { + launchSingleTop = true + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt index a46f3d8..b9b6251 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt @@ -49,6 +49,7 @@ fun DmChatRoute( DmScreen( panel = panel, + activePeerUserId = otherUserId, modifier = modifier.fillMaxSize(), scrollToMessageId = scrollToMessageId, onTitleClick = { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt index dfbe98a..98087ee 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt @@ -16,6 +16,7 @@ import ru.fromchat.api.ApiClient import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.messages.ActiveDmChatTracker import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.messages.conversationIdForDm import ru.fromchat.api.local.db.parseDmMessageContent @@ -346,8 +347,10 @@ class DmPanel( mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) } else { val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted) - withContext(Dispatchers.Default) { - MessageCacheStore.upsertDmMessage(otherUserId, incoming) + if (ActiveDmChatTracker.isActive(otherUserId)) { + withContext(Dispatchers.Default) { + MessageCacheStore.upsertDmMessage(otherUserId, incoming) + } } addMessage(incoming) if (envelope.replyToId != null) { @@ -520,7 +523,10 @@ class DmPanel( user_id = envelope.senderId, content = dec.text, timestamp = envelope.timestamp, - is_read = envelope.recipientId == currentUserId, + is_read = when { + envelope.senderId == currentUserId -> true + else -> ActiveDmChatTracker.isActive(otherUserId) + }, is_edited = false, username = username, profile_picture = null, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt index fa96c1a..7651005 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt @@ -4,24 +4,31 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.withContext import ru.fromchat.api.ApiClient import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.messages.ActiveDmChatTracker import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.ui.chat.AvatarInfo import ru.fromchat.ui.chat.ChatScreen +import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility @Composable fun DmScreen( panel: DmPanel, + activePeerUserId: Int, scrollToMessageId: Int? = null, modifier: Modifier = Modifier, onTitleClick: (() -> Unit)? = null, @@ -34,21 +41,26 @@ fun DmScreen( ) { val currentUserId = ApiClient.user?.id val activeInstanceId by CacheContext.activeInstanceId.collectAsState() - val otherUserId = panel.getState().profileUserId + val peerUserId = activePeerUserId.takeIf { it > 0 } ?: panel.getState().profileUserId - LaunchedEffect(panel, activeInstanceId, otherUserId) { + DisposableEffect(activePeerUserId) { + if (activePeerUserId > 0) { + ActiveDmChatTracker.setActive(activePeerUserId) + } + onDispose { ActiveDmChatTracker.setActive(null) } + } + + LaunchedEffect(panel, activeInstanceId, peerUserId) { if (activeInstanceId.isBlank()) return@LaunchedEffect - val peerId = otherUserId ?: return@LaunchedEffect + val peerId = peerUserId ?: return@LaunchedEffect if (peerId <= 0) return@LaunchedEffect - // Panel is retained in [DmPanelCache]; only cold-load when the list is still empty - // (e.g. returning from profile must not call loadMessages and flash the chat spinner). if (panel.getState().messages.isEmpty()) { panel.loadMessages() } } - LaunchedEffect(activeInstanceId, otherUserId) { - val peerId = otherUserId ?: return@LaunchedEffect + LaunchedEffect(activeInstanceId, peerUserId) { + val peerId = peerUserId ?: return@LaunchedEffect val instanceId = activeInstanceId.trim() if (instanceId.isBlank() || peerId <= 0) return@LaunchedEffect scheduleOutboxProcessing(instanceId) @@ -57,14 +69,41 @@ fun DmScreen( } } - LaunchedEffect(panel, activeInstanceId, otherUserId) { - val peerId = otherUserId ?: return@LaunchedEffect + LaunchedEffect(panel, activeInstanceId, peerUserId) { + val peerId = peerUserId ?: return@LaunchedEffect if (activeInstanceId.isBlank() || peerId <= 0) return@LaunchedEffect MessageRepository.observeDmMessages(peerId).collect { rows -> panel.syncMessagesFromDatabase(rows) } } + LaunchedEffect(panel, activeInstanceId, activePeerUserId, peerUserId) { + val peerId = activePeerUserId.takeIf { it > 0 } ?: peerUserId ?: return@LaunchedEffect + if (activeInstanceId.isBlank() || peerId <= 0) return@LaunchedEffect + combine( + AttachmentDownloadVisibility.visibleMessageIds, + snapshotFlow { panel.getState().messages }, + ) { visibleIds, messages -> + visibleIds + .filter { id -> + messages.find { it.id == id }?.user_id == peerId + } + .maxOrNull() + } + .distinctUntilChanged() + .collect { maxVisibleInboundId -> + if ( + maxVisibleInboundId != null && + maxVisibleInboundId > 0 && + ActiveDmChatTracker.isActive(peerId) + ) { + withContext(Dispatchers.Default) { + MessageRepository.markDmConversationReadUpTo(peerId, maxVisibleInboundId) + } + } + } + } + ChatScreen( panel = panel, currentUserId = currentUserId, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListReorderController.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListReorderController.kt new file mode 100644 index 0000000..e43d441 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListReorderController.kt @@ -0,0 +1,16 @@ +package ru.fromchat.ui.main.chats + +import ru.fromchat.api.local.db.store.CachedConversation + +/** Keeps the active DM list ordered with the most recently updated thread first. */ +internal object ChatListReorderController { + fun bump( + current: List, + conversation: CachedConversation, + ): List { + val rest = current.filter { it.otherUserId != conversation.otherUserId } + return listOf(conversation) + rest + } + + fun applyOrdered(conversations: List): List = conversations +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt index d179105..567773b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt @@ -1,15 +1,14 @@ package ru.fromchat.ui.main.chats -import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -27,6 +26,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons @@ -36,6 +36,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -77,12 +79,12 @@ import ru.fromchat.api.schema.user.User import ru.fromchat.cd_chat_preview_sending import ru.fromchat.cd_chat_preview_uploading import ru.fromchat.cd_chat_selected -import ru.fromchat.presence_online import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.ExpressiveUploadIndicator import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.components.Text -import ru.fromchat.unread_count +import ru.fromchat.unread_count_badge +import ru.fromchat.unread_count_overflow import ru.fromchat.user_fallback internal object ChatListLayout { @@ -484,6 +486,8 @@ internal fun ChatRowAvatar( onPressEnd: () -> Unit, onLongPress: (Offset) -> Unit, modifier: Modifier = Modifier, + showOnlineIndicator: Boolean = false, + onlineIndicatorBorderColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, ) { Box( modifier @@ -508,6 +512,55 @@ internal fun ChatRowAvatar( displayName = displayNameForInitials, modifier = Modifier.fillMaxSize(), ) + if (showOnlineIndicator) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(14.dp) + .background(onlineIndicatorBorderColor, CircleShape), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(10.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ChatUnreadBadge( + count: Int, + visible: Boolean, + modifier: Modifier = Modifier, +) { + val tonal = ButtonDefaults.filledTonalButtonColors() + val overflowLabel = stringResource(Res.string.unread_count_overflow) + val label = if (count > 99) overflowLabel else stringResource(Res.string.unread_count_badge, count) + + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = scaleIn(animationSpec = ChatUnreadBadgeSpring) + fadeIn(animationSpec = ChatUnreadBadgeSpring), + exit = scaleOut(animationSpec = ChatUnreadBadgeSpring) + fadeOut(animationSpec = ChatUnreadBadgeSpring), + ) { + Surface( + shape = CircleShape, + color = tonal.containerColor, + modifier = Modifier.size(24.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = tonal.contentColor, + maxLines = 1, + ) + } + } } } @@ -778,36 +831,18 @@ internal fun DmConversationRowContent( val typingUsers = status?.typingUsernames.orEmpty() val isTyping = typingUsers.isNotEmpty() val isOnline = status?.online ?: (cached?.online == true) - val statusKey = when { - isTyping -> "typing:${typingUsers.joinToString("|")}" - isOnline -> "online" - else -> "offline" - } + val listSurfaceColor = MaterialTheme.colorScheme.surfaceContainerLow ListItem( headline = peerTitle, supportingSlot = { - AnimatedContent( - targetState = statusKey, - transitionSpec = { - (slideInVertically { it / 2 } + fadeIn()) togetherWith - (slideOutVertically { -it / 2 } + fadeOut()) - }, - label = "dm_status_${conversation.otherUserId}", - ) { state -> - when { - state.startsWith("typing:") -> TypingIndicator(typingUsers = typingUsers) - state == "online" -> Text( - text = stringResource(Res.string.presence_online), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.primary, - ) - else -> ChatListPreviewSupportingText( - preview = preview, - pendingIndicator = conversation.lastMessagePendingIndicator, - uploadProgress = conversation.lastMessageUploadProgress, - ) - } + if (isTyping) { + TypingIndicator(typingUsers = typingUsers) + } else { + ChatListPreviewSupportingText( + preview = preview, + pendingIndicator = conversation.lastMessagePendingIndicator, + uploadProgress = conversation.lastMessageUploadProgress, + ) } }, containerColor = Color.Transparent, @@ -827,13 +862,16 @@ internal fun DmConversationRowContent( onPressStart = onAvatarPressStart, onPressEnd = onAvatarPressEnd, onLongPress = onAvatarLongPress, + showOnlineIndicator = isOnline, + onlineIndicatorBorderColor = listSurfaceColor, ) } }, trailingContent = { - if (conversation.unreadCount > 0 && listMode == ChatsListMode.Normal) { - Text(stringResource(Res.string.unread_count, conversation.unreadCount)) - } + ChatUnreadBadge( + count = conversation.unreadCount, + visible = conversation.unreadCount > 0 && listMode == ChatsListMode.Normal, + ) }, bodyModifier = Modifier .fillMaxWidth() @@ -940,6 +978,10 @@ internal fun ChatListPreviewSupportingText( } } +internal val ChatUnreadBadgeSpring = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, +) internal val ChatRowPressSpring = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt index 067c29c..597179e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt @@ -476,7 +476,7 @@ fun ChatsTab( if (activeInstanceId.isBlank()) return@LaunchedEffect MessageRepository.observeActiveDmConversations().collect { conversations -> conversations.forEach { ProfileCache.mergeFromCachedConversation(it) } - dmConversations = conversations + dmConversations = ChatListReorderController.applyOrdered(conversations) } } diff --git a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq index 9edaac7..41e3c89 100644 --- a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq +++ b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq @@ -119,6 +119,13 @@ FROM message WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 AND id < 0 ORDER BY timestamp ASC; +selectLatestPendingMessageByConversation: +SELECT * +FROM message +WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 AND id < 0 +ORDER BY timestamp DESC +LIMIT 1; + selectSentMessageIdByClientMessageId: SELECT id FROM message @@ -194,6 +201,21 @@ UPDATE message SET isRead = 1 WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND deletedFlag = 0; +countUnreadInboundDmMessages: +SELECT COUNT(*) +FROM message +WHERE instanceId = ? AND conversationId = ? AND userId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0; + +markInboundDmMessagesReadUpTo: +UPDATE message +SET isRead = 1 +WHERE instanceId = ? AND conversationId = ? AND userId = ? AND id <= ? AND isRead = 0 AND id > 0 AND deletedFlag = 0; + +markAllInboundDmMessagesRead: +UPDATE message +SET isRead = 1 +WHERE instanceId = ? AND conversationId = ? AND userId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0; + deleteAllMessagesForInstance: DELETE FROM message WHERE instanceId = ?;