diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt
index 7fcdff8..b2e2298 100644
--- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt
+++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt
@@ -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,
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/Theme.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/Theme.android.kt
index 271f626..902c376 100644
--- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/Theme.android.kt
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/Theme.android.kt
@@ -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()
- }
\ No newline at end of file
+ }
+
+@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
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 68175ba..379a8a0 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -109,7 +109,6 @@
Отправка вложения
Загрузка продолжается в фоне
Вы
- Человек %1$d
Удалённый аккаунт
Аккаунт заблокирован
Удалить чат
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index c9e2f4c..db7ae70 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -121,7 +121,6 @@
You
- Person %1$d
Deleted account
Account suspended
Delete chat
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
index d0bc0a3..a85d981 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -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(
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt
index 0081168..24ba949 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt
@@ -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,
+ )
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt
index ad28756..671f439 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt
@@ -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() }
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt
index cf2d4c3..7e33fd7 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/calls/CallStore.kt
@@ -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) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt
index e039190..78473a2 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/DmStoredMessageContent.kt
@@ -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? = null,
val fileAspectRatios: List? = 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(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(trimmed)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt
index beddfcd..88cae8e 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt
@@ -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 {
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,
- 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): List {
+ 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): List {
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 }
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt
index 172ae51..8c7c19f 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt
@@ -93,7 +93,7 @@ object MessageRepository {
suspend fun replaceDmConversations(
conversations: List,
- previewStrings: ChatListPreviewStrings,
+ previewStrings: ChatListPreviewStrings? = null,
) = MessageCacheStore.replaceDmConversations(conversations, previewStrings)
suspend fun loadCachedDmConversations(): List =
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt
index ef2371a..3df9faf 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt
@@ -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 = _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()
}
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt
index 7a71b6b..c80de99 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboundMessageProcessor.kt
@@ -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,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt
index ddea26b..a31285b 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt
@@ -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,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt
index a5a4e8a..8dfb9ad 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt
@@ -47,5 +47,7 @@ data class Message(
/** Image dimensions (width, height) for image files (by index); from decrypted message JSON. */
@Transient val fileDimensions: List>? = 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,
)
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt
index 4a59dc2..6a623bc 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt
@@ -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(
}
}
}
- }
+ }
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
index d1bd21b..949d9d1 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -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 { 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())
+ }
}
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt
index 05d1a2f..7126f81 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/Theme.kt
@@ -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,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt
index 88a2f01..62b5bc6 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt
@@ -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()
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt
index 5c1a7f4..4376e7f 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt
@@ -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
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
index a556fc0..2c1a355 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
@@ -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) {
- 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.
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
index 0cd1eaa..f7a779d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt
@@ -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)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt
index 83f292a..60941fd 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt
@@ -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
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
index 1504630..b23a7c4 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt
@@ -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(null) }
+ var rowLayoutCoords by remember(message.id) { mutableStateOf(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
+ }
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt
index d1b1f64..0a46bde 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/ChatFileAttachmentTile.kt
@@ -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(
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/FileAttachmentLeadingSlot.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/FileAttachmentLeadingSlot.kt
index 1f6ef3f..ed870a6 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/FileAttachmentLeadingSlot.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/components/FileAttachmentLeadingSlot.kt
@@ -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
},
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt
index ca400e6..a275336 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt
@@ -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>()
+ val parsedReplyIds = mutableMapOf()
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,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt
index 2931b02..3455c0d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt
@@ -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) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt
index a2b6300..8bc04b4 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageUiMerge.kt
@@ -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,
+ parsedReplyIds: Map = emptyMap(),
+): List {
+ 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,
+ parsedReplyIds: Map = emptyMap(),
+): List {
+ 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,
)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ScreenSurface.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ScreenSurface.kt
new file mode 100644
index 0000000..02237cd
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ScreenSurface.kt
@@ -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,
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt
index 40f0969..d54a810 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt
@@ -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()
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt
index e2a38d4..12da162 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt
@@ -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) =
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
index 1332648..736dca0 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
@@ -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(
},
)
}
+ }
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt
index 16497ab..2d281f2 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogFilesScreen.kt
@@ -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
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt
index 1230fdc..f340833 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/LogsScreen.kt
@@ -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()
}
}
}
+ }
+ }
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt
index 72b1a68..5c2f89a 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt
@@ -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(
}
}
}
+ }
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
index c1ef900..6b5067c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
@@ -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(),
)
+ }
}
}
diff --git a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq
index 49c570e..28a7258 100644
--- a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq
+++ b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq
@@ -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
);
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/Theme.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/Theme.ios.kt
index 4a59b98..9da7155 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/Theme.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/Theme.ios.kt
@@ -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()
\ No newline at end of file
+ if (darkTheme) darkColorScheme() else lightColorScheme()
+
+@Composable
+actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) = Unit