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
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()
@@ -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) {
@@ -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()
@@ -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.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,27 +347,46 @@ fun App(
"navigating by deep link username=$trimmedUsername"
)
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(
"ProfileDeepLink",
"navigating by notification chat route user=$startAtDmConversationUserId messageId=$scrollToMessageId"
"NotificationLaunch",
"navigating to dm user=${target.dmConversationUserId} " +
"messageId=${target.scrollToMessageId} launchId=${target.launchId}"
)
navController.navigate(
DmNav.chatRoute(
otherUserId = startAtDmConversationUserId,
sourceMessageId = scrollToMessageId
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") {
}
target.startAtPublicChat -> {
Logger.d(
"NotificationLaunch",
"navigating to public chat launchId=${target.launchId}"
)
navController.navigate(PublicChatNav.CHAT_ROUTE) {
launchSingleTop = true
}
}
}
}
}
CompositionLocalProvider(
LocalNavController provides navController,
@@ -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,9 +347,11 @@ class DmPanel(
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
} else {
val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
if (ActiveDmChatTracker.isActive(otherUserId)) {
withContext(Dispatchers.Default) {
MessageCacheStore.upsertDmMessage(otherUserId, incoming)
}
}
addMessage(incoming)
if (envelope.replyToId != null) {
val replyTo = _state.messages.find { it.id == envelope.replyToId }
@@ -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,
@@ -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,37 +831,19 @@ 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(
if (isTyping) {
TypingIndicator(typingUsers = typingUsers)
} else {
ChatListPreviewSupportingText(
preview = preview,
pendingIndicator = conversation.lastMessagePendingIndicator,
uploadProgress = conversation.lastMessageUploadProgress,
)
}
}
},
containerColor = Color.Transparent,
position = listItemPosition,
@@ -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 = ?;