mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Try to add unread counter and fix more bugs
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:theme="@style/Theme.FromChat.SplashScreen"
|
android:theme="@style/Theme.FromChat.SplashScreen"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import kotlinx.coroutines.launch
|
|||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.schema.messages.MessagesResponse
|
import ru.fromchat.api.schema.messages.MessagesResponse
|
||||||
import ru.fromchat.config.ServerConfig
|
import ru.fromchat.config.ServerConfig
|
||||||
|
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||||
|
import ru.fromchat.notifications.NotificationLaunchTarget
|
||||||
import ru.fromchat.ui.App
|
import ru.fromchat.ui.App
|
||||||
import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
|
import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
|
||||||
|
|
||||||
@@ -133,6 +135,24 @@ class MainActivity : ComponentActivity() {
|
|||||||
profileLookupErrorMessage = launchState.profileLookupErrorMessage
|
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? {
|
private fun parseProfileDeepLink(intent: Intent?): ProfileDeepLinkTarget? {
|
||||||
val data: Uri = intent?.data ?: return null
|
val data: Uri = intent?.data ?: return null
|
||||||
Logger.d("ProfileDeepLink", "parseProfileDeepLink intentData=${data.toString()}")
|
Logger.d("ProfileDeepLink", "parseProfileDeepLink intentData=${data.toString()}")
|
||||||
@@ -213,7 +233,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
applyLaunchState(parseLaunchStateFromIntent(intent))
|
deliverLaunchIntent(intent)
|
||||||
setContent {
|
setContent {
|
||||||
App(
|
App(
|
||||||
scrollToMessageId = scrollToMessageId,
|
scrollToMessageId = scrollToMessageId,
|
||||||
@@ -241,7 +261,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
applyLaunchState(parseLaunchStateFromIntent(intent))
|
deliverLaunchIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ object NotificationHelper {
|
|||||||
context,
|
context,
|
||||||
if (targetDmUserId != null) -messageId else messageId,
|
if (targetDmUserId != null) -messageId else messageId,
|
||||||
Intent(context, MainActivity::class.java).apply {
|
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_MESSAGE_ID, messageId)
|
||||||
putExtra(
|
putExtra(
|
||||||
EXTRA_NOTIFICATION_CHAT_TYPE,
|
EXTRA_NOTIFICATION_CHAT_TYPE,
|
||||||
|
|||||||
@@ -367,6 +367,8 @@
|
|||||||
<string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string>
|
<string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string>
|
||||||
<string name="more">Ещё</string>
|
<string name="more">Ещё</string>
|
||||||
<string name="unread_count">+%1$d</string>
|
<string name="unread_count">+%1$d</string>
|
||||||
|
<string name="unread_count_badge">%1$d</string>
|
||||||
|
<string name="unread_count_overflow">99+</string>
|
||||||
<string name="unknown">Неизвестно</string>
|
<string name="unknown">Неизвестно</string>
|
||||||
|
|
||||||
<!-- Удаления доступа -->
|
<!-- Удаления доступа -->
|
||||||
|
|||||||
@@ -398,6 +398,8 @@
|
|||||||
|
|
||||||
<string name="more">More</string>
|
<string name="more">More</string>
|
||||||
<string name="unread_count">+%1$d</string>
|
<string name="unread_count">+%1$d</string>
|
||||||
|
<string name="unread_count_badge">%1$d</string>
|
||||||
|
<string name="unread_count_overflow">99+</string>
|
||||||
|
|
||||||
<!-- Suspension -->
|
<!-- Suspension -->
|
||||||
<string name="suspend_chat_banner_message">Your account was blocked</string>
|
<string name="suspend_chat_banner_message">Your account was blocked</string>
|
||||||
|
|||||||
@@ -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.DmConversationsResponse
|
||||||
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
|
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
|
||||||
import ru.fromchat.api.schema.messages.dm.EditDmRequest
|
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.SendDmFile
|
||||||
import ru.fromchat.api.schema.messages.dm.SendDmRequest
|
import ru.fromchat.api.schema.messages.dm.SendDmRequest
|
||||||
import ru.fromchat.api.schema.messages.dm.upload.DmUploadChunkRequest
|
import ru.fromchat.api.schema.messages.dm.upload.DmUploadChunkRequest
|
||||||
@@ -626,6 +627,13 @@ object ApiClient {
|
|||||||
.body<DmConversationsResponse>()
|
.body<DmConversationsResponse>()
|
||||||
.conversations
|
.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<User> {
|
suspend fun searchUsers(query: String): List<User> {
|
||||||
val trimmed = query.trim()
|
val trimmed = query.trim()
|
||||||
if (trimmed.length < 2) return emptyList()
|
if (trimmed.length < 2) return emptyList()
|
||||||
|
|||||||
+75
-18
@@ -102,10 +102,7 @@ object MessageCacheStore {
|
|||||||
): ChatListPreviewState? = withContext(Dispatchers.Default) {
|
): ChatListPreviewState? = withContext(Dispatchers.Default) {
|
||||||
val convId = conversationIdForPublic()
|
val convId = conversationIdForPublic()
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
val recent = db.messageDatabaseQueries
|
val recent = resolvePreviewSourceMessageRow(iid, convId) ?: return@withContext null
|
||||||
.selectRecentMessagesByConversation(iid, convId, limit)
|
|
||||||
.executeAsList()
|
|
||||||
.firstOrNull() ?: return@withContext null
|
|
||||||
val message = enrichQueuedOutboundUi(listOf(recent.toAppMessage()), convId).firstOrNull()
|
val message = enrichQueuedOutboundUi(listOf(recent.toAppMessage()), convId).firstOrNull()
|
||||||
?: return@withContext null
|
?: return@withContext null
|
||||||
buildChatListPreviewState(message, strings, ApiClient.user?.id)
|
buildChatListPreviewState(message, strings, ApiClient.user?.id)
|
||||||
@@ -239,6 +236,7 @@ object MessageCacheStore {
|
|||||||
|
|
||||||
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
|
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
|
||||||
deleteMessageById(conversationIdForDm(otherUserId), messageId)
|
deleteMessageById(conversationIdForDm(otherUserId), messageId)
|
||||||
|
syncDmConversationPreviewFromCache(otherUserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) {
|
suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) {
|
||||||
@@ -308,6 +306,10 @@ object MessageCacheStore {
|
|||||||
val conversationId = conversationIdForDm(conv.user.id)
|
val conversationId = conversationIdForDm(conv.user.id)
|
||||||
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
?: conv.user.username.trim()
|
?: conv.user.username.trim()
|
||||||
|
val localUnread = db.messageDatabaseQueries
|
||||||
|
.countUnreadInboundDmMessages(iid, conversationId, conv.user.id.toLong())
|
||||||
|
.executeAsOne()
|
||||||
|
.toInt()
|
||||||
UpsertDmConversationRow(
|
UpsertDmConversationRow(
|
||||||
conversationId = conversationId,
|
conversationId = conversationId,
|
||||||
otherUserId = conv.user.id,
|
otherUserId = conv.user.id,
|
||||||
@@ -318,7 +320,7 @@ object MessageCacheStore {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
previewStrings,
|
previewStrings,
|
||||||
),
|
),
|
||||||
unreadCount = conv.unreadCount,
|
unreadCount = maxOf(conv.unreadCount, localUnread),
|
||||||
updatedAt = conv.lastMessage.timestamp,
|
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 iid = instanceId()
|
||||||
val convId = conversationIdForDm(otherUserId)
|
val convId = conversationIdForDm(otherUserId)
|
||||||
withContext(Dispatchers.Default) {
|
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(
|
db.messageDatabaseQueries.updateConversationUnreadCount(
|
||||||
unreadCount = 0L,
|
unreadCount = unreadCount,
|
||||||
instanceId = iid,
|
instanceId = iid,
|
||||||
id = convId,
|
id = convId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DmConversationListNotifier.notifyChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun selectUnreadPublicMessageIds(): List<Int> {
|
suspend fun selectUnreadPublicMessageIds(): List<Int> {
|
||||||
@@ -567,6 +587,10 @@ object MessageCacheStore {
|
|||||||
.selectActiveDmConversationsForInstance(instanceId)
|
.selectActiveDmConversationsForInstance(instanceId)
|
||||||
.asFlow()
|
.asFlow()
|
||||||
.mapToList(Dispatchers.Default)
|
.mapToList(Dispatchers.Default)
|
||||||
|
val messagesFlow = db.messageDatabaseQueries
|
||||||
|
.selectMessagesForInstance(instanceId)
|
||||||
|
.asFlow()
|
||||||
|
.mapToList(Dispatchers.Default)
|
||||||
val pendingFlow = db.messageDatabaseQueries
|
val pendingFlow = db.messageDatabaseQueries
|
||||||
.selectAllPendingMessagesForInstance(instanceId)
|
.selectAllPendingMessagesForInstance(instanceId)
|
||||||
.asFlow()
|
.asFlow()
|
||||||
@@ -575,7 +599,8 @@ object MessageCacheStore {
|
|||||||
.selectPendingOutboxForInstance(instanceId)
|
.selectPendingOutboxForInstance(instanceId)
|
||||||
.asFlow()
|
.asFlow()
|
||||||
.mapToList(Dispatchers.Default)
|
.mapToList(Dispatchers.Default)
|
||||||
return merge(conversationsFlow, pendingFlow, outboxFlow)
|
val notifierFlow = DmConversationListNotifier.events.map { Unit }
|
||||||
|
return merge(conversationsFlow, messagesFlow, pendingFlow, outboxFlow, notifierFlow)
|
||||||
.mapLatest { loadCachedDmConversations() }
|
.mapLatest { loadCachedDmConversations() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,12 +632,9 @@ object MessageCacheStore {
|
|||||||
strings: ChatListPreviewStrings,
|
strings: ChatListPreviewStrings,
|
||||||
currentUserId: Int?,
|
currentUserId: Int?,
|
||||||
): ChatListPreviewState? {
|
): ChatListPreviewState? {
|
||||||
val recent = db.messageDatabaseQueries
|
val sourceRow = resolvePreviewSourceMessageRow(instanceId, conversationId) ?: return null
|
||||||
.selectRecentMessagesByConversation(instanceId, conversationId, 1)
|
|
||||||
.executeAsList()
|
|
||||||
.firstOrNull() ?: return null
|
|
||||||
val message = enrichQueuedOutboundUi(
|
val message = enrichQueuedOutboundUi(
|
||||||
listOf(recent.toAppMessage()),
|
listOf(sourceRow.toAppMessage()),
|
||||||
conversationId,
|
conversationId,
|
||||||
).firstOrNull() ?: return null
|
).firstOrNull() ?: return null
|
||||||
return buildChatListPreviewState(message, strings, currentUserId)
|
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) {
|
private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) {
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
val convId = conversationIdForDm(otherUserId)
|
val convId = conversationIdForDm(otherUserId)
|
||||||
@@ -639,10 +680,7 @@ object MessageCacheStore {
|
|||||||
.executeAsOneOrNull()
|
.executeAsOneOrNull()
|
||||||
?: return@withContext
|
?: return@withContext
|
||||||
}
|
}
|
||||||
val recent = db.messageDatabaseQueries
|
val recent = resolvePreviewSourceMessageRow(iid, convId)
|
||||||
.selectRecentMessagesByConversation(iid, convId, 1)
|
|
||||||
.executeAsList()
|
|
||||||
.firstOrNull()
|
|
||||||
val previewStrings = listPreviewStrings
|
val previewStrings = listPreviewStrings
|
||||||
val preview = previewStrings?.let { strings ->
|
val preview = previewStrings?.let { strings ->
|
||||||
recent?.toAppMessage()?.let { message ->
|
recent?.toAppMessage()?.let { message ->
|
||||||
@@ -654,6 +692,13 @@ object MessageCacheStore {
|
|||||||
}
|
}
|
||||||
?.let { truncateDmListPreview(it) }
|
?.let { truncateDmListPreview(it) }
|
||||||
?.takeIf { it.isNotEmpty() }
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
val unreadCount = db.messageDatabaseQueries
|
||||||
|
.countUnreadInboundDmMessages(
|
||||||
|
iid,
|
||||||
|
convId,
|
||||||
|
otherUserId.toLong(),
|
||||||
|
)
|
||||||
|
.executeAsOne()
|
||||||
db.messageDatabaseQueries.upsertConversation(
|
db.messageDatabaseQueries.upsertConversation(
|
||||||
instanceId = iid,
|
instanceId = iid,
|
||||||
id = row.id,
|
id = row.id,
|
||||||
@@ -662,7 +707,7 @@ object MessageCacheStore {
|
|||||||
displayName = row.displayName,
|
displayName = row.displayName,
|
||||||
lastMessageId = recent?.id ?: row.lastMessageId,
|
lastMessageId = recent?.id ?: row.lastMessageId,
|
||||||
lastMessagePreview = preview ?: row.lastMessagePreview,
|
lastMessagePreview = preview ?: row.lastMessagePreview,
|
||||||
unreadCount = row.unreadCount,
|
unreadCount = unreadCount,
|
||||||
updatedAt = recent?.timestamp ?: row.updatedAt,
|
updatedAt = recent?.timestamp ?: row.updatedAt,
|
||||||
archived = row.archived,
|
archived = row.archived,
|
||||||
)
|
)
|
||||||
@@ -670,6 +715,18 @@ object MessageCacheStore {
|
|||||||
DmConversationListNotifier.notifyChanged()
|
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) {
|
private suspend fun clearConversationMessages(conversationId: String) {
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
|
|||||||
+12
-2
@@ -88,8 +88,18 @@ object MessageRepository {
|
|||||||
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) =
|
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) =
|
||||||
MessageCacheStore.ensureDmConversationRow(otherUserId, displayName)
|
MessageCacheStore.ensureDmConversationRow(otherUserId, displayName)
|
||||||
|
|
||||||
suspend fun markDmConversationRead(otherUserId: Int) =
|
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
||||||
MessageCacheStore.markDmConversationRead(otherUserId)
|
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() {
|
suspend fun markPublicConversationRead() {
|
||||||
val localIds = MessageCacheStore.selectUnreadPublicMessageIds()
|
val localIds = MessageCacheStore.selectUnreadPublicMessageIds()
|
||||||
|
|||||||
+19
@@ -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<Int?>(null)
|
||||||
|
val activeOtherUserId: StateFlow<Int?> = _activeOtherUserId.asStateFlow()
|
||||||
|
|
||||||
|
fun setActive(otherUserId: Int?) {
|
||||||
|
if (_activeOtherUserId.value == otherUserId) return
|
||||||
|
_activeOtherUserId.value = otherUserId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isActive(otherUserId: Int): Boolean =
|
||||||
|
otherUserId > 0 && _activeOtherUserId.value == otherUserId
|
||||||
|
}
|
||||||
+147
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.fromchat.api.schema.messages.dm
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DmMarkReadRequest(
|
||||||
|
val upToEnvelopeId: Int? = null,
|
||||||
|
)
|
||||||
+27
@@ -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<NotificationLaunchTarget>(extraBufferCapacity = 1)
|
||||||
|
val pendingLaunches: SharedFlow<NotificationLaunchTarget> = pendingLaunchesFlow.asSharedFlow()
|
||||||
|
|
||||||
|
fun publish(target: NotificationLaunchTarget) {
|
||||||
|
val launchId = ++nextLaunchId
|
||||||
|
pendingLaunchesFlow.tryEmit(target.copy(launchId = launchId))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,12 +65,14 @@ import ru.fromchat.api.local.cache.CacheContext
|
|||||||
import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration
|
import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
import ru.fromchat.api.local.db.store.UserStatusStore
|
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.local.send.OutgoingMessageCoordinator
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||||
import ru.fromchat.config.ServerConfig
|
import ru.fromchat.config.ServerConfig
|
||||||
import ru.fromchat.legal.DocumentScreen
|
import ru.fromchat.legal.DocumentScreen
|
||||||
import ru.fromchat.legal.DocumentType
|
import ru.fromchat.legal.DocumentType
|
||||||
|
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||||
import ru.fromchat.ui.auth.AuthScreen
|
import ru.fromchat.ui.auth.AuthScreen
|
||||||
import ru.fromchat.ui.calls.CallOverlay
|
import ru.fromchat.ui.calls.CallOverlay
|
||||||
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
|
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
|
||||||
@@ -167,15 +169,13 @@ private fun handlePresenceEvent(message: WebSocketMessage) {
|
|||||||
"suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(message)
|
"suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(message)
|
||||||
"statusUpdate" -> message.data?.jsonObject?.let(::handlePresenceStatus)
|
"statusUpdate" -> message.data?.jsonObject?.let(::handlePresenceStatus)
|
||||||
"dmTyping", "stopDmTyping" -> message.data?.jsonObject?.let { handlePresenceTyping(message.type, it) }
|
"dmTyping", "stopDmTyping" -> message.data?.jsonObject?.let { handlePresenceTyping(message.type, it) }
|
||||||
|
"dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message)
|
||||||
"call_signaling" -> CallStore.onWebSocketMessage(message)
|
"call_signaling" -> CallStore.onWebSocketMessage(message)
|
||||||
"updates" -> {
|
"updates" -> {
|
||||||
val data = message.data ?: return
|
val data = message.data ?: return
|
||||||
val updates = ApiClient.json.decodeFromJsonElement<WebSocketUpdatesData>(data)
|
val updates = ApiClient.json.decodeFromJsonElement<WebSocketUpdatesData>(data)
|
||||||
updates.updates.forEach { update ->
|
updates.updates.forEach { update ->
|
||||||
when (update.type) {
|
handlePresenceEvent(WebSocketMessage(type = update.type, data = update.data))
|
||||||
"suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(update)
|
|
||||||
else -> handlePresenceEvent(update)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,10 +318,8 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle startup/deep-link navigation targets (notification chat/profile)
|
// Handle startup/deep-link navigation targets (profile links)
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
startAtDmConversationUserId,
|
|
||||||
startAtPublicChat,
|
|
||||||
startAtProfileUserId,
|
startAtProfileUserId,
|
||||||
startAtProfileUsername,
|
startAtProfileUsername,
|
||||||
startDestination
|
startDestination
|
||||||
@@ -329,8 +327,7 @@ fun App(
|
|||||||
Logger.d(
|
Logger.d(
|
||||||
"ProfileDeepLink",
|
"ProfileDeepLink",
|
||||||
"startup nav check: startDestination=$startDestination, startAtProfileUserId=$startAtProfileUserId, " +
|
"startup nav check: startDestination=$startDestination, startAtProfileUserId=$startAtProfileUserId, " +
|
||||||
"startAtProfileUsername=$startAtProfileUsername, startAtDmConversationUserId=$startAtDmConversationUserId, " +
|
"startAtProfileUsername=$startAtProfileUsername"
|
||||||
"startAtPublicChat=$startAtPublicChat, scrollToMessageId=$scrollToMessageId"
|
|
||||||
)
|
)
|
||||||
if (startDestination == null || startDestination == "welcome") {
|
if (startDestination == null || startDestination == "welcome") {
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
@@ -350,27 +347,46 @@ fun App(
|
|||||||
"navigating by deep link username=$trimmedUsername"
|
"navigating by deep link username=$trimmedUsername"
|
||||||
)
|
)
|
||||||
navController.navigate("profile/$trimmedUsername?fromDeepLink=true")
|
navController.navigate("profile/$trimmedUsername?fromDeepLink=true")
|
||||||
} else if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) {
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(startDestination) {
|
||||||
|
if (startDestination == null || startDestination == "welcome") {
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationLaunchCoordinator.pendingLaunches.collect { target ->
|
||||||
|
when {
|
||||||
|
target.dmConversationUserId != null && target.dmConversationUserId > 0 -> {
|
||||||
Logger.d(
|
Logger.d(
|
||||||
"ProfileDeepLink",
|
"NotificationLaunch",
|
||||||
"navigating by notification chat route user=$startAtDmConversationUserId messageId=$scrollToMessageId"
|
"navigating to dm user=${target.dmConversationUserId} " +
|
||||||
|
"messageId=${target.scrollToMessageId} launchId=${target.launchId}"
|
||||||
)
|
)
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
DmNav.chatRoute(
|
DmNav.chatRoute(
|
||||||
otherUserId = startAtDmConversationUserId,
|
otherUserId = target.dmConversationUserId,
|
||||||
sourceMessageId = scrollToMessageId
|
sourceMessageId = target.scrollToMessageId,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
launchSingleTop = true
|
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") {
|
target.startAtPublicChat -> {
|
||||||
|
Logger.d(
|
||||||
|
"NotificationLaunch",
|
||||||
|
"navigating to public chat launchId=${target.launchId}"
|
||||||
|
)
|
||||||
|
navController.navigate(PublicChatNav.CHAT_ROUTE) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalNavController provides navController,
|
LocalNavController provides navController,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ fun DmChatRoute(
|
|||||||
|
|
||||||
DmScreen(
|
DmScreen(
|
||||||
panel = panel,
|
panel = panel,
|
||||||
|
activePeerUserId = otherUserId,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
scrollToMessageId = scrollToMessageId,
|
scrollToMessageId = scrollToMessageId,
|
||||||
onTitleClick = {
|
onTitleClick = {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ru.fromchat.api.ApiClient
|
|||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
import ru.fromchat.api.local.db.store.MessageCacheStore
|
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.db.store.MessageRepository
|
||||||
import ru.fromchat.api.local.messages.conversationIdForDm
|
import ru.fromchat.api.local.messages.conversationIdForDm
|
||||||
import ru.fromchat.api.local.db.parseDmMessageContent
|
import ru.fromchat.api.local.db.parseDmMessageContent
|
||||||
@@ -346,9 +347,11 @@ class DmPanel(
|
|||||||
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
||||||
} else {
|
} else {
|
||||||
val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
||||||
|
if (ActiveDmChatTracker.isActive(otherUserId)) {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageCacheStore.upsertDmMessage(otherUserId, incoming)
|
MessageCacheStore.upsertDmMessage(otherUserId, incoming)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
addMessage(incoming)
|
addMessage(incoming)
|
||||||
if (envelope.replyToId != null) {
|
if (envelope.replyToId != null) {
|
||||||
val replyTo = _state.messages.find { it.id == envelope.replyToId }
|
val replyTo = _state.messages.find { it.id == envelope.replyToId }
|
||||||
@@ -520,7 +523,10 @@ class DmPanel(
|
|||||||
user_id = envelope.senderId,
|
user_id = envelope.senderId,
|
||||||
content = dec.text,
|
content = dec.text,
|
||||||
timestamp = envelope.timestamp,
|
timestamp = envelope.timestamp,
|
||||||
is_read = envelope.recipientId == currentUserId,
|
is_read = when {
|
||||||
|
envelope.senderId == currentUserId -> true
|
||||||
|
else -> ActiveDmChatTracker.isActive(otherUserId)
|
||||||
|
},
|
||||||
is_edited = false,
|
is_edited = false,
|
||||||
username = username,
|
username = username,
|
||||||
profile_picture = null,
|
profile_picture = null,
|
||||||
|
|||||||
@@ -4,24 +4,31 @@ import androidx.compose.animation.AnimatedVisibilityScope
|
|||||||
import androidx.compose.animation.SharedTransitionScope
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.db.store.MessageRepository
|
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.OutgoingMessageCoordinator
|
||||||
import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import ru.fromchat.ui.chat.AvatarInfo
|
import ru.fromchat.ui.chat.AvatarInfo
|
||||||
import ru.fromchat.ui.chat.ChatScreen
|
import ru.fromchat.ui.chat.ChatScreen
|
||||||
|
import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DmScreen(
|
fun DmScreen(
|
||||||
panel: DmPanel,
|
panel: DmPanel,
|
||||||
|
activePeerUserId: Int,
|
||||||
scrollToMessageId: Int? = null,
|
scrollToMessageId: Int? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onTitleClick: (() -> Unit)? = null,
|
onTitleClick: (() -> Unit)? = null,
|
||||||
@@ -34,21 +41,26 @@ fun DmScreen(
|
|||||||
) {
|
) {
|
||||||
val currentUserId = ApiClient.user?.id
|
val currentUserId = ApiClient.user?.id
|
||||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
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
|
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||||
val peerId = otherUserId ?: return@LaunchedEffect
|
val peerId = peerUserId ?: return@LaunchedEffect
|
||||||
if (peerId <= 0) 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()) {
|
if (panel.getState().messages.isEmpty()) {
|
||||||
panel.loadMessages()
|
panel.loadMessages()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(activeInstanceId, otherUserId) {
|
LaunchedEffect(activeInstanceId, peerUserId) {
|
||||||
val peerId = otherUserId ?: return@LaunchedEffect
|
val peerId = peerUserId ?: return@LaunchedEffect
|
||||||
val instanceId = activeInstanceId.trim()
|
val instanceId = activeInstanceId.trim()
|
||||||
if (instanceId.isBlank() || peerId <= 0) return@LaunchedEffect
|
if (instanceId.isBlank() || peerId <= 0) return@LaunchedEffect
|
||||||
scheduleOutboxProcessing(instanceId)
|
scheduleOutboxProcessing(instanceId)
|
||||||
@@ -57,14 +69,41 @@ fun DmScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(panel, activeInstanceId, otherUserId) {
|
LaunchedEffect(panel, activeInstanceId, peerUserId) {
|
||||||
val peerId = otherUserId ?: return@LaunchedEffect
|
val peerId = peerUserId ?: return@LaunchedEffect
|
||||||
if (activeInstanceId.isBlank() || peerId <= 0) return@LaunchedEffect
|
if (activeInstanceId.isBlank() || peerId <= 0) return@LaunchedEffect
|
||||||
MessageRepository.observeDmMessages(peerId).collect { rows ->
|
MessageRepository.observeDmMessages(peerId).collect { rows ->
|
||||||
panel.syncMessagesFromDatabase(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(
|
ChatScreen(
|
||||||
panel = panel,
|
panel = panel,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
|
|||||||
+16
@@ -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<CachedConversation>,
|
||||||
|
conversation: CachedConversation,
|
||||||
|
): List<CachedConversation> {
|
||||||
|
val rest = current.filter { it.otherUserId != conversation.otherUserId }
|
||||||
|
return listOf(conversation) + rest
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyOrdered(conversations: List<CachedConversation>): List<CachedConversation> = conversations
|
||||||
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
package ru.fromchat.ui.main.chats
|
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.Animatable
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.SpringSpec
|
import androidx.compose.animation.core.SpringSpec
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.combinedClickable
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -36,6 +36,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.ripple
|
import androidx.compose.material3.ripple
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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_sending
|
||||||
import ru.fromchat.cd_chat_preview_uploading
|
import ru.fromchat.cd_chat_preview_uploading
|
||||||
import ru.fromchat.cd_chat_selected
|
import ru.fromchat.cd_chat_selected
|
||||||
import ru.fromchat.presence_online
|
|
||||||
import ru.fromchat.ui.chat.Avatar
|
import ru.fromchat.ui.chat.Avatar
|
||||||
import ru.fromchat.ui.chat.ExpressiveUploadIndicator
|
import ru.fromchat.ui.chat.ExpressiveUploadIndicator
|
||||||
import ru.fromchat.ui.chat.TypingIndicator
|
import ru.fromchat.ui.chat.TypingIndicator
|
||||||
import ru.fromchat.ui.components.Text
|
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
|
import ru.fromchat.user_fallback
|
||||||
|
|
||||||
internal object ChatListLayout {
|
internal object ChatListLayout {
|
||||||
@@ -484,6 +486,8 @@ internal fun ChatRowAvatar(
|
|||||||
onPressEnd: () -> Unit,
|
onPressEnd: () -> Unit,
|
||||||
onLongPress: (Offset) -> Unit,
|
onLongPress: (Offset) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
showOnlineIndicator: Boolean = false,
|
||||||
|
onlineIndicatorBorderColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
@@ -508,6 +512,55 @@ internal fun ChatRowAvatar(
|
|||||||
displayName = displayNameForInitials,
|
displayName = displayNameForInitials,
|
||||||
modifier = Modifier.fillMaxSize(),
|
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,37 +831,19 @@ internal fun DmConversationRowContent(
|
|||||||
val typingUsers = status?.typingUsernames.orEmpty()
|
val typingUsers = status?.typingUsernames.orEmpty()
|
||||||
val isTyping = typingUsers.isNotEmpty()
|
val isTyping = typingUsers.isNotEmpty()
|
||||||
val isOnline = status?.online ?: (cached?.online == true)
|
val isOnline = status?.online ?: (cached?.online == true)
|
||||||
val statusKey = when {
|
val listSurfaceColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
isTyping -> "typing:${typingUsers.joinToString("|")}"
|
|
||||||
isOnline -> "online"
|
|
||||||
else -> "offline"
|
|
||||||
}
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = peerTitle,
|
headline = peerTitle,
|
||||||
supportingSlot = {
|
supportingSlot = {
|
||||||
AnimatedContent(
|
if (isTyping) {
|
||||||
targetState = statusKey,
|
TypingIndicator(typingUsers = typingUsers)
|
||||||
transitionSpec = {
|
} else {
|
||||||
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
ChatListPreviewSupportingText(
|
||||||
(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,
|
preview = preview,
|
||||||
pendingIndicator = conversation.lastMessagePendingIndicator,
|
pendingIndicator = conversation.lastMessagePendingIndicator,
|
||||||
uploadProgress = conversation.lastMessageUploadProgress,
|
uploadProgress = conversation.lastMessageUploadProgress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
position = listItemPosition,
|
position = listItemPosition,
|
||||||
@@ -827,13 +862,16 @@ internal fun DmConversationRowContent(
|
|||||||
onPressStart = onAvatarPressStart,
|
onPressStart = onAvatarPressStart,
|
||||||
onPressEnd = onAvatarPressEnd,
|
onPressEnd = onAvatarPressEnd,
|
||||||
onLongPress = onAvatarLongPress,
|
onLongPress = onAvatarLongPress,
|
||||||
|
showOnlineIndicator = isOnline,
|
||||||
|
onlineIndicatorBorderColor = listSurfaceColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
if (conversation.unreadCount > 0 && listMode == ChatsListMode.Normal) {
|
ChatUnreadBadge(
|
||||||
Text(stringResource(Res.string.unread_count, conversation.unreadCount))
|
count = conversation.unreadCount,
|
||||||
}
|
visible = conversation.unreadCount > 0 && listMode == ChatsListMode.Normal,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
bodyModifier = Modifier
|
bodyModifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -940,6 +978,10 @@ internal fun ChatListPreviewSupportingText(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal val ChatUnreadBadgeSpring = spring<Float>(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium,
|
||||||
|
)
|
||||||
internal val ChatRowPressSpring = spring<Float>(
|
internal val ChatRowPressSpring = spring<Float>(
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
stiffness = Spring.StiffnessMediumLow,
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
|||||||
@@ -476,7 +476,7 @@ fun ChatsTab(
|
|||||||
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||||
MessageRepository.observeActiveDmConversations().collect { conversations ->
|
MessageRepository.observeActiveDmConversations().collect { conversations ->
|
||||||
conversations.forEach { ProfileCache.mergeFromCachedConversation(it) }
|
conversations.forEach { ProfileCache.mergeFromCachedConversation(it) }
|
||||||
dmConversations = conversations
|
dmConversations = ChatListReorderController.applyOrdered(conversations)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ FROM message
|
|||||||
WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 AND id < 0
|
WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 AND id < 0
|
||||||
ORDER BY timestamp ASC;
|
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:
|
selectSentMessageIdByClientMessageId:
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM message
|
FROM message
|
||||||
@@ -194,6 +201,21 @@ UPDATE message
|
|||||||
SET isRead = 1
|
SET isRead = 1
|
||||||
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND deletedFlag = 0;
|
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:
|
deleteAllMessagesForInstance:
|
||||||
DELETE FROM message WHERE instanceId = ?;
|
DELETE FROM message WHERE instanceId = ?;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user