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.R
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.buildChatListPreview
import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope
@@ -301,15 +303,13 @@ object NotificationHelper {
}
}
val senderName = if (
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank()
) {
dmSenderName
} else if (!envelope.senderUsername.isNullOrBlank()) {
envelope.senderUsername
} else {
"User ${envelope.senderId}"
}
val senderName = when {
envelopeId == dmMessageId && !dmSenderName.isNullOrBlank() -> dmSenderName
!envelope.senderUsername.isNullOrBlank() -> envelope.senderUsername
else -> ProfileCache.get(envelope.senderId)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
}.orEmpty()
val dmConversationUserId = envelope.senderId
val notificationBody = buildChatListPreviewFromEnvelope(
envelope = envelope,
@@ -319,7 +319,11 @@ object NotificationHelper {
showFallbackPushNotification(
context = context,
title = "Direct message from $senderName",
title = if (senderName.isNotBlank()) {
"Direct message from $senderName"
} else {
"Direct message"
},
body = notificationBody,
sender = senderName,
messageId = envelopeId,
@@ -1,12 +1,19 @@
package ru.fromchat.ui
import android.app.Activity
import android.graphics.Color as AndroidColor
import android.os.Build
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
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.LocalView
import androidx.core.view.WindowCompat
@Composable
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
@@ -16,4 +23,26 @@ actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
} else {
if (darkTheme) darkColorScheme()
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_text">Загрузка продолжается в фоне</string>
<string name="message_sender_you">Вы</string>
<string name="user_fallback">Человек %1$d</string>
<string name="deleted_account">Удалённый аккаунт</string>
<string name="account_suspended">Аккаунт заблокирован</string>
<string name="action_delete_chat">Удалить чат</string>
@@ -121,7 +121,6 @@
<!-- Messages -->
<string name="message_sender_you">You</string>
<string name="user_fallback">Person %1$d</string>
<string name="deleted_account">Deleted account</string>
<string name="account_suspended">Account suspended</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() {
_suspensionState.value = SuspensionState()
user = user?.copy(
@@ -51,11 +51,13 @@ object ChatListSync {
}
private suspend fun syncDmConversations() {
val previewStrings = MessageCacheStore.listPreviewStrings ?: return
runCatching {
val conversations = ApiClient.getDmConversations()
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.SupervisorJob
import kotlinx.coroutines.launch
import ru.fromchat.api.local.db.store.ProfileCache
/**
* Network work that must not block cold start or the first frame.
@@ -20,7 +21,8 @@ object DeferredStartupNetwork {
if (ApiClient.token.isNullOrEmpty()) return@launch
runCatching {
val profile = ApiClient.getOwnProfile()
ApiClient.syncSuspensionStateFromProfile(profile)
ApiClient.applyOwnProfile(profile)
ProfileCache.put(profile)
}
runCatching { syncPushTokenAfterStartup() }
}
@@ -54,7 +54,7 @@ object CallStore {
val me = ApiClient.user?.id
val p = ProfileCache.get(peerUserId)
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) {
@@ -16,6 +16,18 @@ import ru.fromchat.api.local.cache.DecryptedImageCache
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
private data class PersistedOptimisticOutboundPayload(
@SerialName("text") val text: String,
@@ -40,6 +52,8 @@ private data class PersistedDmMessagePayload(
data class ParsedDmMessageContent(
val text: String,
/** Reply target from encrypted JSON payload (`reply_to_id`), when present. */
val replyToId: Int? = null,
val envelope: DmEnvelope? = null,
val fileThumbnails: List<String>? = null,
val fileAspectRatios: List<Float>? = null,
@@ -52,6 +66,21 @@ data class ParsedDmMessageContent(
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. */
fun encodeOptimisticOutboundMessage(message: Message): String {
val pendingUri = message.pendingFileUri?.trim().orEmpty()
@@ -133,6 +162,17 @@ fun parseDmMessageContent(plaintext: String): ParsedDmMessageContent {
val trimmed = plaintext.trim()
if (trimmed.startsWith("{")) {
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) {
return runCatching {
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.DecryptedImageCache
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.dropSupersededOptimisticMessages
import kotlin.concurrent.Volatile
import ru.fromchat.db.Message as DbMessage
data class CachedConversation(
@@ -78,7 +80,7 @@ object MessageCacheStore {
.asFlow()
.mapToList(Dispatchers.Default)
.map { rows ->
val raw = hydrateReplyReferences(rows)
val raw = hydrateReplyReferencesFromRows(rows)
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
sortMessagesForChatDisplay(
validatedOrEmpty(
@@ -99,11 +101,10 @@ object MessageCacheStore {
fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> {
if (instanceId.isBlank()) return emptyList()
val convId = conversationIdForPublic()
val raw = hydrateReplyReferences(
db.messageDatabaseQueries
.selectRecentMessagesByConversation(instanceId, convId, limit)
.executeAsList(),
).reversed()
val rows = db.messageDatabaseQueries
.selectRecentMessagesByConversation(instanceId, convId, limit)
.executeAsList()
val raw = hydrateReplyReferencesFromRows(rows).reversed()
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
return ProfileCache.enrichPublicMessagesForDisplay(
sortMessagesForChatDisplay(
@@ -338,9 +339,9 @@ object MessageCacheStore {
suspend fun replaceDmConversations(
conversations: List<DmConversation>,
previewStrings: ChatListPreviewStrings,
previewStrings: ChatListPreviewStrings? = listPreviewStrings,
) {
listPreviewStrings = previewStrings
previewStrings?.let { listPreviewStrings = it }
val iid = instanceId()
val currentUserId = ApiClient.user?.id
withContext(Dispatchers.Default) {
@@ -408,10 +409,14 @@ object MessageCacheStore {
private suspend fun buildDmListPreview(
envelope: DmEnvelope,
currentUserId: Int?,
previewStrings: ChatListPreviewStrings,
previewStrings: ChatListPreviewStrings?,
): String? {
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() }
}
@@ -448,11 +453,25 @@ object MessageCacheStore {
val existing = db.messageDatabaseQueries
.selectConversationById(iid, convId)
.executeAsOneOrNull()
if (existing != null) return@withContext
val label = displayName?.trim()?.takeIf { it.isNotEmpty() }
?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: ProfileCache.get(otherUserId)?.username?.trim()?.takeIf { it.isNotEmpty() }
?: ""
val label = resolveDmConversationDisplayLabel(otherUserId, displayName)
if (existing != null) {
if (label.isNotEmpty() && existing.displayName.isNullOrBlank()) {
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(
instanceId = iid,
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) {
val iid = instanceId()
val convId = conversationIdForDm(otherUserId)
@@ -835,6 +859,11 @@ object MessageCacheStore {
val storedContent = encodePersistedDmMessage(confirmed)
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.transaction {
val existingReplyToId = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.firstOrNull { it.clientMessageId == clientMessageId }
?.replyToId
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
db.messageDatabaseQueries.upsertMessage(
instanceId = iid,
@@ -845,7 +874,7 @@ object MessageCacheStore {
timestamp = confirmed.timestamp,
isRead = if (confirmed.is_read) 1L else 0L,
isEdited = if (confirmed.is_edited) 1L else 0L,
replyToId = resolveReplyToIdForPersistence(confirmed),
replyToId = resolveReplyToIdForPersistence(confirmed, existingReplyToId),
clientMessageId = confirmed.client_message_id,
deletedFlag = 0L,
sendStatus = "sent"
@@ -865,7 +894,7 @@ object MessageCacheStore {
val rows = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
val raw = hydrateReplyReferences(rows)
val raw = hydrateReplyReferencesFromRows(rows)
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded)
sortMessagesForChatDisplay(
@@ -885,7 +914,7 @@ object MessageCacheStore {
val rows = db.messageDatabaseQueries
.selectRecentMessagesByConversation(iid, conversationId, limit)
.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> {
val messages = rows.map { it.toAppMessage() }
val byId = messages.associateBy { it.id }
return rows.zip(messages).map { (row, message) ->
val replyId = row.replyToId?.toInt() ?: message.dmEnvelope?.replyToId
if (replyId != null) {
message.copy(reply_to = byId[replyId])
message.copy(
replyToId = replyId,
reply_to = byId[replyId] ?: message.reply_to,
)
} else {
message
}
@@ -1015,6 +1054,7 @@ object MessageCacheStore {
private fun resolveReplyToIdForPersistence(msg: Message, existingReplyToId: Long? = null): Long? {
return msg.reply_to?.id?.toLong()
?: msg.replyToId?.toLong()
?: msg.dmEnvelope?.replyToId?.toLong()
?: existingReplyToId?.takeIf { it > 0L }
}
@@ -93,7 +93,7 @@ object MessageRepository {
suspend fun replaceDmConversations(
conversations: List<DmConversation>,
previewStrings: ChatListPreviewStrings,
previewStrings: ChatListPreviewStrings? = null,
) = MessageCacheStore.replaceDmConversations(conversations, previewStrings)
suspend fun loadCachedDmConversations(): List<CachedConversation> =
@@ -3,6 +3,9 @@ package ru.fromchat.api.local.db.store
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -45,9 +48,16 @@ object ProfileCache {
@Volatile
private var loadedInstanceId: String = ""
private val _revision = MutableStateFlow(0)
val revision: StateFlow<Int> = _revision.asStateFlow()
private val persistMutex = Mutex()
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private fun bumpRevision() {
_revision.value++
}
fun get(userId: Int): UserProfile? = profiles[userId]
fun findByUsername(username: String): UserProfile? =
@@ -125,6 +135,7 @@ object ProfileCache {
}
val cur = profiles
profiles = cur + (profile.id to profile)
bumpRevision()
val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) {
ioScope.launch {
@@ -137,6 +148,7 @@ object ProfileCache {
val cur = profiles
if (userId !in cur) return
profiles = cur - userId
bumpRevision()
val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) {
ioScope.launch {
@@ -154,8 +166,7 @@ object ProfileCache {
}
fun mergeFromDmUser(user: User) {
val existing = get(user.id)
if (existing != null && !existing.isClientPreviewOnly) return
if (user.id <= 0) return
val incomingUsername = user.username.trim()
if (incomingUsername.isEmpty()) return
@@ -167,6 +178,28 @@ object ProfileCache {
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(
UserProfile(
id = user.id,
@@ -272,6 +305,7 @@ object ProfileCache {
emptyMap()
}
pruneUnusableClientPreviewsLocked()
bumpRevision()
}
}
}
@@ -286,6 +320,7 @@ object ProfileCache {
emptyMap()
}
pruneUnusableClientPreviewsLocked()
bumpRevision()
}
}
@@ -308,6 +343,7 @@ object ProfileCache {
persistMutex.withLock {
profiles = emptyMap()
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.store.MessageRepository
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.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.websocket.types.DmDeletedData
import ru.fromchat.ui.chat.utils.attachDmReplyReferences
import ru.fromchat.ui.chat.utils.resolveDmReplyToId
object DmInboundMessageProcessor {
suspend fun processNew(element: JsonElement) {
@@ -32,18 +35,28 @@ object DmInboundMessageProcessor {
val outcome = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
val plaintext = outcome ?: ""
val isCorrupted = outcome == null
val dec = parseDmMessageContent(plaintext)
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) {
val clientId = envelope.clientMessageId?.trim().orEmpty()
if (clientId.isNotEmpty()) {
MessageRepository.confirmDmMessage(otherUserId, clientId, message)
MessageRepository.confirmDmMessage(otherUserId, clientId, hydrated)
} else {
MessageRepository.upsertDmMessage(otherUserId, message)
MessageRepository.upsertDmMessage(otherUserId, hydrated)
}
} else {
val isRead = ActiveDmChatTracker.isActive(otherUserId)
val inbound = message.copy(is_read = isRead)
val inbound = hydrated.copy(is_read = isRead)
MessageRepository.upsertDmMessage(otherUserId, inbound)
}
}
@@ -82,26 +95,32 @@ object DmInboundMessageProcessor {
}
withContext(Dispatchers.Default) {
val existing = runCatching { MessageRepository.loadDmMessages(otherUserId) }
val existingMessages = runCatching { MessageRepository.loadDmMessages(otherUserId) }
.getOrDefault(emptyList())
.find { it.id == envelope.id }
val existing = existingMessages.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)
val base = existing ?: buildMessage(envelope, plaintext, isCorrupted, currentUserId, otherUserId)
val updated = base.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,
)
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,
): Message {
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) {
"You"
} else {
cached?.displayName?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() }
senderProfile?.visibleDisplayName(currentUserId)?.takeIf { it.isNotBlank() }
?: envelope.senderUsername?.takeIf { it.isNotBlank() }
?: "User $otherUserId"
?: ""
}
return Message(
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.dm.SendDmFile
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.clearUploadSecretsOnly
import ru.fromchat.api.local.AttachmentMediaLog
@@ -179,10 +180,11 @@ object OutgoingMessageCoordinator {
val conversationId = conversationIdForDm(recipientId)
withContext(Dispatchers.Default) {
MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId)
val payload = json.encodeToString(
DmOutboxPayload(
recipientId = recipientId,
plaintext = plaintext,
plaintext = outboundPlaintext,
clientMessageId = clientMessageId,
replyToId = replyToId,
transportFiles = transportFiles,
@@ -226,10 +228,11 @@ object OutgoingMessageCoordinator {
)
withContext(Dispatchers.Default) {
MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId)
val payload = json.encodeToString(
DmAttachmentOutboxPayload(
recipientId = recipientId,
plaintext = plaintext,
plaintext = outboundPlaintext,
clientMessageId = clientMessageId,
replyToId = replyToId,
fileUri = fileUri,
@@ -47,5 +47,7 @@ data class Message(
/** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */
@Transient val fileDimensions: List<Pair<Int, Int>>? = null,
/** 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.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.Text
private data class PendingDocument(
@@ -108,6 +109,7 @@ fun DocumentScreen(
ToggleNavScrimEffect()
ScreenSurface {
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
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.ProfileRoutes
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.utils.NetworkConnectivity
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
@@ -401,8 +402,9 @@ fun App(
LocalSystemBarsVisibility provides rememberSystemBarsController()
) {
if (startDestination != null) {
Box(Modifier.fillMaxSize()) {
NavHost(
ScreenSurface {
Box(Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = startDestination!!,
enterTransition = { rootNavEnterTransition() },
@@ -691,6 +693,7 @@ fun App(
}
CallOverlay(Modifier.fillMaxSize())
}
}
}
}
@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import ru.fromchat.config.Settings
import ru.fromchat.ui.components.googleSansMaterialTypography
@@ -28,6 +29,9 @@ var theme by mutableStateOf(
@Composable
expect fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme
@Composable
expect fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color)
@Composable
fun FromChatTheme(
darkTheme: Boolean = when (theme) {
@@ -76,6 +80,8 @@ fun FromChatTheme(
val surfaceContainerHigh by animateColorAsState(colorScheme.surfaceContainerHigh)
val surfaceContainerHighest by animateColorAsState(colorScheme.surfaceContainerHighest)
ApplySystemBarTheme(darkTheme = darkTheme, surfaceColor = surface)
colorScheme = colorScheme.copy(
primary = primary,
onPrimary = onPrimary,
@@ -42,7 +42,6 @@ import ru.fromchat.call_dismiss
import ru.fromchat.call_failed_title
import ru.fromchat.call_incoming_subtitle
import ru.fromchat.ui.components.Text
import ru.fromchat.user_fallback
@Composable
fun CallOverlay(modifier: Modifier = Modifier) {
@@ -96,7 +95,8 @@ fun CallOverlay(modifier: Modifier = Modifier) {
val title =
cached?.displayNameForUi(me)?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() }
?: stringResource(Res.string.user_fallback, s.fromUserId)
?: s.fromUsername.takeIf { it.isNotBlank() }
?: ""
Box(
modifier = modifier
.fillMaxSize()
@@ -1090,7 +1090,7 @@ private fun AttachmentUploadFailedOverlay(
}
val retryText = stringResource(Res.string.attachment_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
.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 retryText = stringResource(Res.string.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(
modifier = modifier
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f)),
@@ -1194,12 +1194,8 @@ internal fun ExpressiveFileAttachmentRow(
onCancelProgress: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val headlineColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
val supportingColor = if (isAuthor) {
Color.White.copy(alpha = 0.78f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val headlineColor = messageBubbleContentColor(isAuthor)
val supportingColor = messageBubbleSupportingContentColor(isAuthor)
val leadingSize = 48.dp
Row(
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.dedupeMessagesByClientId
import ru.fromchat.ui.chat.utils.dropSupersededOptimisticMessages
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences
import ru.fromchat.ui.chat.utils.mergeDatabaseMessagesWithPanelState
import kotlin.time.ExperimentalTime
@@ -83,11 +84,14 @@ abstract class ChatPanel(
/** Merge SQLDelight rows with in-memory optimistic attachment UI (pending preview, thumbnails). */
suspend fun syncMessagesFromDatabase(messages: List<Message>) {
batchStateUpdates {
updateState { current ->
val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages)
if (current.messages == merged) current
else current.copy(messages = merged)
addMessageMutex.withLock {
batchStateUpdates {
updateState { current ->
val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages)
val withReplies = attachPublicReplyReferences(merged)
if (current.messages == withReplies) current
else current.copy(messages = withReplies)
}
}
}
}
@@ -360,7 +364,9 @@ abstract class ChatPanel(
mapped + resolvedConfirmed
else -> mapped
}
currentState.copy(messages = sortMessagesForChatDisplay(messages))
currentState.copy(
messages = sortMessagesForChatDisplay(attachPublicReplyReferences(messages)),
)
}
scope.launch(Dispatchers.Default) {
val resolved = _state.messages.find { it.client_message_id == tempId }
@@ -435,7 +441,11 @@ abstract class ChatPanel(
* Send message with immediate display (optimistic update)
*/
@OptIn(ExperimentalTime::class)
suspend fun sendMessageWithImmediateDisplay(content: String, replyToId: Int?) {
suspend fun sendMessageWithImmediateDisplay(
content: String,
replyToId: Int?,
replyTo: Message? = null,
) {
if (content.isBlank()) return
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.
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(
id = -1, // Temporary negative ID
user_id = currentUserId ?: -1,
@@ -455,9 +469,8 @@ abstract class ChatPanel(
is_edited = false,
username = "You",
client_message_id = tempId,
reply_to = replyToId?.let { replyId ->
_state.messages.find { it.id == replyId }
}
reply_to = resolvedReply,
replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 },
)
// Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
@@ -754,7 +754,7 @@ fun ChatScreen(
}
} else {
scope.launch {
val replyToId = replyTo?.id
val replyToId = replyTo?.id?.takeIf { it > 0 }
val recipientId = panel.getRecipientId()
if (attachments.isNotEmpty() && recipientId != null) {
val plaintext = text.ifBlank { "" }
@@ -842,7 +842,7 @@ fun ChatScreen(
}
}
} else if (text.isNotBlank()) {
panel.sendMessageWithImmediateDisplay(text, replyToId)
panel.sendMessageWithImmediateDisplay(text, replyToId, replyTo)
}
replyTo = null
haptic(HapticFeedbackEvent.MessageSent)
@@ -14,8 +14,6 @@ import ru.fromchat.ui.profile.isDeletedAccount
import ru.fromchat.ui.profile.isDeletedAccountUsername
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.
*/
@@ -37,11 +35,6 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
if (message.username.equals("deleted", ignoreCase = true)) {
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
}
@@ -180,8 +180,7 @@ fun MessageItem(
var isPressed by remember { mutableStateOf(false) }
var avatarPressed by remember(message.id) { mutableStateOf(false) }
var replyPressed by remember(message.id) { mutableStateOf(false) }
var bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) }
var slackRowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
var rowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f
val avatarScaleTarget = if (avatarPressed && !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(
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { rowLayoutCoords = it }
.then(rowLongPress)
.padding(horizontal = 8.dp)
.enterLayoutHeight(
scale = enterScale.value,
@@ -467,13 +490,9 @@ fun MessageItem(
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
)
val bubblePressAndLongPress =
if (isContextMenuOpen) Modifier
else Modifier.pointerInput(
isContextMenuOpen,
message.id,
onBubbleTap,
) {
val bubbleTap =
if (isContextMenuOpen || onBubbleTap == null) Modifier
else Modifier.pointerInput(message.id, onBubbleTap) {
detectTapGestures(
onPress = {
isPressed = true
@@ -483,48 +502,11 @@ fun MessageItem(
isPressed = false
}
},
onTap = { onBubbleTap?.invoke() },
onLongPress = { localOffset ->
onTapPosition(bubbleBodyPositionInRoot + localOffset)
onLongPress()
}
onTap = { onBubbleTap.invoke() },
)
}
val slackRowPressAndLongPress =
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)
) {
Box {
Column(
horizontalAlignment =
if (isAuthor) Alignment.End else Alignment.Start,
@@ -618,13 +600,10 @@ fun MessageItem(
}
}
val gestureWidthModifier = Modifier.fillMaxWidth()
Box(
modifier = gestureWidthModifier
.onGloballyPositioned { coordinates ->
bubbleBodyPositionInRoot = coordinates.positionInRoot()
}
.then(bubblePressAndLongPress)
modifier = Modifier
.fillMaxWidth()
.then(bubbleTap)
) {
Column {
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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.cd_attachment_upload_retry
import ru.fromchat.ui.chat.ExpressiveFileAttachmentRow
import ru.fromchat.ui.chat.messageBubbleContentColor
import ru.fromchat.ui.chat.utils.showAttachmentOpenFailed
import ru.fromchat.ui.components.Text
@@ -228,7 +228,7 @@ fun ChatFileAttachmentTile(
}
val retryText = stringResource(Res.string.attachment_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)) {
ExpressiveFileAttachmentRow(
@@ -59,13 +59,14 @@ internal fun FileAttachmentLeadingSlot(
isDownloaded -> FileLeadingVisual.File
else -> FileLeadingVisual.Download
}
val onPrimary = MaterialTheme.colorScheme.onPrimary
val containerColor = if (isAuthor) {
Color.White.copy(alpha = 0.22f)
onPrimary.copy(alpha = 0.22f)
} else {
MaterialTheme.colorScheme.secondaryContainer
}
val iconOnContainer = if (isAuthor) {
Color.White
onPrimary
} else {
MaterialTheme.colorScheme.onSecondaryContainer
}
@@ -83,9 +84,9 @@ internal fun FileAttachmentLeadingSlot(
onCancel = onCancelProgress,
showCloseScrim = false,
modifier = Modifier.fillMaxSize(),
indicatorColor = if (isAuthor) Color.White else null,
indicatorColor = if (isAuthor) onPrimary else null,
trackColorOverride = if (isAuthor) {
Color.White.copy(alpha = 0.28f)
onPrimary.copy(alpha = 0.28f)
} else {
null
},
@@ -43,10 +43,12 @@ import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.ui.chat.utils.DmTypingHandler
import ru.fromchat.api.local.download.DownloadedFileRegistry
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.dropSupersededOptimisticMessages
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
import ru.fromchat.ui.chat.utils.preserveReplyToFromExisting
import ru.fromchat.ui.chat.utils.resolveDmReplyToId
import ru.fromchat.ui.chat.isImageFilename
import ru.fromchat.api.local.send.seedOutboundFileAsDownloaded
@@ -246,21 +248,18 @@ class DmPanel(
val priorMessages = _state.messages
val optimisticSnapshot = snapshotPendingOptimisticMessages()
val decryptedForLog = mutableListOf<Pair<Int, String>>()
val parsedReplyIds = mutableMapOf<Int, Int>()
val messages = response.messages.map { envelope ->
val outcome = decryptDmEnvelopeForUi(envelope)
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)
}
decryptedForLog.takeLast(5).forEachIndexed { i, (id, json) ->
Logger.d("DmPanel", "Decrypted message #${i + 1} (id=$id): $json")
}
val replyToMap = messages.associateBy { it.id }
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 messagesWithReplies = attachDmReplyReferences(messages, parsedReplyIds)
val mergedForUi = preserveReplyToFromExisting(
priorMessages + optimisticSnapshot,
messagesWithReplies,
@@ -368,11 +367,12 @@ class DmPanel(
if (envelope.senderId == currentUserId) {
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
} else {
val dec = parseDmMessageContent(outcome.plaintext)
val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
val replyTo = envelope.replyToId?.let { replyId ->
_state.messages.find { it.id == replyId }
}
val withReply = if (replyTo != null) incoming.copy(reply_to = replyTo) else incoming
val replyId = resolveDmReplyToId(envelope, dec.replyToId)
val context = _state.messages + incoming
val withReply = attachDmReplyReferences(context, replyId?.let { mapOf(incoming.id to it) } ?: emptyMap())
.last()
if (ActiveDmChatTracker.isActive(otherUserId)) {
withContext(Dispatchers.Default) {
MessageCacheStore.upsertDmMessage(otherUserId, withReply)
@@ -446,16 +446,20 @@ class DmPanel(
pendingFileAspectRatio = aspect,
fileAspectRatios = confirmed.fileAspectRatios ?: aspect?.let { listOf(it) },
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(
"merge_confirmed",
"msgId" to envelope.id,
"clientId" to cid,
"localPreview" to (merged.pendingFileUri?.take(64) ?: "null"),
"localPreview" to (mergedWithReply.pendingFileUri?.take(64) ?: "null"),
"aspect" to aspect,
)
@@ -471,7 +475,7 @@ class DmPanel(
optimisticIndex >= 0 -> {
currentState.messages.mapIndexedNotNull { index, message ->
when {
index == optimisticIndex -> merged
index == optimisticIndex -> mergedWithReply
message.id == envelope.id -> null
else -> message
}
@@ -479,10 +483,10 @@ class DmPanel(
}
existingRealIndex >= 0 -> {
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)
currentState.copy(messages = deduped)
@@ -540,7 +544,7 @@ class DmPanel(
val username = if (envelope.senderId == currentUserId) {
"You"
} else {
otherDisplayName.ifBlank { "User $otherUserId" }
otherDisplayName
}
return Message(
id = envelope.id,
@@ -31,6 +31,8 @@ import ru.fromchat.ui.chat.AvatarInfo
import ru.fromchat.ui.chat.ChatPanel
import ru.fromchat.ui.chat.utils.PublicChatTypingHandler
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
class PublicChatPanel(
@@ -160,23 +162,31 @@ class PublicChatPanel(
private suspend fun hydrateMessagesFromLocalCache() {
val cached = withContext(Dispatchers.Default) {
runCatching { MessageRepository.loadRecentPublicMessagesImmediate(limit = 128) }
runCatching { MessageRepository.loadPublicMessages() }
.getOrDefault(emptyList())
}
if (cached.isEmpty()) return
if (cached.isEmpty() && _state.messages.isEmpty()) return
withContext(Dispatchers.Main) {
batchStateUpdates {
val shown = _state.messages
if (shown.isNotEmpty()) {
val withReplies = preserveReplyToFromExisting(shown, cached)
if (withReplies != shown) {
updateState { it.copy(messages = sortMessagesForChatDisplay(withReplies)) }
when {
shown.isEmpty() -> {
if (cached.isNotEmpty()) {
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) {
ProfileCache.mergePreviewFromPublicMessage(newMsg)
val displayMessage = ProfileCache.enrichPublicMessageForDisplay(newMsg)
val withReply = attachPublicReplyReferences(_state.messages + newMsg).last()
val displayMessage = ProfileCache.enrichPublicMessageForDisplay(withReply)
addMessage(displayMessage)
withContext(Dispatchers.Default) {
MessageCacheStore.upsertPublicMessage(newMsg)
MessageCacheStore.upsertPublicMessage(withReply)
}
}
@@ -365,6 +376,9 @@ class PublicChatPanel(
messages = older + currentState.messages
)
}
withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(_state.messages)
}
}
setHasMoreMessages(false) // TODO: Implement has_more from API
} 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.messages.sortMessagesForChatDisplay
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.
@@ -70,6 +108,7 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
fileDimensions = db.fileDimensions ?: panel.fileDimensions,
content = db.content.ifBlank { panel.content },
isContentCorrupted = panel.isContentCorrupted || db.isContentCorrupted,
replyToId = db.replyToId ?: panel.replyToId,
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.displayNameForUi
import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.user_fallback
internal object ChatListLayout {
private const val CATEGORY_TOP_SPACER = 0
@@ -873,7 +872,7 @@ internal fun DmConversationRowContent(
isPeerDeleted -> deletedUserDisplayNameForUi()
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
conversation.displayName.isNotBlank() -> conversation.displayName
else -> stringResource(Res.string.user_fallback, conversation.otherUserId)
else -> cached?.visibleUsername(currentUserId).orEmpty()
}
val avatarInitialsLabel = when {
isPeerDeleted -> deletedUserDisplayNameForUi()
@@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.resetFocus
@@ -76,6 +77,7 @@ import ru.fromchat.search_not_found_message
import ru.fromchat.search_title
import ru.fromchat.ui.components.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.Text
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -229,12 +231,12 @@ fun ChatsSearchScreen(
}
}
Scaffold(
modifier = Modifier
.imePadding()
.windowInsetsPadding(WindowInsets.safeDrawing),
contentWindowInsets = WindowInsets.safeDrawing,
topBar = {
ScreenSurface {
Scaffold(
modifier = Modifier.imePadding(),
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets.safeDrawing,
topBar = {
SearchBar(
query = searchText,
onQueryChange = { searchText = it },
@@ -325,6 +327,7 @@ fun ChatsSearchScreen(
}
}
}
}
}
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -86,6 +87,7 @@ import ru.fromchat.Res
import ru.fromchat.action_delete
import ru.fromchat.action_mark_read
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ChatListSync
import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext
@@ -289,6 +291,7 @@ fun ChatsTab(
val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val profileCacheRevision by ProfileCache.revision.collectAsState()
var dmConversations by remember(activeInstanceId) {
val cached = if (activeInstanceId.isBlank()) {
emptyList()
@@ -334,8 +337,11 @@ fun ChatsTab(
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) {
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly, activeInstanceId) {
MessageCacheStore.listPreviewStrings = previewStrings
if (activeInstanceId.isNotBlank()) {
runCatching { ChatListSync.syncFromNetwork() }
}
}
SideEffect {
@@ -716,6 +722,7 @@ fun ChatsTab(
.padding(horizontal = 12.dp, vertical = 8.dp),
)
} else {
key(profileCacheRevision) {
ChatConversationsList(
listState = tabListState,
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.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -108,6 +109,7 @@ import ru.fromchat.logging.LogShareCompression
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.BackHandler
import ru.fromchat.ui.components.PredictiveBackHandler
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring
import ru.fromchat.ui.main.chats.SelectionCheckmarkSlot
@@ -424,6 +426,7 @@ fun LogFilesScreen(
val listBottomInset = if (selectionBarVisible) 88.dp else 8.dp
val fileCategoryColor = MaterialTheme.colorScheme.surfaceContainer
ScreenSurface {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = Color.Transparent,
@@ -442,7 +445,9 @@ fun LogFilesScreen(
topBar = {
Box {
TopAppBar(
modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress },
modifier = Modifier
.graphicsLayer { alpha = 1f - selectionProgress }
.background(MaterialTheme.colorScheme.surfaceContainer),
navigationIcon = {
IconButton(
onClick = { navController.navigateUp() },
@@ -493,7 +498,8 @@ fun LogFilesScreen(
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
.padding(innerPadding)
.background(MaterialTheme.colorScheme.background),
) {
DisableSelection {
LazyColumn(
@@ -626,6 +632,7 @@ fun LogFilesScreen(
}
}
}
}
}
@Composable
@@ -15,6 +15,7 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.scrollBy
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.ExpressiveIconFrame
import ru.fromchat.ui.components.PredictiveBackHandler
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.main.chats.ChatSelectionTransitionSpring
import ru.fromchat.ui.main.chats.SelectionCheckmarkSlot
@@ -719,6 +721,7 @@ fun LogsScreen() {
ToggleNavScrimEffect()
}
ScreenSurface {
Scaffold(
modifier = Modifier
.fillMaxSize()
@@ -752,9 +755,11 @@ fun LogsScreen() {
topBar = {
Box {
TopAppBar(
modifier = Modifier.graphicsLayer {
alpha = (1f - selectionProgress) * (1f - searchProgress)
},
modifier = Modifier
.graphicsLayer {
alpha = (1f - selectionProgress) * (1f - searchProgress)
}
.background(MaterialTheme.colorScheme.surfaceContainer),
navigationIcon = {
IconButton(
onClick = { navController.navigateUp() },
@@ -976,12 +981,17 @@ fun LogsScreen() {
}
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(MaterialTheme.colorScheme.background),
) {
when {
displayEntries.isEmpty() -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.then(if (searchMode) Modifier.imePadding() else Modifier),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -998,7 +1008,6 @@ fun LogsScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.imePadding(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -1052,13 +1061,14 @@ fun LogsScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.then(if (searchMode) Modifier.imePadding() else Modifier),
) {
listContent()
}
}
}
}
}
}
}
@@ -82,6 +82,7 @@ import ru.fromchat.ui.components.DisabledBringIntoViewSpec
import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.HazeActionButton
import ru.fromchat.ui.components.LazyListFocusScrollEffect
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.expressiveStepFieldColors
import ru.fromchat.ui.components.rememberLazyListFocusScrollState
@@ -320,6 +321,7 @@ fun EditProfileScreen(
}
}
ScreenSurface {
Scaffold(
modifier = Modifier.fillMaxSize(),
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.TypingIndicator
import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.ScreenSurface
import ru.fromchat.ui.components.ShimmerBox
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.showReplacingSnackbar
@@ -296,6 +297,17 @@ fun ProfileScreen(
}
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
LaunchedEffect(backStackEntry, lookupKey) {
@@ -307,6 +319,7 @@ fun ProfileScreen(
try {
val refreshed = ApiClient.getOwnProfile()
ProfileCache.put(refreshed)
ApiClient.applyOwnProfile(refreshed)
state = latestUi.copy(profile = refreshed, error = null)
} catch (_: Exception) {
ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
@@ -367,6 +380,9 @@ fun ProfileScreen(
)
ProfileCache.put(profile)
if (targetUserId == null && targetUsername == null) {
ApiClient.applyOwnProfile(profile)
}
state = latestUi.copy(profile = profile, isLoading = false, error = null)
loadedSuccessfully = true
true
@@ -604,21 +620,18 @@ fun ProfileScreen(
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
)
Box(
modifier = modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars),
) {
ScreenSurface(modifier = modifier) {
val useSharedAvatar = sharedTransitionScope != null &&
animatedVisibilityScope != null &&
sharedAvatarKey != null
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -764,10 +777,12 @@ fun ProfileScreen(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
.fillMaxWidth(),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -890,21 +905,18 @@ fun PublicChatProfileScreen(
animatedVisibilityScope != null &&
sharedAvatarKey != null
Box(
modifier = modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
ScreenSurface(modifier = modifier) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when {
useSharedAvatar && displayName.isNotBlank() -> {
item {
@@ -974,10 +986,12 @@ fun PublicChatProfileScreen(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
.fillMaxWidth(),
)
}
}
}
@@ -284,7 +284,7 @@ WHERE instanceId = ? AND id = ?;
deleteEmptyDmConversations:
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
WHERE instanceId = ? AND deletedFlag = 0
);
@@ -3,7 +3,11 @@ package ru.fromchat.ui
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@Composable
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