Fix cache bugs, light theme and others

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-10 14:10:18 +03:00
Unverified
parent 9d43edd547
commit 243d41385d
39 changed files with 572 additions and 233 deletions
@@ -27,6 +27,8 @@ import ru.fromchat.MainActivity
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.R import ru.fromchat.R
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ChatListPreviewStrings import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.api.local.messages.buildChatListPreview import ru.fromchat.api.local.messages.buildChatListPreview
import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope
@@ -301,15 +303,13 @@ object NotificationHelper {
} }
} }
val senderName = if ( val senderName = when {
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName
) { !envelope.senderUsername.isNullOrBlank() -> envelope.senderUsername
dmSenderName else -> ProfileCache.get(envelope.senderId)
} else if (!envelope.senderUsername.isNullOrBlank()) { ?.visibleDisplayName(currentUserId)
envelope.senderUsername ?.takeIf { it.isNotBlank() }
} else { }.orEmpty()
"User ${envelope.senderId}"
}
val dmConversationUserId = envelope.senderId val dmConversationUserId = envelope.senderId
val notificationBody = buildChatListPreviewFromEnvelope( val notificationBody = buildChatListPreviewFromEnvelope(
envelope = envelope, envelope = envelope,
@@ -319,7 +319,11 @@ object NotificationHelper {
showFallbackPushNotification( showFallbackPushNotification(
context = context, context = context,
title = "Direct message from $senderName", title = if (senderName.isNotBlank()) {
"Direct message from $senderName"
} else {
"Direct message"
},
body = notificationBody, body = notificationBody,
sender = senderName, sender = senderName,
messageId = envelopeId, messageId = envelopeId,
@@ -1,12 +1,19 @@
package ru.fromchat.ui package ru.fromchat.ui
import android.app.Activity
import android.graphics.Color as AndroidColor
import android.os.Build import android.os.Build
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
@Composable @Composable
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) = actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
@@ -16,4 +23,26 @@ actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
} else { } else {
if (darkTheme) darkColorScheme() if (darkTheme) darkColorScheme()
else lightColorScheme() else lightColorScheme()
} }
@Composable
actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) {
val view = LocalView.current
if (view.isInEditMode) return
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
@Suppress("DEPRECATION")
window.statusBarColor = AndroidColor.TRANSPARENT
@Suppress("DEPRECATION")
window.navigationBarColor = AndroidColor.TRANSPARENT
window.decorView.setBackgroundColor(surfaceColor.toArgb())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
}
}
@@ -109,7 +109,6 @@
<string name="notif_media_upload_title">Отправка вложения</string> <string name="notif_media_upload_title">Отправка вложения</string>
<string name="notif_media_upload_text">Загрузка продолжается в фоне</string> <string name="notif_media_upload_text">Загрузка продолжается в фоне</string>
<string name="message_sender_you">Вы</string> <string name="message_sender_you">Вы</string>
<string name="user_fallback">Человек %1$d</string>
<string name="deleted_account">Удалённый аккаунт</string> <string name="deleted_account">Удалённый аккаунт</string>
<string name="account_suspended">Аккаунт заблокирован</string> <string name="account_suspended">Аккаунт заблокирован</string>
<string name="action_delete_chat">Удалить чат</string> <string name="action_delete_chat">Удалить чат</string>
@@ -121,7 +121,6 @@
<!-- Messages --> <!-- Messages -->
<string name="message_sender_you">You</string> <string name="message_sender_you">You</string>
<string name="user_fallback">Person %1$d</string>
<string name="deleted_account">Deleted account</string> <string name="deleted_account">Deleted account</string>
<string name="account_suspended">Account suspended</string> <string name="account_suspended">Account suspended</string>
<string name="action_delete_chat">Delete chat</string> <string name="action_delete_chat">Delete chat</string>
@@ -166,6 +166,23 @@ object ApiClient {
) )
} }
suspend fun applyOwnProfile(profile: UserProfile) {
syncSuspensionStateFromProfile(profile)
val currentUser = user ?: return
user = currentUser.copy(
username = profile.username,
displayName = profile.displayName,
bio = profile.bio,
profile_picture = profile.profilePicture,
verified = profile.verified,
verificationStatus = profile.verificationStatus,
deleted = profile.deleted,
online = profile.online,
last_seen = profile.lastSeen,
)
persistCurrentUser()
}
fun clearSuspensionState() { fun clearSuspensionState() {
_suspensionState.value = SuspensionState() _suspensionState.value = SuspensionState()
user = user?.copy( user = user?.copy(
@@ -51,11 +51,13 @@ object ChatListSync {
} }
private suspend fun syncDmConversations() { private suspend fun syncDmConversations() {
val previewStrings = MessageCacheStore.listPreviewStrings ?: return
runCatching { runCatching {
val conversations = ApiClient.getDmConversations() val conversations = ApiClient.getDmConversations()
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
MessageRepository.replaceDmConversations(conversations, previewStrings) MessageRepository.replaceDmConversations(
conversations,
MessageCacheStore.listPreviewStrings,
)
} }
} }
@@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.fromchat.api.local.db.store.ProfileCache
/** /**
* Network work that must not block cold start or the first frame. * Network work that must not block cold start or the first frame.
@@ -20,7 +21,8 @@ object DeferredStartupNetwork {
if (ApiClient.token.isNullOrEmpty()) return@launch if (ApiClient.token.isNullOrEmpty()) return@launch
runCatching { runCatching {
val profile = ApiClient.getOwnProfile() val profile = ApiClient.getOwnProfile()
ApiClient.syncSuspensionStateFromProfile(profile) ApiClient.applyOwnProfile(profile)
ProfileCache.put(profile)
} }
runCatching { syncPushTokenAfterStartup() } runCatching { syncPushTokenAfterStartup() }
} }
@@ -54,7 +54,7 @@ object CallStore {
val me = ApiClient.user?.id val me = ApiClient.user?.id
val p = ProfileCache.get(peerUserId) val p = ProfileCache.get(peerUserId)
val label = p?.visibleDisplayName(me)?.orEmpty()?.ifBlank { null } val label = p?.visibleDisplayName(me)?.orEmpty()?.ifBlank { null }
return label ?: p?.username?.takeIf { it.isNotBlank() } ?: "User $peerUserId" return label ?: p?.username?.takeIf { it.isNotBlank() }.orEmpty()
} }
fun onWebSocketMessage(message: WebSocketMessage) { fun onWebSocketMessage(message: WebSocketMessage) {
@@ -16,6 +16,18 @@ import ru.fromchat.api.local.cache.DecryptedImageCache
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Serializable
private data class DmOutboundTextEnvelope(
val type: String = "text",
val data: DmOutboundTextData,
)
@Serializable
private data class DmOutboundTextData(
val content: String,
@SerialName("reply_to_id") val replyToId: Int? = null,
)
@Serializable @Serializable
private data class PersistedOptimisticOutboundPayload( private data class PersistedOptimisticOutboundPayload(
@SerialName("text") val text: String, @SerialName("text") val text: String,
@@ -40,6 +52,8 @@ private data class PersistedDmMessagePayload(
data class ParsedDmMessageContent( data class ParsedDmMessageContent(
val text: String, val text: String,
/** Reply target from encrypted JSON payload (`reply_to_id`), when present. */
val replyToId: Int? = null,
val envelope: DmEnvelope? = null, val envelope: DmEnvelope? = null,
val fileThumbnails: List<String>? = null, val fileThumbnails: List<String>? = null,
val fileAspectRatios: List<Float>? = null, val fileAspectRatios: List<Float>? = null,
@@ -52,6 +66,21 @@ data class ParsedDmMessageContent(
val uploadJobId: String? = null, val uploadJobId: String? = null,
) )
/**
* Plaintext for outbound DM encryption. Embeds [replyToId] in the Web-compatible JSON envelope
* so recipients can resolve reply previews without relying on API envelope metadata.
*/
fun buildDmOutboundPlaintext(content: String, replyToId: Int?): String {
val trimmed = content.trim()
val replyId = replyToId?.takeIf { it > 0 }
if (replyId == null) return trimmed
return json.encodeToString(
DmOutboundTextEnvelope(
data = DmOutboundTextData(content = trimmed, replyToId = replyId),
),
)
}
/** Persists in-flight attachment fields so SQLDelight reload keeps the file row UI. */ /** Persists in-flight attachment fields so SQLDelight reload keeps the file row UI. */
fun encodeOptimisticOutboundMessage(message: Message): String { fun encodeOptimisticOutboundMessage(message: Message): String {
val pendingUri = message.pendingFileUri?.trim().orEmpty() val pendingUri = message.pendingFileUri?.trim().orEmpty()
@@ -133,6 +162,17 @@ fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent {
val trimmed = plaintext.trim() val trimmed = plaintext.trim()
if (trimmed.startsWith("{")) { if (trimmed.startsWith("{")) {
val root = runCatching { json.parseToJsonElement(trimmed).jsonObject }.getOrNull() val root = runCatching { json.parseToJsonElement(trimmed).jsonObject }.getOrNull()
if (root?.containsKey("type") == true && root["type"]?.jsonPrimitive?.content == "text") {
return runCatching {
val payload = json.decodeFromString<DmOutboundTextEnvelope>(trimmed)
ParsedDmMessageContent(
text = payload.data.content,
replyToId = payload.data.replyToId?.takeIf { it > 0 },
)
}.getOrElse {
ParsedDmMessageContent(text = plaintext)
}
}
if (root?.containsKey("pendingFileUri") == true) { if (root?.containsKey("pendingFileUri") == true) {
return runCatching { return runCatching {
val payload = json.decodeFromString<PersistedOptimisticOutboundPayload>(trimmed) val payload = json.decodeFromString<PersistedOptimisticOutboundPayload>(trimmed)
@@ -41,8 +41,10 @@ import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.api.local.cache.DecryptedFileCache import ru.fromchat.api.local.cache.DecryptedFileCache
import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.download.DownloadedFileRegistry import ru.fromchat.api.local.download.DownloadedFileRegistry
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages
import kotlin.concurrent.Volatile
import ru.fromchat.db.Message as DbMessage import ru.fromchat.db.Message as DbMessage
data class CachedConversation( data class CachedConversation(
@@ -78,7 +80,7 @@ object MessageCacheStore {
.asFlow() .asFlow()
.mapToList(Dispatchers.Default) .mapToList(Dispatchers.Default)
.map { rows -> .map { rows ->
val raw = hydrateReplyReferences(rows) val raw = hydrateReplyReferencesFromRows(rows)
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
sortMessagesForChatDisplay( sortMessagesForChatDisplay(
validatedOrEmpty( validatedOrEmpty(
@@ -99,11 +101,10 @@ object MessageCacheStore {
fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> { fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> {
if (instanceId.isBlank()) return emptyList() if (instanceId.isBlank()) return emptyList()
val convId = conversationIdForPublic() val convId = conversationIdForPublic()
val raw = hydrateReplyReferences( val rows = db.messageDatabaseQueries
db.messageDatabaseQueries .selectRecentMessagesByConversation(instanceId, convId, limit)
.selectRecentMessagesByConversation(instanceId, convId, limit) .executeAsList()
.executeAsList(), val raw = hydrateReplyReferencesFromRows(rows).reversed()
).reversed()
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
return ProfileCache.enrichPublicMessagesForDisplay( return ProfileCache.enrichPublicMessagesForDisplay(
sortMessagesForChatDisplay( sortMessagesForChatDisplay(
@@ -338,9 +339,9 @@ object MessageCacheStore {
suspend fun replaceDmConversations( suspend fun replaceDmConversations(
conversations: List<DmConversation>, conversations: List<DmConversation>,
previewStrings: ChatListPreviewStrings, previewStrings: ChatListPreviewStrings? = listPreviewStrings,
) { ) {
listPreviewStrings = previewStrings previewStrings?.let { listPreviewStrings = it }
val iid = instanceId() val iid = instanceId()
val currentUserId = ApiClient.user?.id val currentUserId = ApiClient.user?.id
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@@ -408,10 +409,14 @@ object MessageCacheStore {
private suspend fun buildDmListPreview( private suspend fun buildDmListPreview(
envelope: DmEnvelope, envelope: DmEnvelope,
currentUserId: Int?, currentUserId: Int?,
previewStrings: ChatListPreviewStrings, previewStrings: ChatListPreviewStrings?,
): String? { ): String? {
val decrypted = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() val decrypted = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
val previewSource = buildChatListPreviewFromEnvelope(envelope, decrypted, previewStrings) val previewSource = if (previewStrings != null) {
buildChatListPreviewFromEnvelope(envelope, decrypted, previewStrings)
} else {
decrypted?.trim()?.takeIf { it.isNotEmpty() }
}
return previewSource?.let { truncateDmListPreview(it) }?.takeIf { it.isNotEmpty() } return previewSource?.let { truncateDmListPreview(it) }?.takeIf { it.isNotEmpty() }
} }
@@ -448,11 +453,25 @@ object MessageCacheStore {
val existing = db.messageDatabaseQueries val existing = db.messageDatabaseQueries
.selectConversationById(iid, convId) .selectConversationById(iid, convId)
.executeAsOneOrNull() .executeAsOneOrNull()
if (existing != null) return@withContext val label = resolveDmConversationDisplayLabel(otherUserId, displayName)
val label = displayName?.trim()?.takeIf { it.isNotEmpty() } if (existing != null) {
?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() } if (label.isNotEmpty() && existing.displayName.isNullOrBlank()) {
?: ProfileCache.get(otherUserId)?.username?.trim()?.takeIf { it.isNotEmpty() } db.messageDatabaseQueries.upsertConversation(
?: "" instanceId = iid,
id = existing.id,
type = existing.type,
otherUserId = existing.otherUserId,
displayName = label,
lastMessageId = existing.lastMessageId,
lastMessagePreview = existing.lastMessagePreview,
unreadCount = existing.unreadCount,
updatedAt = existing.updatedAt,
archived = existing.archived,
)
DmConversationListNotifier.notifyChanged()
}
return@withContext
}
db.messageDatabaseQueries.upsertConversation( db.messageDatabaseQueries.upsertConversation(
instanceId = iid, instanceId = iid,
id = convId, id = convId,
@@ -468,6 +487,11 @@ object MessageCacheStore {
} }
} }
private fun resolveDmConversationDisplayLabel(otherUserId: Int, displayName: String?): String =
displayName?.trim()?.takeIf { it.isNotEmpty() }
?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: ProfileCache.get(otherUserId)?.visibleUsername(ApiClient.user?.id).orEmpty()
suspend fun markDmConversationReadLocally(otherUserId: Int, upToEnvelopeId: Int? = null) { suspend fun markDmConversationReadLocally(otherUserId: Int, upToEnvelopeId: Int? = null) {
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
@@ -835,6 +859,11 @@ object MessageCacheStore {
val storedContent = encodePersistedDmMessage(confirmed) val storedContent = encodePersistedDmMessage(confirmed)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.transaction { db.messageDatabaseQueries.transaction {
val existingReplyToId = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.firstOrNull { it.clientMessageId == clientMessageId }
?.replyToId
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId) db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
db.messageDatabaseQueries.upsertMessage( db.messageDatabaseQueries.upsertMessage(
instanceId = iid, instanceId = iid,
@@ -845,7 +874,7 @@ object MessageCacheStore {
timestamp = confirmed.timestamp, timestamp = confirmed.timestamp,
isRead = if (confirmed.is_read) 1L else 0L, isRead = if (confirmed.is_read) 1L else 0L,
isEdited = if (confirmed.is_edited) 1L else 0L, isEdited = if (confirmed.is_edited) 1L else 0L,
replyToId = resolveReplyToIdForPersistence(confirmed), replyToId = resolveReplyToIdForPersistence(confirmed, existingReplyToId),
clientMessageId = confirmed.client_message_id, clientMessageId = confirmed.client_message_id,
deletedFlag = 0L, deletedFlag = 0L,
sendStatus = "sent" sendStatus = "sent"
@@ -865,7 +894,7 @@ object MessageCacheStore {
val rows = db.messageDatabaseQueries val rows = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId) .selectMessagesByConversation(iid, conversationId)
.executeAsList() .executeAsList()
val raw = hydrateReplyReferences(rows) val raw = hydrateReplyReferencesFromRows(rows)
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded) purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded)
sortMessagesForChatDisplay( sortMessagesForChatDisplay(
@@ -885,7 +914,7 @@ object MessageCacheStore {
val rows = db.messageDatabaseQueries val rows = db.messageDatabaseQueries
.selectRecentMessagesByConversation(iid, conversationId, limit) .selectRecentMessagesByConversation(iid, conversationId, limit)
.executeAsList() .executeAsList()
hydrateReplyReferences(rows).reversed() hydrateReplyReferencesFromRows(rows).reversed()
} }
} }
@@ -991,13 +1020,23 @@ object MessageCacheStore {
} }
} }
private fun hydrateReplyReferencesFromRows(rows: List<DbMessage>): List<Message> {
val replyIds = rows.mapNotNull { row ->
row.replyToId?.toInt()?.takeIf { it > 0 }?.let { row.id.toInt() to it }
}.toMap()
return attachPublicReplyReferences(hydrateReplyReferences(rows), replyIds)
}
private fun hydrateReplyReferences(rows: List<DbMessage>): List<Message> { private fun hydrateReplyReferences(rows: List<DbMessage>): List<Message> {
val messages = rows.map { it.toAppMessage() } val messages = rows.map { it.toAppMessage() }
val byId = messages.associateBy { it.id } val byId = messages.associateBy { it.id }
return rows.zip(messages).map { (row, message) -> return rows.zip(messages).map { (row, message) ->
val replyId = row.replyToId?.toInt() ?: message.dmEnvelope?.replyToId val replyId = row.replyToId?.toInt() ?: message.dmEnvelope?.replyToId
if (replyId != null) { if (replyId != null) {
message.copy(reply_to = byId[replyId]) message.copy(
replyToId = replyId,
reply_to = byId[replyId] ?: message.reply_to,
)
} else { } else {
message message
} }
@@ -1015,6 +1054,7 @@ object MessageCacheStore {
private fun resolveReplyToIdForPersistence(msg: Message, existingReplyToId: Long? = null): Long? { private fun resolveReplyToIdForPersistence(msg: Message, existingReplyToId: Long? = null): Long? {
return msg.reply_to?.id?.toLong() return msg.reply_to?.id?.toLong()
?: msg.replyToId?.toLong()
?: msg.dmEnvelope?.replyToId?.toLong() ?: msg.dmEnvelope?.replyToId?.toLong()
?: existingReplyToId?.takeIf { it > 0L } ?: existingReplyToId?.takeIf { it > 0L }
} }
@@ -93,7 +93,7 @@ object MessageRepository {
suspend fun replaceDmConversations( suspend fun replaceDmConversations(
conversations: List<DmConversation>, conversations: List<DmConversation>,
previewStrings: ChatListPreviewStrings, previewStrings: ChatListPreviewStrings? = null,
) = MessageCacheStore.replaceDmConversations(conversations, previewStrings) ) = MessageCacheStore.replaceDmConversations(conversations, previewStrings)
suspend fun loadCachedDmConversations(): List<CachedConversation> = suspend fun loadCachedDmConversations(): List<CachedConversation> =
@@ -3,6 +3,9 @@ package ru.fromchat.api.local.db.store
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -45,9 +48,16 @@ object ProfileCache {
@Volatile @Volatile
private var loadedInstanceId: String = "" private var loadedInstanceId: String = ""
private val _revision = MutableStateFlow(0)
val revision: StateFlow<Int> = _revision.asStateFlow()
private val persistMutex = Mutex() private val persistMutex = Mutex()
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private fun bumpRevision() {
_revision.value++
}
fun get(userId: Int): UserProfile? = profiles[userId] fun get(userId: Int): UserProfile? = profiles[userId]
fun findByUsername(username: String): UserProfile? = fun findByUsername(username: String): UserProfile? =
@@ -125,6 +135,7 @@ object ProfileCache {
} }
val cur = profiles val cur = profiles
profiles = cur + (profile.id to profile) profiles = cur + (profile.id to profile)
bumpRevision()
val instanceId = loadedInstanceId val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) { if (instanceId.isNotEmpty()) {
ioScope.launch { ioScope.launch {
@@ -137,6 +148,7 @@ object ProfileCache {
val cur = profiles val cur = profiles
if (userId !in cur) return if (userId !in cur) return
profiles = cur - userId profiles = cur - userId
bumpRevision()
val instanceId = loadedInstanceId val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) { if (instanceId.isNotEmpty()) {
ioScope.launch { ioScope.launch {
@@ -154,8 +166,7 @@ object ProfileCache {
} }
fun mergeFromDmUser(user: User) { fun mergeFromDmUser(user: User) {
val existing = get(user.id) if (user.id <= 0) return
if (existing != null && !existing.isClientPreviewOnly) return
val incomingUsername = user.username.trim() val incomingUsername = user.username.trim()
if (incomingUsername.isEmpty()) return if (incomingUsername.isEmpty()) return
@@ -167,6 +178,28 @@ object ProfileCache {
user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername
} }
val existing = get(user.id)
if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy(
username = incomingUsername,
displayName = if (isDeleted) null else incomingDisplayName ?: existing.displayName,
profilePicture = if (isDeleted) {
null
} else {
user.profile_picture?.takeIf { it.isNotBlank() } ?: existing.profilePicture
},
online = user.online,
lastSeen = user.last_seen?.takeIf { it.isNotBlank() } ?: existing.lastSeen,
verified = user.verified ?: existing.verified,
verificationStatus = user.verificationStatus ?: existing.verificationStatus,
suspended = user.suspended ?: existing.suspended,
suspensionReason = user.suspensionReason ?: existing.suspensionReason,
deleted = isDeleted,
)
if (patched != existing) put(patched)
return
}
put( put(
UserProfile( UserProfile(
id = user.id, id = user.id,
@@ -272,6 +305,7 @@ object ProfileCache {
emptyMap() emptyMap()
} }
pruneUnusableClientPreviewsLocked() pruneUnusableClientPreviewsLocked()
bumpRevision()
} }
} }
} }
@@ -286,6 +320,7 @@ object ProfileCache {
emptyMap() emptyMap()
} }
pruneUnusableClientPreviewsLocked() pruneUnusableClientPreviewsLocked()
bumpRevision()
} }
} }
@@ -308,6 +343,7 @@ object ProfileCache {
persistMutex.withLock { persistMutex.withLock {
profiles = emptyMap() profiles = emptyMap()
loadedInstanceId = "" loadedInstanceId = ""
bumpRevision()
} }
} }
} }
@@ -8,10 +8,13 @@ import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.api.local.db.parseDmMessageContent import ru.fromchat.api.local.db.parseDmMessageContent
import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.local.messages.ActiveDmChatTracker import ru.fromchat.api.local.messages.ActiveDmChatTracker
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.websocket.types.DmDeletedData import ru.fromchat.api.schema.websocket.types.DmDeletedData
import ru.fromchat.ui.chat.utils.attachDmReplyReferences
import ru.fromchat.ui.chat.utils.resolveDmReplyToId
object DmInboundMessageProcessor { object DmInboundMessageProcessor {
suspend fun processNew(element: JsonElement) { suspend fun processNew(element: JsonElement) {
@@ -32,18 +35,28 @@ object DmInboundMessageProcessor {
val outcome = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() val outcome = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
val plaintext = outcome ?: "" val plaintext = outcome ?: ""
val isCorrupted = outcome == null val isCorrupted = outcome == null
val dec = parseDmMessageContent(plaintext)
val message = buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId) val message = buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId)
val existing = runCatching { MessageRepository.loadDmMessages(otherUserId) }
.getOrDefault(emptyList())
val replyId = resolveDmReplyToId(envelope, dec.replyToId)
?: existing.firstOrNull { it.client_message_id == envelope.clientMessageId?.trim() }
?.reply_to?.id?.takeIf { it > 0 }
val hydrated = attachDmReplyReferences(
existing.filter { it.id != message.id } + message,
replyId?.let { mapOf(message.id to it) } ?: emptyMap(),
).last()
if (envelope.senderId == currentUserId) { if (envelope.senderId == currentUserId) {
val clientId = envelope.clientMessageId?.trim().orEmpty() val clientId = envelope.clientMessageId?.trim().orEmpty()
if (clientId.isNotEmpty()) { if (clientId.isNotEmpty()) {
MessageRepository.confirmDmMessage(otherUserId, clientId, message) MessageRepository.confirmDmMessage(otherUserId, clientId, hydrated)
} else { } else {
MessageRepository.upsertDmMessage(otherUserId, message) MessageRepository.upsertDmMessage(otherUserId, hydrated)
} }
} else { } else {
val isRead = ActiveDmChatTracker.isActive(otherUserId) val isRead = ActiveDmChatTracker.isActive(otherUserId)
val inbound = message.copy(is_read = isRead) val inbound = hydrated.copy(is_read = isRead)
MessageRepository.upsertDmMessage(otherUserId, inbound) MessageRepository.upsertDmMessage(otherUserId, inbound)
} }
} }
@@ -82,26 +95,32 @@ object DmInboundMessageProcessor {
} }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val existing = runCatching { MessageRepository.loadDmMessages(otherUserId) } val existingMessages = runCatching { MessageRepository.loadDmMessages(otherUserId) }
.getOrDefault(emptyList()) .getOrDefault(emptyList())
.find { it.id == envelope.id } val existing = existingMessages.find { it.id == envelope.id }
val outcome = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() val outcome = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
val plaintext = outcome ?: "" val plaintext = outcome ?: ""
val isCorrupted = outcome == null val isCorrupted = outcome == null
val dec = parseDmMessageContent(plaintext) val dec = parseDmMessageContent(plaintext)
val updated = (existing ?: buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId)) val base = existing ?: buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId)
.copy( val updated = base.copy(
content = dec.text, content = dec.text,
is_edited = true, is_edited = true,
files = envelope.files, files = envelope.files,
dmEnvelope = envelope, dmEnvelope = envelope,
fileThumbnails = dec.fileThumbnails ?: existing?.fileThumbnails, fileThumbnails = dec.fileThumbnails ?: existing?.fileThumbnails,
fileAspectRatios = dec.fileAspectRatios ?: existing?.fileAspectRatios, fileAspectRatios = dec.fileAspectRatios ?: existing?.fileAspectRatios,
fileSizes = dec.fileSizes ?: existing?.fileSizes, fileSizes = dec.fileSizes ?: existing?.fileSizes,
fileDimensions = dec.fileDimensions ?: existing?.fileDimensions, fileDimensions = dec.fileDimensions ?: existing?.fileDimensions,
isContentCorrupted = isCorrupted, isContentCorrupted = isCorrupted,
) )
MessageRepository.upsertDmMessage(otherUserId, updated) val replyId = resolveDmReplyToId(envelope, dec.replyToId)
?: existing?.reply_to?.id?.takeIf { it > 0 }
val hydrated = attachDmReplyReferences(
existingMessages.filter { it.id != updated.id } + updated,
replyId?.let { mapOf(updated.id to it) } ?: emptyMap(),
).last()
MessageRepository.upsertDmMessage(otherUserId, hydrated)
} }
} }
@@ -113,14 +132,18 @@ object DmInboundMessageProcessor {
otherUserId: Int, otherUserId: Int,
): Message { ): Message {
val dec = parseDmMessageContent(plaintext) val dec = parseDmMessageContent(plaintext)
val cached = ProfileCache.get(otherUserId) if (envelope.senderId != currentUserId) {
envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }?.let { senderName ->
ProfileCache.mergePreview(id = envelope.senderId, username = senderName)
}
}
val senderProfile = ProfileCache.get(envelope.senderId)
val username = if (envelope.senderId == currentUserId) { val username = if (envelope.senderId == currentUserId) {
"You" "You"
} else { } else {
cached?.displayName?.takeIf { it.isNotBlank() } senderProfile?.visibleDisplayName(currentUserId)?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() }
?: envelope.senderUsername?.takeIf { it.isNotBlank() } ?: envelope.senderUsername?.takeIf { it.isNotBlank() }
?: "User $otherUserId" ?: ""
} }
return Message( return Message(
id = envelope.id, id = envelope.id,
@@ -26,6 +26,7 @@ import ru.fromchat.api.local.send.outboundFailureErrorKey
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.SendDmFile import ru.fromchat.api.schema.messages.dm.SendDmFile
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.buildDmOutboundPlaintext
import ru.fromchat.api.local.cache.clearUploadArtifacts import ru.fromchat.api.local.cache.clearUploadArtifacts
import ru.fromchat.api.local.cache.clearUploadSecretsOnly import ru.fromchat.api.local.cache.clearUploadSecretsOnly
import ru.fromchat.api.local.AttachmentMediaLog import ru.fromchat.api.local.AttachmentMediaLog
@@ -179,10 +180,11 @@ object OutgoingMessageCoordinator {
val conversationId = conversationIdForDm(recipientId) val conversationId = conversationIdForDm(recipientId)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.upsertDmMessage(recipientId, optimisticMessage) MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId)
val payload = json.encodeToString( val payload = json.encodeToString(
DmOutboxPayload( DmOutboxPayload(
recipientId = recipientId, recipientId = recipientId,
plaintext = plaintext, plaintext = outboundPlaintext,
clientMessageId = clientMessageId, clientMessageId = clientMessageId,
replyToId = replyToId, replyToId = replyToId,
transportFiles = transportFiles, transportFiles = transportFiles,
@@ -226,10 +228,11 @@ object OutgoingMessageCoordinator {
) )
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageRepository.upsertDmMessage(recipientId, optimisticMessage) MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId)
val payload = json.encodeToString( val payload = json.encodeToString(
DmAttachmentOutboxPayload( DmAttachmentOutboxPayload(
recipientId = recipientId, recipientId = recipientId,
plaintext = plaintext, plaintext = outboundPlaintext,
clientMessageId = clientMessageId, clientMessageId = clientMessageId,
replyToId = replyToId, replyToId = replyToId,
fileUri = fileUri, fileUri = fileUri,
@@ -47,5 +47,7 @@ data class Message(
/** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */ /** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */
@Transient val fileDimensions: List<Pair<Int, Int>>? = null, @Transient val fileDimensions: List<Pair<Int, Int>>? = null,
/** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */ /** True when DM plaintext could not be decrypted and [content] shows the corrupted placeholder. */
@Transient val isContentCorrupted: Boolean = false @Transient val isContentCorrupted: Boolean = false,
/** Reply target id from local DB when nested [reply_to] is not hydrated yet. */
@Transient val replyToId: Int? = null,
) )
@@ -67,6 +67,7 @@ import ru.fromchat.legal_document_cached_banner
import ru.fromchat.legal_document_load_error import ru.fromchat.legal_document_load_error
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.ActionButton import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
private data class PendingDocument( private data class PendingDocument(
@@ -108,6 +109,7 @@ fun DocumentScreen(
ToggleNavScrimEffect() ToggleNavScrimEffect()
ScreenSurface {
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -273,6 +275,7 @@ fun DocumentScreen(
) )
} }
} }
}
} }
} }
} }
@@ -358,6 +361,6 @@ private fun DocumentContent(
} }
} }
} }
} }
} }
} }
@@ -102,6 +102,7 @@ import ru.fromchat.ui.profile.EditProfileFocusField
import ru.fromchat.ui.profile.EditProfileScreen import ru.fromchat.ui.profile.EditProfileScreen
import ru.fromchat.ui.profile.ProfileRoutes import ru.fromchat.ui.profile.ProfileRoutes
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.NetworkConnectivity
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") } val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
@@ -401,8 +402,9 @@ fun App(
LocalSystemBarsVisibility provides rememberSystemBarsController() LocalSystemBarsVisibility provides rememberSystemBarsController()
) { ) {
if (startDestination != null) { if (startDestination != null) {
Box(Modifier.fillMaxSize()) { ScreenSurface {
NavHost( Box(Modifier.fillMaxSize()) {
NavHost(
navController = navController, navController = navController,
startDestination = startDestination!!, startDestination = startDestination!!,
enterTransition = { rootNavEnterTransition() }, enterTransition = { rootNavEnterTransition() },
@@ -691,6 +693,7 @@ fun App(
} }
CallOverlay(Modifier.fillMaxSize()) CallOverlay(Modifier.fillMaxSize())
}
} }
} }
} }
@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import ru.fromchat.config.Settings import ru.fromchat.config.Settings
import ru.fromchat.ui.components.googleSansMaterialTypography import ru.fromchat.ui.components.googleSansMaterialTypography
@@ -28,6 +29,9 @@ var theme by mutableStateOf(
@Composable @Composable
expect fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme expect fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme
@Composable
expect fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color)
@Composable @Composable
fun FromChatTheme( fun FromChatTheme(
darkTheme: Boolean = when (theme) { darkTheme: Boolean = when (theme) {
@@ -76,6 +80,8 @@ fun FromChatTheme(
val surfaceContainerHigh by animateColorAsState(colorScheme.surfaceContainerHigh) val surfaceContainerHigh by animateColorAsState(colorScheme.surfaceContainerHigh)
val surfaceContainerHighest by animateColorAsState(colorScheme.surfaceContainerHighest) val surfaceContainerHighest by animateColorAsState(colorScheme.surfaceContainerHighest)
ApplySystemBarTheme(darkTheme = darkTheme, surfaceColor = surface)
colorScheme = colorScheme.copy( colorScheme = colorScheme.copy(
primary = primary, primary = primary,
onPrimary = onPrimary, onPrimary = onPrimary,
@@ -42,7 +42,6 @@ import ru.fromchat.call_dismiss
import ru.fromchat.call_failed_title import ru.fromchat.call_failed_title
import ru.fromchat.call_incoming_subtitle import ru.fromchat.call_incoming_subtitle
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.user_fallback
@Composable @Composable
fun CallOverlay(modifier: Modifier = Modifier) { fun CallOverlay(modifier: Modifier = Modifier) {
@@ -96,7 +95,8 @@ fun CallOverlay(modifier: Modifier = Modifier) {
val title = val title =
cached?.displayNameForUi(me)?.takeIf { it.isNotBlank() } cached?.displayNameForUi(me)?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() } ?: cached?.username?.takeIf { it.isNotBlank() }
?: stringResource(Res.string.user_fallback, s.fromUserId) ?: s.fromUsername.takeIf { it.isNotBlank() }
?: ""
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -1090,7 +1090,7 @@ private fun AttachmentUploadFailedOverlay(
} }
val retryText = stringResource(Res.string.attachment_retry) val retryText = stringResource(Res.string.attachment_retry)
val retryCd = stringResource(Res.string.cd_attachment_upload_retry) val retryCd = stringResource(Res.string.cd_attachment_upload_retry)
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface val headlineColor = messageBubbleContentColor(isAuthor)
Box( Box(
modifier = modifier modifier = modifier
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)), .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)),
@@ -1125,7 +1125,7 @@ private fun AttachmentImageLoadFailedOverlay(
val failedText = stringResource(Res.string.attachment_image_load_failed) val failedText = stringResource(Res.string.attachment_image_load_failed)
val retryText = stringResource(Res.string.attachment_retry) val retryText = stringResource(Res.string.attachment_retry)
val retryCd = stringResource(Res.string.cd_attachment_retry) val retryCd = stringResource(Res.string.cd_attachment_retry)
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface val headlineColor = messageBubbleContentColor(isAuthor)
Box( Box(
modifier = modifier modifier = modifier
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)), .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)),
@@ -1194,12 +1194,8 @@ internal fun ExpressiveFileAttachmentRow(
onCancelProgress: (() -> Unit)? = null, onCancelProgress: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface val headlineColor = messageBubbleContentColor(isAuthor)
val supportingColor = if (isAuthor) { val supportingColor = messageBubbleSupportingContentColor(isAuthor)
Color.White.copy(alpha = 0.78f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val leadingSize = 48.dp val leadingSize = 48.dp
Row( Row(
modifier = modifier modifier = modifier
@@ -22,6 +22,7 @@ import ru.fromchat.ui.chat.utils.TypingHandler
import ru.fromchat.ui.chat.utils.TypingUser import ru.fromchat.ui.chat.utils.TypingUser
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences
import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@@ -83,11 +84,14 @@ abstract class ChatPanel(
/** Merge SQLDelight rows with in-memory optimistic attachment UI (pending preview, thumbnails). */ /** Merge SQLDelight rows with in-memory optimistic attachment UI (pending preview, thumbnails). */
suspend fun syncMessagesFromDatabase(messages: List<Message>) { suspend fun syncMessagesFromDatabase(messages: List<Message>) {
batchStateUpdates { addMessageMutex.withLock {
updateState { current -> batchStateUpdates {
val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages) updateState { current ->
if (current.messages == merged) current val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages)
else current.copy(messages = merged) val withReplies = attachPublicReplyReferences(merged)
if (current.messages == withReplies) current
else current.copy(messages = withReplies)
}
} }
} }
} }
@@ -360,7 +364,9 @@ abstract class ChatPanel(
mapped + resolvedConfirmed mapped + resolvedConfirmed
else -> mapped else -> mapped
} }
currentState.copy(messages = sortMessagesForChatDisplay(messages)) currentState.copy(
messages = sortMessagesForChatDisplay(attachPublicReplyReferences(messages)),
)
} }
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val resolved = _state.messages.find { it.client_message_id == tempId } val resolved = _state.messages.find { it.client_message_id == tempId }
@@ -435,7 +441,11 @@ abstract class ChatPanel(
* Send message with immediate display (optimistic update) * Send message with immediate display (optimistic update)
*/ */
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
suspend fun sendMessageWithImmediateDisplay(content: String, replyToId: Int?) { suspend fun sendMessageWithImmediateDisplay(
content: String,
replyToId: Int?,
replyTo: Message? = null,
) {
if (content.isBlank()) return if (content.isBlank()) return
val sendT0 = kotlin.time.Clock.System.now().toEpochMilliseconds() val sendT0 = kotlin.time.Clock.System.now().toEpochMilliseconds()
@@ -446,6 +456,10 @@ abstract class ChatPanel(
// Show the bubble immediately; pace only the network send below. // Show the bubble immediately; pace only the network send below.
val tempId = generateClientMessageId() val tempId = generateClientMessageId()
val resolvedReply = replyTo?.takeIf { it.id > 0 }
?: replyToId?.takeIf { it > 0 }?.let { replyId ->
_state.messages.find { it.id == replyId }
}
val tempMessage = Message( val tempMessage = Message(
id = -1, // Temporary negative ID id = -1, // Temporary negative ID
user_id = currentUserId ?: -1, user_id = currentUserId ?: -1,
@@ -455,9 +469,8 @@ abstract class ChatPanel(
is_edited = false, is_edited = false,
username = "You", username = "You",
client_message_id = tempId, client_message_id = tempId,
reply_to = replyToId?.let { replyId -> reply_to = resolvedReply,
_state.messages.find { it.id == replyId } replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 },
}
) )
// Unique negative id avoids duplicate LazyColumn keys and bad merge logic. // Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
@@ -754,7 +754,7 @@ fun ChatScreen(
} }
} else { } else {
scope.launch { scope.launch {
val replyToId = replyTo?.id val replyToId = replyTo?.id?.takeIf { it > 0 }
val recipientId = panel.getRecipientId() val recipientId = panel.getRecipientId()
if (attachments.isNotEmpty() && recipientId != null) { if (attachments.isNotEmpty() && recipientId != null) {
val plaintext = text.ifBlank { "" } val plaintext = text.ifBlank { "" }
@@ -842,7 +842,7 @@ fun ChatScreen(
} }
} }
} else if (text.isNotBlank()) { } else if (text.isNotBlank()) {
panel.sendMessageWithImmediateDisplay(text, replyToId) panel.sendMessageWithImmediateDisplay(text, replyToId, replyTo)
} }
replyTo = null replyTo = null
haptic(HapticFeedbackEvent.MessageSent) haptic(HapticFeedbackEvent.MessageSent)
@@ -14,8 +14,6 @@ import ru.fromchat.ui.profile.isDeletedAccount
import ru.fromchat.ui.profile.isDeletedAccountUsername import ru.fromchat.ui.profile.isDeletedAccountUsername
import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.peerIsDeleted
private val userIdUsernamePattern = Regex("^User (\\d+)$")
/** /**
* Resolves [Message.username] for display: localized «Вы», deleted user label, or server-provided name. * Resolves [Message.username] for display: localized «Вы», deleted user label, or server-provided name.
*/ */
@@ -37,11 +35,6 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
if (message.username.equals("deleted", ignoreCase = true)) { if (message.username.equals("deleted", ignoreCase = true)) {
return deletedUserDisplayNameForUi() return deletedUserDisplayNameForUi()
} }
val m = userIdUsernamePattern.matchEntire(message.username)
if (m != null) {
val id = m.groupValues[1].toIntOrNull()
if (id != null) return deletedUserDisplayNameForUi()
}
return message.username return message.username
} }
@@ -180,8 +180,7 @@ fun MessageItem(
var isPressed by remember { mutableStateOf(false) } var isPressed by remember { mutableStateOf(false) }
var avatarPressed by remember(message.id) { mutableStateOf(false) } var avatarPressed by remember(message.id) { mutableStateOf(false) }
var replyPressed by remember(message.id) { mutableStateOf(false) } var replyPressed by remember(message.id) { mutableStateOf(false) }
var bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) } var rowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
var slackRowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f
val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f
val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f
@@ -375,9 +374,33 @@ fun MessageItem(
) )
} }
val rowLongPress =
if (isContextMenuOpen) Modifier
else Modifier.pointerInput(isContextMenuOpen, message.id) {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
}
},
onLongPress = { localOffset ->
val coords = rowLayoutCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset))
}
onLongPress()
},
)
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.onGloballyPositioned { rowLayoutCoords = it }
.then(rowLongPress)
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.enterLayoutHeight( .enterLayoutHeight(
scale = enterScale.value, scale = enterScale.value,
@@ -467,13 +490,9 @@ fun MessageItem(
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
) )
val bubblePressAndLongPress = val bubbleTap =
if (isContextMenuOpen) Modifier if (isContextMenuOpen || onBubbleTap == null) Modifier
else Modifier.pointerInput( else Modifier.pointerInput(message.id, onBubbleTap) {
isContextMenuOpen,
message.id,
onBubbleTap,
) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
isPressed = true isPressed = true
@@ -483,48 +502,11 @@ fun MessageItem(
isPressed = false isPressed = false
} }
}, },
onTap = { onBubbleTap?.invoke() }, onTap = { onBubbleTap.invoke() },
onLongPress = { localOffset ->
onTapPosition(bubbleBodyPositionInRoot + localOffset)
onLongPress()
}
) )
} }
val slackRowPressAndLongPress = Box {
if (isContextMenuOpen) Modifier
else Modifier.pointerInput(
isContextMenuOpen,
message.id,
onBubbleTap,
) {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
}
},
onTap = { onBubbleTap?.invoke() },
onLongPress = { localOffset ->
val coords = slackRowLayoutCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset))
} else {
onTapPosition(bubbleBodyPositionInRoot + localOffset)
}
onLongPress()
}
)
}
Box(
modifier = Modifier
.onGloballyPositioned { slackRowLayoutCoords = it }
.then(slackRowPressAndLongPress)
) {
Column( Column(
horizontalAlignment = horizontalAlignment =
if (isAuthor) Alignment.End else Alignment.Start, if (isAuthor) Alignment.End else Alignment.Start,
@@ -618,13 +600,10 @@ fun MessageItem(
} }
} }
val gestureWidthModifier = Modifier.fillMaxWidth()
Box( Box(
modifier = gestureWidthModifier modifier = Modifier
.onGloballyPositioned { coordinates -> .fillMaxWidth()
bubbleBodyPositionInRoot = coordinates.positionInRoot() .then(bubbleTap)
}
.then(bubblePressAndLongPress)
) { ) {
Column { Column {
replyRef?.let { replyToMsg -> replyRef?.let { replyToMsg ->
@@ -1069,3 +1048,19 @@ private fun MessageReplyQuote(
} }
} }
} }
@Composable
internal fun messageBubbleContentColor(isAuthor: Boolean) =
if (isAuthor) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurface
}
@Composable
internal fun messageBubbleSupportingContentColor(isAuthor: Boolean) =
if (isAuthor) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.78f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
@@ -18,7 +18,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -41,6 +40,7 @@ import ru.fromchat.attachment_upload_failed
import ru.fromchat.attachment_upload_failed_too_large import ru.fromchat.attachment_upload_failed_too_large
import ru.fromchat.cd_attachment_upload_retry import ru.fromchat.cd_attachment_upload_retry
import ru.fromchat.ui.chat.ExpressiveFileAttachmentRow import ru.fromchat.ui.chat.ExpressiveFileAttachmentRow
import ru.fromchat.ui.chat.messageBubbleContentColor
import ru.fromchat.ui.chat.utils.showAttachmentOpenFailed import ru.fromchat.ui.chat.utils.showAttachmentOpenFailed
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
@@ -228,7 +228,7 @@ fun ChatFileAttachmentTile(
} }
val retryText = stringResource(Res.string.attachment_retry) val retryText = stringResource(Res.string.attachment_retry)
val retryCd = stringResource(Res.string.cd_attachment_upload_retry) val retryCd = stringResource(Res.string.cd_attachment_upload_retry)
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface val headlineColor = messageBubbleContentColor(isAuthor)
Box(modifier = modifier.widthIn(max = 280.dp)) { Box(modifier = modifier.widthIn(max = 280.dp)) {
ExpressiveFileAttachmentRow( ExpressiveFileAttachmentRow(
@@ -59,13 +59,14 @@ internal fun FileAttachmentLeadingSlot(
isDownloaded -> FileLeadingVisual.File isDownloaded -> FileLeadingVisual.File
else -> FileLeadingVisual.Download else -> FileLeadingVisual.Download
} }
val onPrimary = MaterialTheme.colorScheme.onPrimary
val containerColor = if (isAuthor) { val containerColor = if (isAuthor) {
Color.White.copy(alpha = 0.22f) onPrimary.copy(alpha = 0.22f)
} else { } else {
MaterialTheme.colorScheme.secondaryContainer MaterialTheme.colorScheme.secondaryContainer
} }
val iconOnContainer = if (isAuthor) { val iconOnContainer = if (isAuthor) {
Color.White onPrimary
} else { } else {
MaterialTheme.colorScheme.onSecondaryContainer MaterialTheme.colorScheme.onSecondaryContainer
} }
@@ -83,9 +84,9 @@ internal fun FileAttachmentLeadingSlot(
onCancel = onCancelProgress, onCancel = onCancelProgress,
showCloseScrim = false, showCloseScrim = false,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
indicatorColor = if (isAuthor) Color.White else null, indicatorColor = if (isAuthor) onPrimary else null,
trackColorOverride = if (isAuthor) { trackColorOverride = if (isAuthor) {
Color.White.copy(alpha = 0.28f) onPrimary.copy(alpha = 0.28f)
} else { } else {
null null
}, },
@@ -43,10 +43,12 @@ import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.ui.chat.utils.DmTypingHandler import ru.fromchat.ui.chat.utils.DmTypingHandler
import ru.fromchat.api.local.download.DownloadedFileRegistry import ru.fromchat.api.local.download.DownloadedFileRegistry
import ru.fromchat.ui.chat.utils.TypingHandler import ru.fromchat.ui.chat.utils.TypingHandler
import ru.fromchat.ui.chat.utils.attachDmReplyReferences
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting
import ru.fromchat.ui.chat.utils.resolveDmReplyToId
import ru.fromchat.ui.chat.isImageFilename import ru.fromchat.ui.chat.isImageFilename
import ru.fromchat.api.local.send.seedOutboundFileAsDownloaded import ru.fromchat.api.local.send.seedOutboundFileAsDownloaded
@@ -246,21 +248,18 @@ class DmPanel(
val priorMessages = _state.messages val priorMessages = _state.messages
val optimisticSnapshot = snapshotPendingOptimisticMessages() val optimisticSnapshot = snapshotPendingOptimisticMessages()
val decryptedForLog = mutableListOf<Pair<Int, String>>() val decryptedForLog = mutableListOf<Pair<Int, String>>()
val parsedReplyIds = mutableMapOf<Int, Int>()
val messages = response.messages.map { envelope -> val messages = response.messages.map { envelope ->
val outcome = decryptDmEnvelopeForUi(envelope) val outcome = decryptDmEnvelopeForUi(envelope)
decryptedForLog.add(envelope.id to outcome.plaintext) decryptedForLog.add(envelope.id to outcome.plaintext)
val dec = parseDmMessageContent(outcome.plaintext)
resolveDmReplyToId(envelope, dec.replyToId)?.let { parsedReplyIds[envelope.id] = it }
createMessage(envelope, outcome.plaintext, outcome.isCorrupted) createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
} }
decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) -> decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) ->
Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json") Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json")
} }
val replyToMap = messages.associateBy { it.id } val messagesWithReplies = attachDmReplyReferences(messages, parsedReplyIds)
val messagesWithReplies = messages.map { msg ->
val envelope = response.messages.find { it.id == msg.id }
if (envelope?.replyToId != null) {
msg.copy(reply_to = replyToMap[envelope.replyToId])
} else msg
}
val mergedForUi = preserveReplyToFromExisting( val mergedForUi = preserveReplyToFromExisting(
priorMessages + optimisticSnapshot, priorMessages + optimisticSnapshot,
messagesWithReplies, messagesWithReplies,
@@ -368,11 +367,12 @@ class DmPanel(
if (envelope.senderId == currentUserId) { if (envelope.senderId == currentUserId) {
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
} else { } else {
val dec = parseDmMessageContent(outcome.plaintext)
val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted) val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
val replyTo = envelope.replyToId?.let { replyId -> val replyId = resolveDmReplyToId(envelope, dec.replyToId)
_state.messages.find { it.id == replyId } val context = _state.messages + incoming
} val withReply = attachDmReplyReferences(context, replyId?.let { mapOf(incoming.id to it) } ?: emptyMap())
val withReply = if (replyTo != null) incoming.copy(reply_to = replyTo) else incoming .last()
if (ActiveDmChatTracker.isActive(otherUserId)) { if (ActiveDmChatTracker.isActive(otherUserId)) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.upsertDmMessage(otherUserId, withReply) MessageCacheStore.upsertDmMessage(otherUserId, withReply)
@@ -446,16 +446,20 @@ class DmPanel(
pendingFileAspectRatio = aspect, pendingFileAspectRatio = aspect,
fileAspectRatios = confirmed.fileAspectRatios ?: aspect?.let { listOf(it) }, fileAspectRatios = confirmed.fileAspectRatios ?: aspect?.let { listOf(it) },
fileDimensions = confirmed.fileDimensions ?: stateSourceBeforeMerge?.fileDimensions, fileDimensions = confirmed.fileDimensions ?: stateSourceBeforeMerge?.fileDimensions,
reply_to = envelope.replyToId?.let { replyId ->
_state.messages.find { it.id == replyId }
} ?: stateSourceBeforeMerge?.reply_to,
) )
val mergedForPersistence = merged.copy(pendingFilename = null) val dec = parseDmMessageContent(plaintext)
val replyId = resolveDmReplyToId(envelope, dec.replyToId)
?: stateSourceBeforeMerge?.reply_to?.id?.takeIf { it > 0 }
val mergedWithReply = attachDmReplyReferences(
_state.messages.filter { it.id != envelope.id && it.client_message_id != cid } + merged,
replyId?.let { mapOf(merged.id to it) } ?: emptyMap(),
).last()
val mergedForPersistence = mergedWithReply.copy(pendingFilename = null)
AttachmentMediaLog.persist( AttachmentMediaLog.persist(
"merge_confirmed", "merge_confirmed",
"msgId" to envelope.id, "msgId" to envelope.id,
"clientId" to cid, "clientId" to cid,
"localPreview" to (merged.pendingFileUri?.take(64) ?: "null"), "localPreview" to (mergedWithReply.pendingFileUri?.take(64) ?: "null"),
"aspect" to aspect, "aspect" to aspect,
) )
@@ -471,7 +475,7 @@ class DmPanel(
optimisticIndex >= 0 -> { optimisticIndex >= 0 -> {
currentState.messages.mapIndexedNotNull { index, message -> currentState.messages.mapIndexedNotNull { index, message ->
when { when {
index == optimisticIndex -> merged index == optimisticIndex -> mergedWithReply
message.id == envelope.id -> null message.id == envelope.id -> null
else -> message else -> message
} }
@@ -479,10 +483,10 @@ class DmPanel(
} }
existingRealIndex >= 0 -> { existingRealIndex >= 0 -> {
currentState.messages.mapIndexed { index, message -> currentState.messages.mapIndexed { index, message ->
if (index == existingRealIndex) merged else message if (index == existingRealIndex) mergedWithReply else message
} }
} }
else -> currentState.messages + merged else -> currentState.messages + mergedWithReply
} }
val deduped = dedupeMessagesByClientId(newMessages) val deduped = dedupeMessagesByClientId(newMessages)
currentState.copy(messages = deduped) currentState.copy(messages = deduped)
@@ -540,7 +544,7 @@ class DmPanel(
val username = if (envelope.senderId == currentUserId) { val username = if (envelope.senderId == currentUserId) {
"You" "You"
} else { } else {
otherDisplayName.ifBlank { "User $otherUserId" } otherDisplayName
} }
return Message( return Message(
id = envelope.id, id = envelope.id,
@@ -31,6 +31,8 @@ import ru.fromchat.ui.chat.AvatarInfo
import ru.fromchat.ui.chat.ChatPanel import ru.fromchat.ui.chat.ChatPanel
import ru.fromchat.ui.chat.utils.PublicChatTypingHandler import ru.fromchat.ui.chat.utils.PublicChatTypingHandler
import ru.fromchat.ui.chat.utils.TypingHandler import ru.fromchat.ui.chat.utils.TypingHandler
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences
import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState
import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting
class PublicChatPanel( class PublicChatPanel(
@@ -160,23 +162,31 @@ class PublicChatPanel(
private suspend fun hydrateMessagesFromLocalCache() { private suspend fun hydrateMessagesFromLocalCache() {
val cached = withContext(Dispatchers.Default) { val cached = withContext(Dispatchers.Default) {
runCatching { MessageRepository.loadRecentPublicMessagesImmediate(limit = 128) } runCatching { MessageRepository.loadPublicMessages() }
.getOrDefault(emptyList()) .getOrDefault(emptyList())
} }
if (cached.isEmpty()) return if (cached.isEmpty() && _state.messages.isEmpty()) return
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
batchStateUpdates { batchStateUpdates {
val shown = _state.messages val shown = _state.messages
if (shown.isNotEmpty()) { when {
val withReplies = preserveReplyToFromExisting(shown, cached) shown.isEmpty() -> {
if (withReplies != shown) { if (cached.isNotEmpty()) {
updateState { it.copy(messages = sortMessagesForChatDisplay(withReplies)) } clearMessages()
addMessages(cached)
}
setLoading(false)
}
cached.isEmpty() -> setLoading(false)
else -> {
// Never replace the in-memory list with the DB snapshot alone — that
// dropped paginated / ahead-of-network rows when reopening (e.g. profile).
val merged = mergeDatabaseMessagesWithPanelState(shown, cached)
if (merged != shown) {
updateState { it.copy(messages = sortMessagesForChatDisplay(merged)) }
}
setLoading(false)
} }
setLoading(false)
} else {
clearMessages()
addMessages(cached)
setLoading(false)
} }
} }
} }
@@ -219,10 +229,11 @@ class PublicChatPanel(
private suspend fun ingestIncomingPublicMessage(newMsg: Message) { private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
ProfileCache.mergePreviewFromPublicMessage(newMsg) ProfileCache.mergePreviewFromPublicMessage(newMsg)
val displayMessage = ProfileCache.enrichPublicMessageForDisplay(newMsg) val withReply = attachPublicReplyReferences(_state.messages + newMsg).last()
val displayMessage = ProfileCache.enrichPublicMessageForDisplay(withReply)
addMessage(displayMessage) addMessage(displayMessage)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.upsertPublicMessage(newMsg) MessageCacheStore.upsertPublicMessage(withReply)
} }
} }
@@ -365,6 +376,9 @@ class PublicChatPanel(
messages = older + currentState.messages messages = older + currentState.messages
) )
} }
withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages)
}
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
} catch (_: Exception) { } catch (_: Exception) {
@@ -5,6 +5,44 @@ import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.aspectRatioFromDimensionPair import ru.fromchat.api.local.db.aspectRatioFromDimensionPair
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope
internal fun resolveDmReplyToId(
envelope: DmEnvelope?,
parsedReplyToId: Int?,
): Int? = envelope?.replyToId?.takeIf { it > 0 }
?: parsedReplyToId?.takeIf { it > 0 }
internal fun resolvePublicReplyToId(message: Message): Int? =
message.replyToId?.takeIf { it > 0 }
?: message.reply_to?.id?.takeIf { it > 0 }
/** Resolves [Message.reply_to] from in-chat siblings when the payload is missing or stub-only. */
internal fun attachPublicReplyReferences(
messages: List<Message>,
parsedReplyIds: Map<Int, Int> = emptyMap(),
): List<Message> {
val byId = messages.associateBy { it.id }
return messages.map { msg ->
val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg
val nested = msg.reply_to
if (nested != null && nested.content.isNotBlank()) return@map msg
byId[replyId]?.let { msg.copy(reply_to = it, replyToId = replyId) } ?: msg
}
}
/** Resolves [Message.reply_to] from envelope metadata and/or parsed reply ids. */
internal fun attachDmReplyReferences(
messages: List<Message>,
parsedReplyIds: Map<Int, Int> = emptyMap(),
): List<Message> {
val byId = messages.associateBy { it.id }
return messages.map { msg ->
if (msg.reply_to != null) return@map msg
val replyId = resolveDmReplyToId(msg.dmEnvelope, parsedReplyIds[msg.id]) ?: return@map msg
byId[replyId]?.let { msg.copy(reply_to = it) } ?: msg
}
}
/** /**
* SQLDelight rows omit optimistic attachment fields; merge DB snapshot with in-memory UI state. * SQLDelight rows omit optimistic attachment fields; merge DB snapshot with in-memory UI state.
@@ -70,6 +108,7 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
fileDimensions = db.fileDimensions ?: panel.fileDimensions, fileDimensions = db.fileDimensions ?: panel.fileDimensions,
content = db.content.ifBlank { panel.content }, content = db.content.ifBlank { panel.content },
isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted, isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted,
replyToId = db.replyToId ?: panel.replyToId,
reply_to = db.reply_to ?: panel.reply_to, reply_to = db.reply_to ?: panel.reply_to,
) )
} }
@@ -0,0 +1,28 @@
package ru.fromchat.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
/**
* Full-bleed themed background for screens behind edge-to-edge system bars.
* Paint this at the root; apply [WindowInsets] padding on inner content, not on this layer.
*/
@Composable
fun ScreenSurface(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.background,
content: @Composable BoxScope.() -> Unit,
) {
Box(
modifier = modifier
.fillMaxSize()
.background(color),
content = content,
)
}
@@ -90,7 +90,6 @@ import ru.fromchat.unread_count_overflow
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
import ru.fromchat.ui.profile.displayNameForUi import ru.fromchat.ui.profile.displayNameForUi
import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.user_fallback
internal object ChatListLayout { internal object ChatListLayout {
private const val CATEGORY_TOP_SPACER = 0 private const val CATEGORY_TOP_SPACER = 0
@@ -873,7 +872,7 @@ internal fun DmConversationRowContent(
isPeerDeleted -> deletedUserDisplayNameForUi() isPeerDeleted -> deletedUserDisplayNameForUi()
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim() !cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
conversation.displayName.isNotBlank() -> conversation.displayName conversation.displayName.isNotBlank() -> conversation.displayName
else -> stringResource(Res.string.user_fallback, conversation.otherUserId) else -> cached?.visibleUsername(currentUserId).orEmpty()
} }
val avatarInitialsLabel = when { val avatarInitialsLabel = when {
isPeerDeleted -> deletedUserDisplayNameForUi() isPeerDeleted -> deletedUserDisplayNameForUi()
@@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.resetFocus import com.pr0gramm3r101.utils.resetFocus
@@ -76,6 +77,7 @@ import ru.fromchat.search_not_found_message
import ru.fromchat.search_title import ru.fromchat.search_title
import ru.fromchat.ui.components.SearchBar import ru.fromchat.ui.components.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement import ru.fromchat.ui.components.SearchBarSharedElement
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -229,12 +231,12 @@ fun ChatsSearchScreen(
} }
} }
Scaffold( ScreenSurface {
modifier = Modifier Scaffold(
.imePadding() modifier = Modifier.imePadding(),
.windowInsetsPadding(WindowInsets.safeDrawing), containerColor = Color.Transparent,
contentWindowInsets = WindowInsets.safeDrawing, contentWindowInsets = WindowInsets.safeDrawing,
topBar = { topBar = {
SearchBar( SearchBar(
query = searchText, query = searchText,
onQueryChange = { searchText = it }, onQueryChange = { searchText = it },
@@ -325,6 +327,7 @@ fun ChatsSearchScreen(
} }
} }
} }
}
} }
private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String) = private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String) =
@@ -51,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -86,6 +87,7 @@ import ru.fromchat.Res
import ru.fromchat.action_delete import ru.fromchat.action_delete
import ru.fromchat.action_mark_read import ru.fromchat.action_mark_read
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ChatListSync
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
@@ -289,6 +291,7 @@ fun ChatsTab(
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true) val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
val activeInstanceId by CacheContext.activeInstanceId.collectAsState() val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val profileCacheRevision by ProfileCache.revision.collectAsState()
var dmConversations by remember(activeInstanceId) { var dmConversations by remember(activeInstanceId) {
val cached = if (activeInstanceId.isBlank()) { val cached = if (activeInstanceId.isBlank()) {
emptyList() emptyList()
@@ -334,8 +337,11 @@ fun ChatsTab(
var showSuspendedSupportSheet by remember { mutableStateOf(false) } var showSuspendedSupportSheet by remember { mutableStateOf(false) }
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) { LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly, activeInstanceId) {
MessageCacheStore.listPreviewStrings = previewStrings MessageCacheStore.listPreviewStrings = previewStrings
if (activeInstanceId.isNotBlank()) {
runCatching { ChatListSync.syncFromNetwork() }
}
} }
SideEffect { SideEffect {
@@ -716,6 +722,7 @@ fun ChatsTab(
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
) )
} else { } else {
key(profileCacheRevision) {
ChatConversationsList( ChatConversationsList(
listState = tabListState, listState = tabListState,
listFilter = ChatListFilter.Active, listFilter = ChatListFilter.Active,
@@ -809,6 +816,7 @@ fun ChatsTab(
}, },
) )
} }
}
} }
} }
@@ -10,6 +10,7 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -108,6 +109,7 @@ import ru.fromchat.logging.LogShareCompression
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.BackHandler
import ru.fromchat.ui.components.PredictiveBackHandler import ru.fromchat.ui.components.PredictiveBackHandler
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring
import ru.fromchat.ui.main.chats.SelectionCheckmarkSlot import ru.fromchat.ui.main.chats.SelectionCheckmarkSlot
@@ -424,6 +426,7 @@ fun LogFilesScreen(
val listBottomInset = if (selectionBarVisible) 88.dp else 8.dp val listBottomInset = if (selectionBarVisible) 88.dp else 8.dp
val fileCategoryColor = MaterialTheme.colorScheme.surfaceContainer val fileCategoryColor = MaterialTheme.colorScheme.surfaceContainer
ScreenSurface {
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -442,7 +445,9 @@ fun LogFilesScreen(
topBar = { topBar = {
Box { Box {
TopAppBar( TopAppBar(
modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress }, modifier = Modifier
.graphicsLayer { alpha = 1f - selectionProgress }
.background(MaterialTheme.colorScheme.surfaceContainer),
navigationIcon = { navigationIcon = {
IconButton( IconButton(
onClick = { navController.navigateUp() }, onClick = { navController.navigateUp() },
@@ -493,7 +498,8 @@ fun LogFilesScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding)
.background(MaterialTheme.colorScheme.background),
) { ) {
DisableSelection { DisableSelection {
LazyColumn( LazyColumn(
@@ -626,6 +632,7 @@ fun LogFilesScreen(
} }
} }
} }
}
} }
@Composable @Composable
@@ -15,6 +15,7 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -197,6 +198,7 @@ import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.BackHandler
import ru.fromchat.ui.components.ExpressiveIconFrame import ru.fromchat.ui.components.ExpressiveIconFrame
import ru.fromchat.ui.components.PredictiveBackHandler import ru.fromchat.ui.components.PredictiveBackHandler
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring
import ru.fromchat.ui.main.chats.SelectionCheckmarkSlot import ru.fromchat.ui.main.chats.SelectionCheckmarkSlot
@@ -719,6 +721,7 @@ fun LogsScreen() {
ToggleNavScrimEffect() ToggleNavScrimEffect()
} }
ScreenSurface {
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -752,9 +755,11 @@ fun LogsScreen() {
topBar = { topBar = {
Box { Box {
TopAppBar( TopAppBar(
modifier = Modifier.graphicsLayer { modifier = Modifier
alpha = (1f - selectionProgress) * (1f - searchProgress) .graphicsLayer {
}, alpha = (1f - selectionProgress) * (1f - searchProgress)
}
.background(MaterialTheme.colorScheme.surfaceContainer),
navigationIcon = { navigationIcon = {
IconButton( IconButton(
onClick = { navController.navigateUp() }, onClick = { navController.navigateUp() },
@@ -976,12 +981,17 @@ fun LogsScreen() {
} }
}, },
) { innerPadding -> ) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(MaterialTheme.colorScheme.background),
) {
when { when {
displayEntries.isEmpty() -> { displayEntries.isEmpty() -> {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding)
.then(if (searchMode) Modifier.imePadding() else Modifier), .then(if (searchMode) Modifier.imePadding() else Modifier),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -998,7 +1008,6 @@ fun LogsScreen() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding)
.imePadding(), .imePadding(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -1052,13 +1061,14 @@ fun LogsScreen() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding)
.then(if (searchMode) Modifier.imePadding() else Modifier), .then(if (searchMode) Modifier.imePadding() else Modifier),
) { ) {
listContent() listContent()
} }
} }
} }
}
}
} }
} }
@@ -82,6 +82,7 @@ import ru.fromchat.ui.components.DisabledBringIntoViewSpec
import ru.fromchat.ui.components.FromChatSnackbarHost import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.HazeActionButton import ru.fromchat.ui.components.HazeActionButton
import ru.fromchat.ui.components.LazyListFocusScrollEffect import ru.fromchat.ui.components.LazyListFocusScrollEffect
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.expressiveStepFieldColors import ru.fromchat.ui.components.expressiveStepFieldColors
import ru.fromchat.ui.components.rememberLazyListFocusScrollState import ru.fromchat.ui.components.rememberLazyListFocusScrollState
@@ -320,6 +321,7 @@ fun EditProfileScreen(
} }
} }
ScreenSurface {
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.navigationBars, contentWindowInsets = WindowInsets.navigationBars,
@@ -499,4 +501,5 @@ fun EditProfileScreen(
} }
} }
} }
}
} }
@@ -160,6 +160,7 @@ import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.chat.TypingIndicator
import ru.fromchat.ui.components.FromChatSnackbarHost import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.ShimmerBox import ru.fromchat.ui.components.ShimmerBox
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.showReplacingSnackbar import ru.fromchat.ui.components.showReplacingSnackbar
@@ -296,6 +297,17 @@ fun ProfileScreen(
} }
val latestUi by rememberUpdatedState(state) val latestUi by rememberUpdatedState(state)
val profileCacheRevision by ProfileCache.revision.collectAsState()
val isOwnProfileLookup = targetUserId == null && targetUsername == null
LaunchedEffect(profileCacheRevision) {
if (!isOwnProfileLookup) return@LaunchedEffect
ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) {
state = latestUi.copy(profile = cached, isLoading = false, error = null)
}
}
}
val backStackEntry = navController.currentBackStackEntry val backStackEntry = navController.currentBackStackEntry
LaunchedEffect(backStackEntry, lookupKey) { LaunchedEffect(backStackEntry, lookupKey) {
@@ -307,6 +319,7 @@ fun ProfileScreen(
try { try {
val refreshed = ApiClient.getOwnProfile() val refreshed = ApiClient.getOwnProfile()
ProfileCache.put(refreshed) ProfileCache.put(refreshed)
ApiClient.applyOwnProfile(refreshed)
state = latestUi.copy(profile = refreshed, error = null) state = latestUi.copy(profile = refreshed, error = null)
} catch (_: Exception) { } catch (_: Exception) {
ownUserId?.let { ProfileCache.get(it) }?.let { cached -> ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
@@ -367,6 +380,9 @@ fun ProfileScreen(
) )
ProfileCache.put(profile) ProfileCache.put(profile)
if (targetUserId == null && targetUsername == null) {
ApiClient.applyOwnProfile(profile)
}
state = latestUi.copy(profile = profile, isLoading = false, error = null) state = latestUi.copy(profile = profile, isLoading = false, error = null)
loadedSuccessfully = true loadedSuccessfully = true
true true
@@ -604,21 +620,18 @@ fun ProfileScreen(
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
) )
Box( ScreenSurface(modifier = modifier) {
modifier = modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars),
) {
val useSharedAvatar = sharedTransitionScope != null && val useSharedAvatar = sharedTransitionScope != null &&
animatedVisibilityScope != null && animatedVisibilityScope != null &&
sharedAvatarKey != null sharedAvatarKey != null
Box( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier Box(
.fillMaxSize() modifier = Modifier
.background(MaterialTheme.colorScheme.background) .fillMaxSize()
.hazeSource(hazeState), .background(MaterialTheme.colorScheme.background)
) { .hazeSource(hazeState),
) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -764,10 +777,12 @@ fun ProfileScreen(
hostState = snackbarHostState, hostState = snackbarHostState,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 16.dp) .padding(bottom = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),
) )
}
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -890,21 +905,18 @@ fun PublicChatProfileScreen(
animatedVisibilityScope != null && animatedVisibilityScope != null &&
sharedAvatarKey != null sharedAvatarKey != null
Box( ScreenSurface(modifier = modifier) {
modifier = modifier Box(modifier = Modifier.fillMaxSize()) {
.fillMaxSize() Box(
.windowInsetsPadding(WindowInsets.navigationBars), modifier = Modifier
) { .fillMaxSize()
Box( .background(MaterialTheme.colorScheme.background)
modifier = Modifier .hazeSource(hazeState),
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when { when {
useSharedAvatar && displayName.isNotBlank() -> { useSharedAvatar && displayName.isNotBlank() -> {
item { item {
@@ -974,10 +986,12 @@ fun PublicChatProfileScreen(
hostState = snackbarHostState, hostState = snackbarHostState,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 16.dp) .padding(bottom = 16.dp)
.fillMaxWidth(), .fillMaxWidth(),
) )
}
} }
} }
@@ -284,7 +284,7 @@ WHERE instanceId = ? AND id = ?;
deleteEmptyDmConversations: deleteEmptyDmConversations:
DELETE FROM conversation DELETE FROM conversation
WHERE instanceId = ? AND type = 'dm' AND id NOT IN ( WHERE instanceId = ? AND type = 'dm' AND lastMessageId IS NULL AND id NOT IN (
SELECT DISTINCT conversationId FROM message SELECT DISTINCT conversationId FROM message
WHERE instanceId = ? AND deletedFlag = 0 WHERE instanceId = ? AND deletedFlag = 0
); );
@@ -3,7 +3,11 @@ package ru.fromchat.ui
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@Composable @Composable
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) = actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
if (darkTheme) darkColorScheme() else lightColorScheme() if (darkTheme) darkColorScheme() else lightColorScheme()
@Composable
actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) = Unit