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
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:theme="@style/Theme.FromChat.SplashScreen"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
@@ -28,6 +28,8 @@ import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.schema.messages.MessagesResponse
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||
import ru.fromchat.notifications.NotificationLaunchTarget
|
||||
import ru.fromchat.ui.App
|
||||
import ru.fromchat.ui.chat.panels.publicchat.isPublicChatVisible
|
||||
|
||||
@@ -133,6 +135,24 @@ class MainActivity : ComponentActivity() {
|
||||
profileLookupErrorMessage = launchState.profileLookupErrorMessage
|
||||
}
|
||||
|
||||
private fun deliverLaunchIntent(intent: Intent?) {
|
||||
val launchState = parseLaunchStateFromIntent(intent)
|
||||
applyLaunchState(launchState)
|
||||
|
||||
val messageId = intent?.getIntExtra(EXTRA_MESSAGE_ID, -1) ?: -1
|
||||
if (messageId == -1 || intent?.hasExtra(EXTRA_NOTIFICATION_CHAT_TYPE) != true) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationLaunchCoordinator.publish(
|
||||
NotificationLaunchTarget(
|
||||
dmConversationUserId = launchState.startAtDmConversationUserId,
|
||||
scrollToMessageId = launchState.scrollToMessageId,
|
||||
startAtPublicChat = launchState.startAtPublicChat,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseProfileDeepLink(intent: Intent?): ProfileDeepLinkTarget? {
|
||||
val data: Uri = intent?.data ?: return null
|
||||
Logger.d("ProfileDeepLink", "parseProfileDeepLink intentData=${data.toString()}")
|
||||
@@ -213,7 +233,7 @@ class MainActivity : ComponentActivity() {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
|
||||
applyLaunchState(parseLaunchStateFromIntent(intent))
|
||||
deliverLaunchIntent(intent)
|
||||
setContent {
|
||||
App(
|
||||
scrollToMessageId = scrollToMessageId,
|
||||
@@ -241,7 +261,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
applyLaunchState(parseLaunchStateFromIntent(intent))
|
||||
deliverLaunchIntent(intent)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
||||
@@ -81,7 +81,9 @@ object NotificationHelper {
|
||||
context,
|
||||
if (targetDmUserId != null) -messageId else messageId,
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_MESSAGE_ID, messageId)
|
||||
putExtra(
|
||||
EXTRA_NOTIFICATION_CHAT_TYPE,
|
||||
|
||||
@@ -367,6 +367,8 @@
|
||||
<string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string>
|
||||
<string name="more">Ещё</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>
|
||||
|
||||
<!-- Удаления доступа -->
|
||||
|
||||
@@ -398,6 +398,8 @@
|
||||
|
||||
<string name="more">More</string>
|
||||
<string name="unread_count">+%1$d</string>
|
||||
<string name="unread_count_badge">%1$d</string>
|
||||
<string name="unread_count_overflow">99+</string>
|
||||
|
||||
<!-- Suspension -->
|
||||
<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.DmHistoryResponse
|
||||
import ru.fromchat.api.schema.messages.dm.EditDmRequest
|
||||
import ru.fromchat.api.schema.messages.dm.DmMarkReadRequest
|
||||
import ru.fromchat.api.schema.messages.dm.SendDmFile
|
||||
import ru.fromchat.api.schema.messages.dm.SendDmRequest
|
||||
import ru.fromchat.api.schema.messages.dm.upload.DmUploadChunkRequest
|
||||
@@ -626,6 +627,13 @@ object ApiClient {
|
||||
.body<DmConversationsResponse>()
|
||||
.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> {
|
||||
val trimmed = query.trim()
|
||||
if (trimmed.length < 2) return emptyList()
|
||||
|
||||
+75
-18
@@ -102,10 +102,7 @@ object MessageCacheStore {
|
||||
): ChatListPreviewState? = withContext(Dispatchers.Default) {
|
||||
val convId = conversationIdForPublic()
|
||||
val iid = instanceId()
|
||||
val recent = db.messageDatabaseQueries
|
||||
.selectRecentMessagesByConversation(iid, convId, limit)
|
||||
.executeAsList()
|
||||
.firstOrNull() ?: return@withContext null
|
||||
val recent = resolvePreviewSourceMessageRow(iid, convId) ?: return@withContext null
|
||||
val message = enrichQueuedOutboundUi(listOf(recent.toAppMessage()), convId).firstOrNull()
|
||||
?: return@withContext null
|
||||
buildChatListPreviewState(message, strings, ApiClient.user?.id)
|
||||
@@ -239,6 +236,7 @@ object MessageCacheStore {
|
||||
|
||||
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
|
||||
deleteMessageById(conversationIdForDm(otherUserId), messageId)
|
||||
syncDmConversationPreviewFromCache(otherUserId)
|
||||
}
|
||||
|
||||
suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) {
|
||||
@@ -308,6 +306,10 @@ object MessageCacheStore {
|
||||
val conversationId = conversationIdForDm(conv.user.id)
|
||||
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: conv.user.username.trim()
|
||||
val localUnread = db.messageDatabaseQueries
|
||||
.countUnreadInboundDmMessages(iid, conversationId, conv.user.id.toLong())
|
||||
.executeAsOne()
|
||||
.toInt()
|
||||
UpsertDmConversationRow(
|
||||
conversationId = conversationId,
|
||||
otherUserId = conv.user.id,
|
||||
@@ -318,7 +320,7 @@ object MessageCacheStore {
|
||||
currentUserId,
|
||||
previewStrings,
|
||||
),
|
||||
unreadCount = conv.unreadCount,
|
||||
unreadCount = maxOf(conv.unreadCount, localUnread),
|
||||
updatedAt = conv.lastMessage.timestamp,
|
||||
)
|
||||
}
|
||||
@@ -420,16 +422,34 @@ object MessageCacheStore {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markDmConversationRead(otherUserId: Int) {
|
||||
suspend fun markDmConversationReadLocally(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
if (upToEnvelopeId != null && upToEnvelopeId > 0) {
|
||||
db.messageDatabaseQueries.markInboundDmMessagesReadUpTo(
|
||||
instanceId = iid,
|
||||
conversationId = convId,
|
||||
userId = otherUserId.toLong(),
|
||||
id = upToEnvelopeId.toLong(),
|
||||
)
|
||||
} else {
|
||||
db.messageDatabaseQueries.markAllInboundDmMessagesRead(
|
||||
instanceId = iid,
|
||||
conversationId = convId,
|
||||
userId = otherUserId.toLong(),
|
||||
)
|
||||
}
|
||||
val unreadCount = db.messageDatabaseQueries
|
||||
.countUnreadInboundDmMessages(iid, convId, otherUserId.toLong())
|
||||
.executeAsOne()
|
||||
db.messageDatabaseQueries.updateConversationUnreadCount(
|
||||
unreadCount = 0L,
|
||||
unreadCount = unreadCount,
|
||||
instanceId = iid,
|
||||
id = convId,
|
||||
)
|
||||
}
|
||||
DmConversationListNotifier.notifyChanged()
|
||||
}
|
||||
|
||||
suspend fun selectUnreadPublicMessageIds(): List<Int> {
|
||||
@@ -567,6 +587,10 @@ object MessageCacheStore {
|
||||
.selectActiveDmConversationsForInstance(instanceId)
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
val messagesFlow = db.messageDatabaseQueries
|
||||
.selectMessagesForInstance(instanceId)
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
val pendingFlow = db.messageDatabaseQueries
|
||||
.selectAllPendingMessagesForInstance(instanceId)
|
||||
.asFlow()
|
||||
@@ -575,7 +599,8 @@ object MessageCacheStore {
|
||||
.selectPendingOutboxForInstance(instanceId)
|
||||
.asFlow()
|
||||
.mapToList(Dispatchers.Default)
|
||||
return merge(conversationsFlow, pendingFlow, outboxFlow)
|
||||
val notifierFlow = DmConversationListNotifier.events.map { Unit }
|
||||
return merge(conversationsFlow, messagesFlow, pendingFlow, outboxFlow, notifierFlow)
|
||||
.mapLatest { loadCachedDmConversations() }
|
||||
}
|
||||
|
||||
@@ -607,12 +632,9 @@ object MessageCacheStore {
|
||||
strings: ChatListPreviewStrings,
|
||||
currentUserId: Int?,
|
||||
): ChatListPreviewState? {
|
||||
val recent = db.messageDatabaseQueries
|
||||
.selectRecentMessagesByConversation(instanceId, conversationId, 1)
|
||||
.executeAsList()
|
||||
.firstOrNull() ?: return null
|
||||
val sourceRow = resolvePreviewSourceMessageRow(instanceId, conversationId) ?: return null
|
||||
val message = enrichQueuedOutboundUi(
|
||||
listOf(recent.toAppMessage()),
|
||||
listOf(sourceRow.toAppMessage()),
|
||||
conversationId,
|
||||
).firstOrNull() ?: return null
|
||||
return buildChatListPreviewState(message, strings, currentUserId)
|
||||
@@ -625,6 +647,25 @@ object MessageCacheStore {
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePreviewSourceMessageRow(
|
||||
instanceId: String,
|
||||
conversationId: String,
|
||||
): DbMessage? {
|
||||
val latestSent = db.messageDatabaseQueries
|
||||
.selectRecentMessagesByConversation(instanceId, conversationId, 1)
|
||||
.executeAsList()
|
||||
.firstOrNull()
|
||||
val latestPending = db.messageDatabaseQueries
|
||||
.selectLatestPendingMessageByConversation(instanceId, conversationId)
|
||||
.executeAsOneOrNull()
|
||||
return when {
|
||||
latestPending == null -> latestSent
|
||||
latestSent == null -> latestPending
|
||||
latestPending.timestamp >= latestSent.timestamp -> latestPending
|
||||
else -> latestSent
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
@@ -639,10 +680,7 @@ object MessageCacheStore {
|
||||
.executeAsOneOrNull()
|
||||
?: return@withContext
|
||||
}
|
||||
val recent = db.messageDatabaseQueries
|
||||
.selectRecentMessagesByConversation(iid, convId, 1)
|
||||
.executeAsList()
|
||||
.firstOrNull()
|
||||
val recent = resolvePreviewSourceMessageRow(iid, convId)
|
||||
val previewStrings = listPreviewStrings
|
||||
val preview = previewStrings?.let { strings ->
|
||||
recent?.toAppMessage()?.let { message ->
|
||||
@@ -654,6 +692,13 @@ object MessageCacheStore {
|
||||
}
|
||||
?.let { truncateDmListPreview(it) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
val unreadCount = db.messageDatabaseQueries
|
||||
.countUnreadInboundDmMessages(
|
||||
iid,
|
||||
convId,
|
||||
otherUserId.toLong(),
|
||||
)
|
||||
.executeAsOne()
|
||||
db.messageDatabaseQueries.upsertConversation(
|
||||
instanceId = iid,
|
||||
id = row.id,
|
||||
@@ -662,7 +707,7 @@ object MessageCacheStore {
|
||||
displayName = row.displayName,
|
||||
lastMessageId = recent?.id ?: row.lastMessageId,
|
||||
lastMessagePreview = preview ?: row.lastMessagePreview,
|
||||
unreadCount = row.unreadCount,
|
||||
unreadCount = unreadCount,
|
||||
updatedAt = recent?.timestamp ?: row.updatedAt,
|
||||
archived = row.archived,
|
||||
)
|
||||
@@ -670,6 +715,18 @@ object MessageCacheStore {
|
||||
DmConversationListNotifier.notifyChanged()
|
||||
}
|
||||
|
||||
suspend fun isInboundDmMessageRead(otherUserId: Int, envelopeId: Int): Boolean {
|
||||
if (envelopeId <= 0) return true
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
return withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries
|
||||
.selectMessageById(iid, convId, envelopeId.toLong())
|
||||
.executeAsOneOrNull()
|
||||
?.isRead == 1L
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun clearConversationMessages(conversationId: String) {
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
|
||||
+12
-2
@@ -88,8 +88,18 @@ object MessageRepository {
|
||||
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) =
|
||||
MessageCacheStore.ensureDmConversationRow(otherUserId, displayName)
|
||||
|
||||
suspend fun markDmConversationRead(otherUserId: Int) =
|
||||
MessageCacheStore.markDmConversationRead(otherUserId)
|
||||
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
||||
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
|
||||
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
|
||||
}
|
||||
|
||||
suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) {
|
||||
if (upToEnvelopeId <= 0) return
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
val alreadyRead = MessageCacheStore.isInboundDmMessageRead(otherUserId, upToEnvelopeId)
|
||||
if (alreadyRead) return
|
||||
markDmConversationRead(otherUserId, upToEnvelopeId)
|
||||
}
|
||||
|
||||
suspend fun markPublicConversationRead() {
|
||||
val localIds = MessageCacheStore.selectUnreadPublicMessageIds()
|
||||
|
||||
+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.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.UserStatusStore
|
||||
import ru.fromchat.api.local.messages.DmInboxCoordinator
|
||||
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.legal.DocumentScreen
|
||||
import ru.fromchat.legal.DocumentType
|
||||
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||
import ru.fromchat.ui.auth.AuthScreen
|
||||
import ru.fromchat.ui.calls.CallOverlay
|
||||
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
|
||||
@@ -167,15 +169,13 @@ private fun handlePresenceEvent(message: WebSocketMessage) {
|
||||
"suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(message)
|
||||
"statusUpdate" -> message.data?.jsonObject?.let(::handlePresenceStatus)
|
||||
"dmTyping", "stopDmTyping" -> message.data?.jsonObject?.let { handlePresenceTyping(message.type, it) }
|
||||
"dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message)
|
||||
"call_signaling" -> CallStore.onWebSocketMessage(message)
|
||||
"updates" -> {
|
||||
val data = message.data ?: return
|
||||
val updates = ApiClient.json.decodeFromJsonElement<WebSocketUpdatesData>(data)
|
||||
updates.updates.forEach { update ->
|
||||
when (update.type) {
|
||||
"suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(update)
|
||||
else -> handlePresenceEvent(update)
|
||||
}
|
||||
handlePresenceEvent(WebSocketMessage(type = update.type, data = update.data))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,10 +318,8 @@ fun App(
|
||||
}
|
||||
}
|
||||
|
||||
// Handle startup/deep-link navigation targets (notification chat/profile)
|
||||
// Handle startup/deep-link navigation targets (profile links)
|
||||
LaunchedEffect(
|
||||
startAtDmConversationUserId,
|
||||
startAtPublicChat,
|
||||
startAtProfileUserId,
|
||||
startAtProfileUsername,
|
||||
startDestination
|
||||
@@ -329,8 +327,7 @@ fun App(
|
||||
Logger.d(
|
||||
"ProfileDeepLink",
|
||||
"startup nav check: startDestination=$startDestination, startAtProfileUserId=$startAtProfileUserId, " +
|
||||
"startAtProfileUsername=$startAtProfileUsername, startAtDmConversationUserId=$startAtDmConversationUserId, " +
|
||||
"startAtPublicChat=$startAtPublicChat, scrollToMessageId=$scrollToMessageId"
|
||||
"startAtProfileUsername=$startAtProfileUsername"
|
||||
)
|
||||
if (startDestination == null || startDestination == "welcome") {
|
||||
return@LaunchedEffect
|
||||
@@ -350,23 +347,42 @@ fun App(
|
||||
"navigating by deep link username=$trimmedUsername"
|
||||
)
|
||||
navController.navigate("profile/$trimmedUsername?fromDeepLink=true")
|
||||
} else if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) {
|
||||
Logger.d(
|
||||
"ProfileDeepLink",
|
||||
"navigating by notification chat route user=$startAtDmConversationUserId messageId=$scrollToMessageId"
|
||||
)
|
||||
navController.navigate(
|
||||
DmNav.chatRoute(
|
||||
otherUserId = startAtDmConversationUserId,
|
||||
sourceMessageId = scrollToMessageId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(startDestination) {
|
||||
if (startDestination == null || startDestination == "welcome") {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
NotificationLaunchCoordinator.pendingLaunches.collect { target ->
|
||||
when {
|
||||
target.dmConversationUserId != null && target.dmConversationUserId > 0 -> {
|
||||
Logger.d(
|
||||
"NotificationLaunch",
|
||||
"navigating to dm user=${target.dmConversationUserId} " +
|
||||
"messageId=${target.scrollToMessageId} launchId=${target.launchId}"
|
||||
)
|
||||
) {
|
||||
launchSingleTop = true
|
||||
navController.navigate(
|
||||
DmNav.chatRoute(
|
||||
otherUserId = target.dmConversationUserId,
|
||||
sourceMessageId = target.scrollToMessageId,
|
||||
)
|
||||
) {
|
||||
launchSingleTop = true
|
||||
popUpTo("chat") { saveState = true }
|
||||
}
|
||||
}
|
||||
} else if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") {
|
||||
Logger.d("ProfileDeepLink", "navigating to public chat route")
|
||||
navController.navigate("chats/publicChat") {
|
||||
launchSingleTop = true
|
||||
|
||||
target.startAtPublicChat -> {
|
||||
Logger.d(
|
||||
"NotificationLaunch",
|
||||
"navigating to public chat launchId=${target.launchId}"
|
||||
)
|
||||
navController.navigate(PublicChatNav.CHAT_ROUTE) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ fun DmChatRoute(
|
||||
|
||||
DmScreen(
|
||||
panel = panel,
|
||||
activePeerUserId = otherUserId,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
scrollToMessageId = scrollToMessageId,
|
||||
onTitleClick = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.MessageCacheStore
|
||||
import ru.fromchat.api.local.messages.ActiveDmChatTracker
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
import ru.fromchat.api.local.messages.conversationIdForDm
|
||||
import ru.fromchat.api.local.db.parseDmMessageContent
|
||||
@@ -346,8 +347,10 @@ class DmPanel(
|
||||
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
||||
} else {
|
||||
val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageCacheStore.upsertDmMessage(otherUserId, incoming)
|
||||
if (ActiveDmChatTracker.isActive(otherUserId)) {
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageCacheStore.upsertDmMessage(otherUserId, incoming)
|
||||
}
|
||||
}
|
||||
addMessage(incoming)
|
||||
if (envelope.replyToId != null) {
|
||||
@@ -520,7 +523,10 @@ class DmPanel(
|
||||
user_id = envelope.senderId,
|
||||
content = dec.text,
|
||||
timestamp = envelope.timestamp,
|
||||
is_read = envelope.recipientId == currentUserId,
|
||||
is_read = when {
|
||||
envelope.senderId == currentUserId -> true
|
||||
else -> ActiveDmChatTracker.isActive(otherUserId)
|
||||
},
|
||||
is_edited = false,
|
||||
username = username,
|
||||
profile_picture = null,
|
||||
|
||||
@@ -4,24 +4,31 @@ import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
import ru.fromchat.api.local.messages.ActiveDmChatTracker
|
||||
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||
import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.ui.chat.AvatarInfo
|
||||
import ru.fromchat.ui.chat.ChatScreen
|
||||
import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility
|
||||
|
||||
@Composable
|
||||
fun DmScreen(
|
||||
panel: DmPanel,
|
||||
activePeerUserId: Int,
|
||||
scrollToMessageId: Int? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
onTitleClick: (() -> Unit)? = null,
|
||||
@@ -34,21 +41,26 @@ fun DmScreen(
|
||||
) {
|
||||
val currentUserId = ApiClient.user?.id
|
||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||
val otherUserId = panel.getState().profileUserId
|
||||
val peerUserId = activePeerUserId.takeIf { it > 0 } ?: panel.getState().profileUserId
|
||||
|
||||
LaunchedEffect(panel, activeInstanceId, otherUserId) {
|
||||
DisposableEffect(activePeerUserId) {
|
||||
if (activePeerUserId > 0) {
|
||||
ActiveDmChatTracker.setActive(activePeerUserId)
|
||||
}
|
||||
onDispose { ActiveDmChatTracker.setActive(null) }
|
||||
}
|
||||
|
||||
LaunchedEffect(panel, activeInstanceId, peerUserId) {
|
||||
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||
val peerId = otherUserId ?: return@LaunchedEffect
|
||||
val peerId = peerUserId ?: return@LaunchedEffect
|
||||
if (peerId <= 0) return@LaunchedEffect
|
||||
// Panel is retained in [DmPanelCache]; only cold-load when the list is still empty
|
||||
// (e.g. returning from profile must not call loadMessages and flash the chat spinner).
|
||||
if (panel.getState().messages.isEmpty()) {
|
||||
panel.loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(activeInstanceId, otherUserId) {
|
||||
val peerId = otherUserId ?: return@LaunchedEffect
|
||||
LaunchedEffect(activeInstanceId, peerUserId) {
|
||||
val peerId = peerUserId ?: return@LaunchedEffect
|
||||
val instanceId = activeInstanceId.trim()
|
||||
if (instanceId.isBlank() || peerId <= 0) return@LaunchedEffect
|
||||
scheduleOutboxProcessing(instanceId)
|
||||
@@ -57,14 +69,41 @@ fun DmScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(panel, activeInstanceId, otherUserId) {
|
||||
val peerId = otherUserId ?: return@LaunchedEffect
|
||||
LaunchedEffect(panel, activeInstanceId, peerUserId) {
|
||||
val peerId = peerUserId ?: return@LaunchedEffect
|
||||
if (activeInstanceId.isBlank() || peerId <= 0) return@LaunchedEffect
|
||||
MessageRepository.observeDmMessages(peerId).collect { rows ->
|
||||
panel.syncMessagesFromDatabase(rows)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(panel, activeInstanceId, activePeerUserId, peerUserId) {
|
||||
val peerId = activePeerUserId.takeIf { it > 0 } ?: peerUserId ?: return@LaunchedEffect
|
||||
if (activeInstanceId.isBlank() || peerId <= 0) return@LaunchedEffect
|
||||
combine(
|
||||
AttachmentDownloadVisibility.visibleMessageIds,
|
||||
snapshotFlow { panel.getState().messages },
|
||||
) { visibleIds, messages ->
|
||||
visibleIds
|
||||
.filter { id ->
|
||||
messages.find { it.id == id }?.user_id == peerId
|
||||
}
|
||||
.maxOrNull()
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { maxVisibleInboundId ->
|
||||
if (
|
||||
maxVisibleInboundId != null &&
|
||||
maxVisibleInboundId > 0 &&
|
||||
ActiveDmChatTracker.isActive(peerId)
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageRepository.markDmConversationReadUpTo(peerId, maxVisibleInboundId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatScreen(
|
||||
panel = panel,
|
||||
currentUserId = currentUserId,
|
||||
|
||||
+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
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
@@ -27,6 +26,7 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -36,6 +36,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -77,12 +79,12 @@ import ru.fromchat.api.schema.user.User
|
||||
import ru.fromchat.cd_chat_preview_sending
|
||||
import ru.fromchat.cd_chat_preview_uploading
|
||||
import ru.fromchat.cd_chat_selected
|
||||
import ru.fromchat.presence_online
|
||||
import ru.fromchat.ui.chat.Avatar
|
||||
import ru.fromchat.ui.chat.ExpressiveUploadIndicator
|
||||
import ru.fromchat.ui.chat.TypingIndicator
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.unread_count
|
||||
import ru.fromchat.unread_count_badge
|
||||
import ru.fromchat.unread_count_overflow
|
||||
import ru.fromchat.user_fallback
|
||||
|
||||
internal object ChatListLayout {
|
||||
@@ -484,6 +486,8 @@ internal fun ChatRowAvatar(
|
||||
onPressEnd: () -> Unit,
|
||||
onLongPress: (Offset) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
showOnlineIndicator: Boolean = false,
|
||||
onlineIndicatorBorderColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
@@ -508,6 +512,55 @@ internal fun ChatRowAvatar(
|
||||
displayName = displayNameForInitials,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
if (showOnlineIndicator) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(14.dp)
|
||||
.background(onlineIndicatorBorderColor, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun ChatUnreadBadge(
|
||||
count: Int,
|
||||
visible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tonal = ButtonDefaults.filledTonalButtonColors()
|
||||
val overflowLabel = stringResource(Res.string.unread_count_overflow)
|
||||
val label = if (count > 99) overflowLabel else stringResource(Res.string.unread_count_badge, count)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
modifier = modifier,
|
||||
enter = scaleIn(animationSpec = ChatUnreadBadgeSpring) + fadeIn(animationSpec = ChatUnreadBadgeSpring),
|
||||
exit = scaleOut(animationSpec = ChatUnreadBadgeSpring) + fadeOut(animationSpec = ChatUnreadBadgeSpring),
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = tonal.containerColor,
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tonal.contentColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,36 +831,18 @@ internal fun DmConversationRowContent(
|
||||
val typingUsers = status?.typingUsernames.orEmpty()
|
||||
val isTyping = typingUsers.isNotEmpty()
|
||||
val isOnline = status?.online ?: (cached?.online == true)
|
||||
val statusKey = when {
|
||||
isTyping -> "typing:${typingUsers.joinToString("|")}"
|
||||
isOnline -> "online"
|
||||
else -> "offline"
|
||||
}
|
||||
val listSurfaceColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
ListItem(
|
||||
headline = peerTitle,
|
||||
supportingSlot = {
|
||||
AnimatedContent(
|
||||
targetState = statusKey,
|
||||
transitionSpec = {
|
||||
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
||||
(slideOutVertically { -it / 2 } + fadeOut())
|
||||
},
|
||||
label = "dm_status_${conversation.otherUserId}",
|
||||
) { state ->
|
||||
when {
|
||||
state.startsWith("typing:") -> TypingIndicator(typingUsers = typingUsers)
|
||||
state == "online" -> Text(
|
||||
text = stringResource(Res.string.presence_online),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
else -> ChatListPreviewSupportingText(
|
||||
preview = preview,
|
||||
pendingIndicator = conversation.lastMessagePendingIndicator,
|
||||
uploadProgress = conversation.lastMessageUploadProgress,
|
||||
)
|
||||
}
|
||||
if (isTyping) {
|
||||
TypingIndicator(typingUsers = typingUsers)
|
||||
} else {
|
||||
ChatListPreviewSupportingText(
|
||||
preview = preview,
|
||||
pendingIndicator = conversation.lastMessagePendingIndicator,
|
||||
uploadProgress = conversation.lastMessageUploadProgress,
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = Color.Transparent,
|
||||
@@ -827,13 +862,16 @@ internal fun DmConversationRowContent(
|
||||
onPressStart = onAvatarPressStart,
|
||||
onPressEnd = onAvatarPressEnd,
|
||||
onLongPress = onAvatarLongPress,
|
||||
showOnlineIndicator = isOnline,
|
||||
onlineIndicatorBorderColor = listSurfaceColor,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (conversation.unreadCount > 0 && listMode == ChatsListMode.Normal) {
|
||||
Text(stringResource(Res.string.unread_count, conversation.unreadCount))
|
||||
}
|
||||
ChatUnreadBadge(
|
||||
count = conversation.unreadCount,
|
||||
visible = conversation.unreadCount > 0 && listMode == ChatsListMode.Normal,
|
||||
)
|
||||
},
|
||||
bodyModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -940,6 +978,10 @@ internal fun ChatListPreviewSupportingText(
|
||||
}
|
||||
}
|
||||
|
||||
internal val ChatUnreadBadgeSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium,
|
||||
)
|
||||
internal val ChatRowPressSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
|
||||
@@ -476,7 +476,7 @@ fun ChatsTab(
|
||||
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||
MessageRepository.observeActiveDmConversations().collect { conversations ->
|
||||
conversations.forEach { ProfileCache.mergeFromCachedConversation(it) }
|
||||
dmConversations = conversations
|
||||
dmConversations = ChatListReorderController.applyOrdered(conversations)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ FROM message
|
||||
WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 AND id < 0
|
||||
ORDER BY timestamp ASC;
|
||||
|
||||
selectLatestPendingMessageByConversation:
|
||||
SELECT *
|
||||
FROM message
|
||||
WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 AND id < 0
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1;
|
||||
|
||||
selectSentMessageIdByClientMessageId:
|
||||
SELECT id
|
||||
FROM message
|
||||
@@ -194,6 +201,21 @@ UPDATE message
|
||||
SET isRead = 1
|
||||
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND deletedFlag = 0;
|
||||
|
||||
countUnreadInboundDmMessages:
|
||||
SELECT COUNT(*)
|
||||
FROM message
|
||||
WHERE instanceId = ? AND conversationId = ? AND userId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0;
|
||||
|
||||
markInboundDmMessagesReadUpTo:
|
||||
UPDATE message
|
||||
SET isRead = 1
|
||||
WHERE instanceId = ? AND conversationId = ? AND userId = ? AND id <= ? AND isRead = 0 AND id > 0 AND deletedFlag = 0;
|
||||
|
||||
markAllInboundDmMessagesRead:
|
||||
UPDATE message
|
||||
SET isRead = 1
|
||||
WHERE instanceId = ? AND conversationId = ? AND userId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0;
|
||||
|
||||
deleteAllMessagesForInstance:
|
||||
DELETE FROM message WHERE instanceId = ?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user