Try to add unread counter and fix more bugs

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-05 11:24:00 +03:00
Unverified
parent 139de3393b
commit 0752ed980a
21 changed files with 592 additions and 96 deletions
+1
View File
@@ -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()
@@ -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) {
@@ -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()
@@ -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
}
@@ -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()
}
}
}
}
}
@@ -0,0 +1,8 @@
package ru.fromchat.api.schema.messages.dm
import kotlinx.serialization.Serializable
@Serializable
data class DmMarkReadRequest(
val upToEnvelopeId: Int? = null,
)
@@ -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,
@@ -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 = ?;