diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml
index 5208015..d21ec15 100644
--- a/app/android/src/main/AndroidManifest.xml
+++ b/app/android/src/main/AndroidManifest.xml
@@ -21,7 +21,7 @@
android:networkSecurityConfig="@xml/network_security_config">
+ android:resource="@drawable/ic_stat_fromchat" />
dmSenderName
- !envelope.senderUsername.isNullOrBlank() -> envelope.senderUsername
+ !envelope.senderDisplayName.isNullOrBlank() -> envelope.senderDisplayName
else -> ProfileCache.get(envelope.senderId)
?.visibleDisplayName(currentUserId)
?.takeIf { it.isNotBlank() }
+ ?: envelope.senderUsername
}.orEmpty()
val dmConversationUserId = envelope.senderId
val notificationBody = buildChatListPreviewFromEnvelope(
@@ -403,7 +404,7 @@ object NotificationHelper {
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
- .setSmallIcon(R.drawable.logo)
+ .setSmallIcon(NotificationSmallIcon.resId(context))
.setContentTitle(title)
.setContentText(body)
.setStyle(
@@ -506,7 +507,7 @@ object NotificationHelper {
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
- .setSmallIcon(R.drawable.logo)
+ .setSmallIcon(NotificationSmallIcon.resId(context))
.setStyle(
NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
diff --git a/app/android/src/main/res/drawable/ic_stat_fromchat.xml b/app/android/src/main/res/drawable/ic_stat_fromchat.xml
new file mode 100644
index 0000000..2d9e047
--- /dev/null
+++ b/app/android/src/main/res/drawable/ic_stat_fromchat.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/NotificationSmallIcon.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/NotificationSmallIcon.kt
new file mode 100644
index 0000000..3cde0da
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/NotificationSmallIcon.kt
@@ -0,0 +1,14 @@
+package ru.fromchat.notifications
+
+import android.content.Context
+
+/** White silhouette drawable for status-bar / notification small icons. */
+object NotificationSmallIcon {
+ private const val DRAWABLE_NAME = "ic_stat_fromchat"
+
+ fun resId(context: Context): Int {
+ val id = context.resources.getIdentifier(DRAWABLE_NAME, "drawable", context.packageName)
+ check(id != 0) { "Missing drawable/$DRAWABLE_NAME in application resources" }
+ return id
+ }
+}
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallForegroundService.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallForegroundService.kt
index 8db93e0..5cad72f 100644
--- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallForegroundService.kt
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallForegroundService.kt
@@ -1,6 +1,5 @@
package ru.fromchat.ui.calls
-import android.R
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -16,6 +15,7 @@ import androidx.core.app.Person
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import ru.fromchat.api.calls.CallStore
+import ru.fromchat.notifications.NotificationSmallIcon
/**
* Foreground call session: keeps camera / mic eligible in background.
@@ -60,12 +60,6 @@ class CallForegroundService : Service() {
ensureActiveCallChannel(nm, channelLabel)
- val smallIcon = try {
- packageManager.getApplicationInfo(packageName, 0).icon
- } catch (_: Exception) {
- R.drawable.sym_call_outgoing
- }
-
val hangUpPi = PendingIntent.getService(
this,
RC_HANG_UP,
@@ -93,7 +87,7 @@ class CallForegroundService : Service() {
.build()
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
- .setSmallIcon(smallIcon)
+ .setSmallIcon(NotificationSmallIcon.resId(this))
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_CALL)
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt
index 1a63b78..898fbb3 100644
--- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/calls/CallMediaLayer.android.kt
@@ -127,6 +127,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.LiveKitConnectSession
import ru.fromchat.api.local.db.store.ProfileCache
+import ru.fromchat.notifications.NotificationSmallIcon
import ru.fromchat.call_status_connecting
import ru.fromchat.call_status_reconnecting
import ru.fromchat.call_status_reconnecting_with_detail
@@ -1293,15 +1294,10 @@ private fun CallInlineControlBar(
)
nm.createNotificationChannel(ch)
}
- val smallIcon = try {
- context.packageManager.getApplicationInfo(context.packageName, 0).icon
- } catch (_: Exception) {
- R.drawable.stat_sys_upload
- }
return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID)
.setContentTitle(updatedTitle)
.setContentText(updatedText)
- .setSmallIcon(smallIcon)
+ .setSmallIcon(NotificationSmallIcon.resId(context))
.setOngoing(true)
.build()
}
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 37308a2..e376e4c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.FlowPreview
+import ru.fromchat.Logger
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ConnectionStateStore
@@ -86,12 +87,54 @@ object ChatListSync {
private suspend fun refreshPublicChatPreviewFromLatest() {
runCatching {
- val response = ApiClient.getMessages(limit = 1)
- val latest = response.messages.maxByOrNull { message ->
+ val cached = MessageRepository.loadPublicMessages()
+ val maxCachedId = cached.asSequence().map { it.id }.filter { it > 0 }.maxOrNull() ?: 0
+ val latestResponse = ApiClient.getMessages(limit = 1)
+ val latest = latestResponse.messages.maxByOrNull { message ->
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
} ?: return@runCatching
- // Upsert only — does not wipe older cached messages.
- MessageRepository.upsertPublicMessage(latest)
+
+ val cachedIds = cached.asSequence().map { it.id }.filter { it > 0 }.toHashSet()
+ val holeBelowLatest =
+ latest.id > 0 &&
+ maxCachedId > 0 &&
+ latest.id > maxCachedId + 1
+ val latestMissingWithPriorCache =
+ latest.id > 0 &&
+ latest.id !in cachedIds &&
+ maxCachedId > 0 &&
+ latest.id > maxCachedId
+
+ if (holeBelowLatest || latestMissingWithPriorCache) {
+ // Preview-only upsert would leave first+last holes; pull a page and merge.
+ Logger.i(
+ "ChatListSync",
+ "Public preview gap: latestId=${latest.id} maxCachedId=$maxCachedId " +
+ "cachedCount=${cached.size} — fetching page to fill",
+ )
+ val page = ApiClient.getMessages(limit = 50)
+ val networkMessages = page.messages
+ if (networkMessages.isEmpty()) {
+ MessageRepository.upsertPublicMessage(latest)
+ return@runCatching
+ }
+ ProfileCache.mergePreviewFromPublicMessages(networkMessages)
+ val networkIds = networkMessages.map { it.id }.toSet()
+ val minNetworkId = networkMessages.minOf { it.id }
+ val maxNetworkId = networkMessages.maxOf { it.id }
+ val older = cached.filter { it.id > 0 && it.id !in networkIds && it.id < minNetworkId }
+ val ahead = cached.filter { it.id > 0 && it.id !in networkIds && it.id > maxNetworkId }
+ val merged = (networkMessages + older + ahead).distinctBy { it.id }
+ Logger.i(
+ "ChatListSync",
+ "Public gap fill: network=${networkMessages.size} older=${older.size} " +
+ "ahead=${ahead.size} merged=${merged.size} — replaceAll=true",
+ )
+ MessageRepository.replacePublicMessages(merged, replaceAll = true)
+ } else {
+ // Upsert only — does not wipe older cached messages.
+ MessageRepository.upsertPublicMessage(latest)
+ }
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileUpdateSync.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileUpdateSync.kt
index a1a28dc..4c5c6c7 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileUpdateSync.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileUpdateSync.kt
@@ -51,13 +51,26 @@ object ProfileUpdateSync {
val data = message.data ?: return
val updates = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
- }.getOrNull() ?: return
+ }.getOrNull() ?: run {
+ Logger.w("ProfileUpdateSync", "updates batch decode failed")
+ return
+ }
+ val profileUpdates = updates.updates.count { it.type == "profileUpdate" }
+ if (profileUpdates > 0) {
+ Logger.d(
+ "ProfileUpdateSync",
+ "updates batch seq=${updates.seq} profileUpdateCount=$profileUpdates",
+ )
+ }
updates.updates.forEach { update ->
handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data))
}
}
"profileUpdate" -> {
- val payload = message.data ?: return
+ val payload = message.data ?: run {
+ Logger.w("ProfileUpdateSync", "profileUpdate missing data")
+ return
+ }
onProfileUpdatePayload(payload)
}
}
@@ -73,9 +86,18 @@ object ProfileUpdateSync {
Logger.d(
"ProfileUpdateSync",
"profileUpdate id=${profile.id} username='${profile.username}' " +
- "bio='${profile.bio?.take(48)}'",
+ "deleted=${profile.deleted} suspended=${profile.suspended} " +
+ "bio='${profile.bio?.take(48)}' revisionBefore=${ProfileCache.revision.value}",
)
+ val hadCached = ProfileCache.get(profile.id)
ProfileCache.applyServerProfile(profile, force = true)
+ val after = ProfileCache.get(profile.id)
+ Logger.d(
+ "ProfileUpdateSync",
+ "profileUpdate applied id=${profile.id} " +
+ "wasDeleted=${hadCached?.deleted} nowDeleted=${after?.deleted} " +
+ "revisionAfter=${ProfileCache.revision.value}",
+ )
UserStatusStore.update(profile.id, profile.online, profile.lastSeen)
if (ApiClient.user?.id == profile.id) {
@@ -83,6 +105,19 @@ object ProfileUpdateSync {
}
runCatching { MessageRepository.patchDmConversationPeerProfile(profile.id) }
+ .onFailure {
+ Logger.w(
+ "ProfileUpdateSync",
+ "patchDmConversationPeerProfile failed id=${profile.id}: ${it.message}",
+ it,
+ )
+ }
+ .onSuccess {
+ Logger.d(
+ "ProfileUpdateSync",
+ "patchDmConversationPeerProfile done id=${profile.id}",
+ )
+ }
}
private fun parseProfileUpdate(data: JsonElement): UserProfile? {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt
index 77d0be3..b21c376 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt
@@ -58,13 +58,24 @@ object UpdateSyncManager {
/**
* Apply a live or replayed updates envelope, then advance cursor and ack the server.
+ *
+ * Ack is fire-and-forget: [WebSocketManager.request] must not be awaited from the WS
+ * receive loop (it would deadlock — the ack response cannot be read while this call blocks).
*/
suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
applyMutex.withLock {
- val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: return@withLock
+ Logger.d("UpdateSync", "onUpdatesEnvelope begin")
+ val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: run {
+ Logger.w("UpdateSync", "onUpdatesEnvelope apply returned null")
+ return@withLock
+ }
+ Logger.d(
+ "UpdateSync",
+ "onUpdatesEnvelope applied seq=$seq lastSeq=${_lastSeq.value}",
+ )
if (seq > _lastSeq.value) {
persistLastSeq(seq)
- sendAck(seq)
+ sendAckFireAndForget(seq)
}
}
}
@@ -90,6 +101,10 @@ object UpdateSyncManager {
/**
* Catch up from [lastSeq]: chunked getUpdates, or tooLong → history rebuild.
* Does not advance the cursor until apply/rebuild succeeds.
+ *
+ * Loops until [GetUpdatesResponse.hasMore] is false. On repeated getUpdates failures
+ * (e.g. prior ack-deadlock timeouts), falls back to a full history rebuild so the UI
+ * is not left with first+last holes filled only by slow incremental envelopes.
*/
suspend fun runGapDetectionIfNeeded() {
if (gapDetectionInProgress) {
@@ -108,16 +123,38 @@ object UpdateSyncManager {
try {
var rounds = 0
+ var consecutiveFailures = 0
while (rounds < 100) {
rounds++
val startSeq = _lastSeq.value
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
- val response = requestGetUpdates(token, startSeq) ?: break
+ val response = requestGetUpdates(token, startSeq)
+ if (response == null) {
+ consecutiveFailures++
+ Logger.w(
+ "UpdateSyncManager",
+ "getUpdates returned null (timeout/disconnect) " +
+ "failures=$consecutiveFailures lastSeq=$startSeq",
+ )
+ if (consecutiveFailures >= 2) {
+ Logger.w(
+ "UpdateSyncManager",
+ "Gap catch-up stalled — rebuilding from history",
+ )
+ rebuildStateFromHistory()
+ break
+ }
+ continue
+ }
+ consecutiveFailures = 0
+
+ val gapHint = (response.lastSeq - startSeq).coerceAtLeast(response.missedCount)
Logger.i(
"UpdateSyncManager",
"Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
- "missed=${response.missedCount}, hasMore=${response.hasMore}",
+ "missed=${response.missedCount}, hasMore=${response.hasMore}, " +
+ "gapHint=$gapHint clientSeq=$startSeq",
)
updateMissedCount(response.missedCount)
@@ -126,18 +163,43 @@ object UpdateSyncManager {
val ok = rebuildStateFromHistory()
if (ok) {
persistLastSeq(response.lastSeq)
- sendAck(response.lastSeq)
+ sendAckFireAndForget(response.lastSeq)
} else {
Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
}
break
}
"ok" -> {
+ // Envelopes for this chunk are applied on the receive path before this
+ // response is delivered; advance cursor here only when there was nothing to apply.
if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
persistLastSeq(response.lastSeq)
- sendAck(response.lastSeq)
+ sendAckFireAndForget(response.lastSeq)
+ }
+ if (!response.hasMore) {
+ Logger.i(
+ "UpdateSyncManager",
+ "Gap catch-up complete after $rounds round(s) lastSeq=${_lastSeq.value}",
+ )
+ break
+ }
+ if (_lastSeq.value <= startSeq && response.missedCount > 0) {
+ // Chunk was announced but cursor did not advance — avoid tight spin.
+ Logger.w(
+ "UpdateSyncManager",
+ "Gap chunk did not advance cursor " +
+ "(start=$startSeq now=${_lastSeq.value} missed=${response.missedCount})",
+ )
+ consecutiveFailures++
+ if (consecutiveFailures >= 2) {
+ val ok = rebuildStateFromHistory()
+ if (ok) {
+ persistLastSeq(response.lastSeq)
+ sendAckFireAndForget(response.lastSeq)
+ }
+ break
+ }
}
- if (!response.hasMore) break
}
else -> {
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
@@ -165,7 +227,7 @@ object UpdateSyncManager {
GetUpdatesRequest(lastSeq = lastSeq),
),
)
- val response = WebSocketManager.request(requestMessage)
+ val response = WebSocketManager.request(requestMessage, timeoutMs = 30_000)
val data = response?.data ?: return null
return runCatching {
ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
@@ -174,11 +236,15 @@ object UpdateSyncManager {
}.getOrNull()
}
- private suspend fun sendAck(seq: Int) {
+ /**
+ * Send ack without waiting for a response. Must not use [WebSocketManager.request] from
+ * the receive/apply path — that deadlocks the incoming frame loop for ~10s per envelope.
+ */
+ private suspend fun sendAckFireAndForget(seq: Int) {
val token = ApiClient.token ?: return
if (seq <= 0) return
runCatching {
- WebSocketManager.request(
+ WebSocketManager.send(
WebSocketMessage(
type = "ackUpdates",
credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
@@ -188,6 +254,7 @@ object UpdateSyncManager {
),
),
)
+ Logger.d("UpdateSync", "ackUpdates sent (fire-and-forget) seq=$seq")
}.onFailure {
Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
}
@@ -236,18 +303,26 @@ object UpdateSyncManager {
val ordered = collected.values.sortedBy {
parseMessageTimestampMillis(it.timestamp) ?: 0L
}
- MessageRepository.replacePublicMessages(ordered)
+ MessageCacheStore.clearPublicMessages()
+ Logger.i(
+ "UpdateSync",
+ "rebuildPublicHistory messages=${ordered.size} — replaceAll=true",
+ )
+ MessageRepository.replacePublicMessages(ordered, replaceAll = true)
}
private suspend fun rebuildDmHistories() {
val conversations = MessageRepository.loadCachedDmConversations()
+ Logger.i("UpdateSync", "rebuildDmHistories conversations=${conversations.size}")
for (conversation in conversations) {
val otherId = conversation.otherUserId
MessageCacheStore.clearDmMessages(otherId)
var beforeId: Int? = null
+ var pageCount = 0
repeat(MAX_HISTORY_PAGES) {
val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
if (page.messages.isEmpty()) return@repeat
+ pageCount++
for (envelope in page.messages) {
val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope)
DmInboundMessageProcessor.processNew(element)
@@ -256,6 +331,7 @@ object UpdateSyncManager {
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
beforeId = oldest.id
}
+ Logger.d("UpdateSync", "rebuildDmHistories otherUserId=$otherId pages=$pageCount")
}
}
}
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 bb47f27..2b2ad6d 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
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
+import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator
import ru.fromchat.api.local.messages.ChatListPreviewState
@@ -167,8 +168,12 @@ object MessageCacheStore {
}
}
- suspend fun replacePublicMessages(messages: List) {
+ suspend fun replacePublicMessages(messages: List, replaceAll: Boolean = false) {
val convId = conversationIdForPublic()
+ Logger.d(
+ "MessageCache",
+ "replacePublicMessages count=${messages.size} replaceAll=$replaceAll convId=$convId",
+ )
val resolved = messages.map { it.resolvePublicAttachmentLayout() }
ProfileCache.mergePreviewFromPublicMessages(resolved)
val pending = loadPendingMessages(convId)
@@ -181,10 +186,11 @@ object MessageCacheStore {
withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, merged)
}
- replaceMessages(convId, merged)
+ replaceMessages(convId, merged, replaceAll = replaceAll)
}
suspend fun clearPublicMessages() {
+ Logger.d("MessageCache", "clearPublicMessages")
clearConversationMessages(conversationIdForPublic())
}
@@ -192,11 +198,17 @@ object MessageCacheStore {
loadMessages(conversationIdForDm(otherUserId))
suspend fun clearDmMessages(otherUserId: Int) {
+ Logger.d("MessageCache", "clearDmMessages otherUserId=$otherUserId")
clearConversationMessages(conversationIdForDm(otherUserId))
}
- suspend fun replaceDmMessages(otherUserId: Int, messages: List) {
+ suspend fun replaceDmMessages(otherUserId: Int, messages: List, replaceAll: Boolean = false) {
val convId = conversationIdForDm(otherUserId)
+ Logger.d(
+ "MessageCache",
+ "replaceDmMessages otherUserId=$otherUserId count=${messages.size} " +
+ "replaceAll=$replaceAll convId=$convId",
+ )
val pending = loadPendingMessages(convId)
val stillPending = filterStillPendingForReplace(convId, pending, messages)
val before = messages + stillPending
@@ -208,7 +220,7 @@ object MessageCacheStore {
withContext(Dispatchers.Default) {
purgeSupersededPendingRows(iid, convId, before, hydrated)
}
- replaceMessages(convId, hydrated)
+ replaceMessages(convId, hydrated, replaceAll = replaceAll)
pruneEmptyConversations()
}
@@ -246,6 +258,11 @@ object MessageCacheStore {
suspend fun upsertPublicMessage(message: Message) {
val resolved = message.resolvePublicAttachmentLayout()
+ Logger.d(
+ "MessageCache",
+ "upsertPublicMessage id=${resolved.id} userId=${resolved.user_id} " +
+ "clientId=${resolved.client_message_id}",
+ )
ProfileCache.mergePreviewFromPublicMessage(resolved)
upsertSingle(conversationIdForPublic(), resolved)
}
@@ -279,25 +296,37 @@ object MessageCacheStore {
}
suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
+ Logger.d(
+ "MessageCache",
+ "upsertDmMessage otherUserId=$otherUserId id=${message.id} " +
+ "userId=${message.user_id} clientId=${message.client_message_id}",
+ )
ensureDmConversationRow(otherUserId)
upsertSingle(conversationIdForDm(otherUserId), message)
syncDmConversationPreviewFromCache(otherUserId)
}
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) {
+ Logger.d("MessageCache", "deletePublicByClientId clientId=$clientMessageId")
deleteByClientMessageId(conversationIdForPublic(), clientMessageId)
}
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) {
+ Logger.d(
+ "MessageCache",
+ "deleteDmByClientId otherUserId=$otherUserId clientId=$clientMessageId",
+ )
deleteByClientMessageId(conversationIdForDm(otherUserId), clientMessageId)
}
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
+ Logger.d("MessageCache", "deleteDmById otherUserId=$otherUserId messageId=$messageId")
deleteMessageById(conversationIdForDm(otherUserId), messageId)
syncDmConversationPreviewFromCache(otherUserId)
}
suspend fun deletePublicMessageById(messageId: Int) {
+ Logger.d("MessageCache", "deletePublicById messageId=$messageId")
deleteMessageById(conversationIdForPublic(), messageId)
}
@@ -440,6 +469,10 @@ object MessageCacheStore {
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
val iid = instanceId()
+ Logger.d(
+ "MessageCache",
+ "markMessageDeleted (soft) convId=$conversationId messageId=$messageId",
+ )
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.markMessageDeleted(
instanceId = iid,
@@ -611,10 +644,43 @@ object MessageCacheStore {
withContext(Dispatchers.Default) {
val existing = db.messageDatabaseQueries
.selectConversationById(iid, convId)
- .executeAsOneOrNull() ?: return@withContext
- val label = resolveDmConversationDisplayLabel(otherUserId, null)
- if (label.isEmpty()) return@withContext
- if (label == existing.displayName) return@withContext
+ .executeAsOneOrNull() ?: run {
+ Logger.d(
+ "MessageCache",
+ "patchDmPeerProfile noConversation otherUserId=$otherUserId",
+ )
+ return@withContext
+ }
+ val profile = ProfileCache.get(otherUserId)
+ val isDeleted = profile?.deleted == true ||
+ profile?.username?.startsWith("#deleted") == true
+ val label = if (isDeleted) {
+ ""
+ } else {
+ resolveDmConversationDisplayLabel(otherUserId, null)
+ }
+ if (!isDeleted && label.isEmpty()) {
+ Logger.d(
+ "MessageCache",
+ "patchDmPeerProfile skipEmptyLabel otherUserId=$otherUserId " +
+ "deleted=${profile?.deleted}",
+ )
+ return@withContext
+ }
+ if (label == existing.displayName) {
+ Logger.d(
+ "MessageCache",
+ "patchDmPeerProfile unchanged otherUserId=$otherUserId " +
+ "deleted=$isDeleted labelEmpty=${label.isEmpty()}",
+ )
+ return@withContext
+ }
+ Logger.d(
+ "MessageCache",
+ "patchDmPeerProfile otherUserId=$otherUserId deleted=$isDeleted " +
+ "oldLabelEmpty=${existing.displayName.isNullOrBlank()} " +
+ "newLabelEmpty=${label.isEmpty()}",
+ )
db.messageDatabaseQueries.upsertConversation(
instanceId = iid,
id = existing.id,
@@ -947,6 +1013,7 @@ object MessageCacheStore {
private suspend fun clearConversationMessages(conversationId: String) {
val iid = instanceId()
+ Logger.d("MessageCache", "clearConversationMessages convId=$conversationId")
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
}
@@ -954,6 +1021,10 @@ object MessageCacheStore {
private suspend fun deleteByClientMessageId(conversationId: String, clientMessageId: String) {
val iid = instanceId()
+ Logger.d(
+ "MessageCache",
+ "deleteByClientMessageId convId=$conversationId clientId=$clientMessageId",
+ )
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageByClientMessageId(iid, conversationId, clientMessageId)
}
@@ -961,6 +1032,12 @@ object MessageCacheStore {
private suspend fun deleteMessageById(conversationId: String, messageId: Int) {
val iid = instanceId()
+ val beforeCount = withContext(Dispatchers.Default) {
+ db.messageDatabaseQueries
+ .selectMessagesByConversation(iid, conversationId)
+ .executeAsList()
+ .size
+ }
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.deleteMessageById(
instanceId = iid,
@@ -968,6 +1045,17 @@ object MessageCacheStore {
id = messageId.toLong(),
)
}
+ val afterCount = withContext(Dispatchers.Default) {
+ db.messageDatabaseQueries
+ .selectMessagesByConversation(iid, conversationId)
+ .executeAsList()
+ .size
+ }
+ Logger.d(
+ "MessageCache",
+ "deleteMessageById convId=$conversationId messageId=$messageId " +
+ "rowsBefore=$beforeCount rowsAfter=$afterCount removed=${beforeCount - afterCount}",
+ )
}
private suspend fun upsertSingle(conversationId: String, msg: Message) {
@@ -1249,9 +1337,11 @@ object MessageCacheStore {
val profile = ProfileCache.get(uid)
val usernameResolved = when {
self != null && uid == self.id -> self.username
- else -> profile?.username?.takeIf { it.isNotBlank() }
- ?: profile?.displayName?.takeIf { it.isNotBlank() }
- ?: ""
+ else -> profile?.username?.takeIf { it.isNotBlank() }.orEmpty()
+ }
+ val displayNameResolved = when {
+ self != null && uid == self.id -> self.displayName?.trim()?.takeIf { it.isNotEmpty() }
+ else -> profile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
}
val pictureResolved = when {
self != null && uid == self.id -> self.profile_picture
@@ -1266,6 +1356,7 @@ object MessageCacheStore {
is_read = isRead != 0L,
is_edited = isEdited != 0L,
username = usernameResolved,
+ displayName = displayNameResolved,
profile_picture = pictureResolved,
verified = profile?.verified,
verificationStatus = profile?.verificationStatus,
@@ -1326,6 +1417,7 @@ object MessageCacheStore {
}
suspend fun clearAll() {
+ Logger.d("MessageCache", "clearAll")
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.purgeAllCache()
}
@@ -1334,44 +1426,86 @@ object MessageCacheStore {
private fun validatedOrEmpty(conversationId: String, messages: List): List {
val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
+ Logger.w(
+ "MessageCache",
+ "validatedOrEmpty incoherent→empty convId=$conversationId count=${messages.size}",
+ )
return emptyList()
}
return CacheValidator.filterMessages(conversationId, messages, self)
}
- private suspend fun replaceMessages(conversationId: String, messages: List) {
+ private suspend fun replaceMessages(
+ conversationId: String,
+ messages: List,
+ replaceAll: Boolean = false,
+ ) {
val self = ApiClient.user?.id
if (!CacheValidator.isConversationCacheCoherent(conversationId, messages, self)) {
+ Logger.w(
+ "MessageCache",
+ "replaceMessages incoherent→clear convId=$conversationId " +
+ "count=${messages.size} replaceAll=$replaceAll",
+ )
clearConversationMessages(conversationId)
return
}
val validated = CacheValidator.filterMessages(conversationId, messages, self)
val iid = instanceId()
+ val beforeCount = withContext(Dispatchers.Default) {
+ db.messageDatabaseQueries
+ .selectMessagesByConversation(iid, conversationId)
+ .executeAsList()
+ .size
+ }
+ Logger.d(
+ "MessageCache",
+ "replaceMessages convId=$conversationId replaceAll=$replaceAll " +
+ "incoming=${messages.size} validated=${validated.size} rowsBefore=$beforeCount",
+ )
withContext(Dispatchers.Default) {
val existingReplyToIds = db.messageDatabaseQueries
.selectMessagesByConversation(iid, conversationId)
.executeAsList()
.associate { it.id.toInt() to it.replyToId }
- db.messageDatabaseQueries.transaction {
- db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
+ if (replaceAll) {
+ db.messageDatabaseQueries.transaction {
+ db.messageDatabaseQueries.deleteMessagesForConversation(iid, conversationId)
+ validated.forEach { msg: Message ->
+ db.messageDatabaseQueries.upsertMessage(
+ instanceId = iid,
+ id = msg.id.toLong(),
+ conversationId = conversationId,
+ userId = msg.user_id.toLong(),
+ content = storedMessageContent(msg),
+ timestamp = msg.timestamp,
+ isRead = if (msg.is_read) 1L else 0L,
+ isEdited = if (msg.is_edited) 1L else 0L,
+ replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
+ clientMessageId = msg.client_message_id,
+ deletedFlag = 0L,
+ sendStatus = if (msg.id < 0) "pending" else "sent",
+ )
+ }
+ }
+ } else {
+ // Merge into existing rows — partial UI snapshots must not wipe full history.
validated.forEach { msg: Message ->
- db.messageDatabaseQueries.upsertMessage(
- instanceId = iid,
- id = msg.id.toLong(),
- conversationId = conversationId,
- userId = msg.user_id.toLong(),
- content = storedMessageContent(msg),
- timestamp = msg.timestamp,
- isRead = if (msg.is_read) 1L else 0L,
- isEdited = if (msg.is_edited) 1L else 0L,
- replyToId = resolveReplyToIdForPersistence(msg, existingReplyToIds[msg.id]),
- clientMessageId = msg.client_message_id,
- deletedFlag = 0L,
- sendStatus = if (msg.id < 0) "pending" else "sent"
- )
+ upsertSingle(conversationId, msg)
}
}
}
+ val afterCount = withContext(Dispatchers.Default) {
+ db.messageDatabaseQueries
+ .selectMessagesByConversation(iid, conversationId)
+ .executeAsList()
+ .size
+ }
+ Logger.d(
+ "MessageCache",
+ "replaceMessages done convId=$conversationId replaceAll=$replaceAll " +
+ "rowsAfter=$afterCount",
+ )
dmOtherUserIdFromConversationId(conversationId)?.let {
syncDmConversationPreviewFromCache(it)
pruneEmptyConversations()
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 fa756cc..a39bf22 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
@@ -56,8 +56,13 @@ object MessageRepository {
fun observeActiveDmConversations(): Flow> =
MessageCacheStore.observeActiveDmConversations(activeInstance())
- suspend fun replacePublicMessages(messages: List) =
- MessageCacheStore.replacePublicMessages(messages)
+ suspend fun replacePublicMessages(messages: List, replaceAll: Boolean = false) {
+ ru.fromchat.Logger.d(
+ "MessageRepo",
+ "replacePublicMessages count=${messages.size} replaceAll=$replaceAll",
+ )
+ MessageCacheStore.replacePublicMessages(messages, replaceAll = replaceAll)
+ }
suspend fun upsertPublicMessage(message: Message) = MessageCacheStore.upsertPublicMessage(message)
@@ -67,20 +72,32 @@ object MessageRepository {
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) =
MessageCacheStore.deletePublicMessageByClientMessageId(clientMessageId)
- suspend fun markMessageDeleted(conversationId: String, messageId: Int) =
+ suspend fun deletePublicMessageById(messageId: Int) {
+ ru.fromchat.Logger.d("MessageRepo", "deletePublicMessageById messageId=$messageId")
+ MessageCacheStore.deletePublicMessageById(messageId)
+ }
+
+ suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
+ ru.fromchat.Logger.d(
+ "MessageRepo",
+ "markMessageDeleted convId=$conversationId messageId=$messageId",
+ )
MessageCacheStore.markMessageDeleted(conversationId, messageId)
+ }
suspend fun markPublicMessageDeleted(messageId: Int) =
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
- suspend fun deletePublicMessageById(messageId: Int) =
- MessageCacheStore.deletePublicMessageById(messageId)
-
suspend fun loadDmMessages(otherUserId: Int): List =
MessageCacheStore.loadDmMessages(otherUserId)
- suspend fun replaceDmMessages(otherUserId: Int, messages: List) =
- MessageCacheStore.replaceDmMessages(otherUserId, messages)
+ suspend fun replaceDmMessages(otherUserId: Int, messages: List, replaceAll: Boolean = false) {
+ ru.fromchat.Logger.d(
+ "MessageRepo",
+ "replaceDmMessages otherUserId=$otherUserId count=${messages.size} replaceAll=$replaceAll",
+ )
+ MessageCacheStore.replaceDmMessages(otherUserId, messages, replaceAll = replaceAll)
+ }
suspend fun upsertDmMessage(otherUserId: Int, message: Message) =
MessageCacheStore.upsertDmMessage(otherUserId, message)
@@ -91,8 +108,13 @@ object MessageRepository {
suspend fun deleteDmMessageByClientMessageId(otherUserId: Int, clientMessageId: String) =
MessageCacheStore.deleteDmMessageByClientMessageId(otherUserId, clientMessageId)
- suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) =
+ suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) {
+ ru.fromchat.Logger.d(
+ "MessageRepo",
+ "deleteDmMessageById otherUserId=$otherUserId messageId=$messageId",
+ )
MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
+ }
suspend fun replaceDmConversations(
conversations: 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 d68f893..952ce96 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
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversationUser
@@ -71,19 +72,41 @@ object ProfileCache {
/** Skip force=false network refetch when a full profile was fetched within this window. */
const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L
- private fun bumpRevision() {
- _revision.value++
+ private fun bumpRevision(reason: String) {
+ val next = _revision.value + 1
+ _revision.value = next
+ Logger.d("ProfileCache", "revision=$next reason=$reason size=${profiles.size}")
}
+ private fun profileSummary(profile: UserProfile): String =
+ "id=${profile.id} user='${profile.username}' deleted=${profile.deleted} " +
+ "suspended=${profile.suspended} preview=${profile.isClientPreviewOnly} " +
+ "verified=${profile.verified} vStatus=${profile.verificationStatus}"
+
fun get(userId: Int): UserProfile? = profiles[userId]
/** True when a full non-preview profile was fetched recently enough to skip refetch. */
@OptIn(ExperimentalTime::class)
fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean {
- val profile = get(userId) ?: return false
- if (profile.isClientPreviewOnly) return false
- val fetchedAt = fullProfileFetchedAtMs[userId] ?: return false
- return (Clock.System.now().toEpochMilliseconds() - fetchedAt) <= maxAgeMs
+ val profile = get(userId) ?: run {
+ Logger.d("ProfileCache", "hasFreshFullProfile id=$userId miss")
+ return false
+ }
+ if (profile.isClientPreviewOnly) {
+ Logger.d("ProfileCache", "hasFreshFullProfile id=$userId stale=previewOnly")
+ return false
+ }
+ val fetchedAt = fullProfileFetchedAtMs[userId] ?: run {
+ Logger.d("ProfileCache", "hasFreshFullProfile id=$userId stale=noFetchTs")
+ return false
+ }
+ val ageMs = Clock.System.now().toEpochMilliseconds() - fetchedAt
+ val fresh = ageMs <= maxAgeMs
+ Logger.d(
+ "ProfileCache",
+ "hasFreshFullProfile id=$userId fresh=$fresh ageMs=$ageMs maxAgeMs=$maxAgeMs",
+ )
+ return fresh
}
/** Emits whenever this user's cached profile changes (including bio). */
@@ -110,7 +133,13 @@ object ProfileCache {
) {
if (id <= 0) return
val existing = get(id)
- if (existing != null && !existing.isClientPreviewOnly) return
+ if (existing != null && !existing.isClientPreviewOnly) {
+ Logger.d(
+ "ProfileCache",
+ "mergePreview skipFullExists id=$id deleted=${existing.deleted}",
+ )
+ return
+ }
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
@@ -120,11 +149,27 @@ object ProfileCache {
} else {
displayName?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.displayName?.takeIf { it.isNotBlank() }
- ?: incomingUsername
}
- if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return
+ if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) {
+ Logger.d("ProfileCache", "mergePreview skipEmptyIdentity id=$id")
+ return
+ }
+ if (incomingUsername.isNullOrEmpty() || incomingDisplayName.isNullOrBlank()) {
+ Logger.d(
+ "ProfileCache",
+ "mergePreview missingIdentity id=$id " +
+ "hasUsername=${!incomingUsername.isNullOrEmpty()} " +
+ "hasDisplayName=${!incomingDisplayName.isNullOrBlank()}",
+ )
+ }
+
+ Logger.d(
+ "ProfileCache",
+ "mergePreview id=$id deleted=$isDeleted hadExisting=${existing != null} " +
+ "user='${incomingUsername.orEmpty()}' display='${incomingDisplayName.orEmpty()}'",
+ )
put(
UserProfile(
id = id,
@@ -159,29 +204,49 @@ object ProfileCache {
val hasIdentity =
profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank()
if (!hasIdentity) {
+ Logger.d("ProfileCache", "put removeEmptyPreview id=${profile.id}")
remove(profile.id)
return
}
}
val cur = profiles
val existing = cur[profile.id]
+ val deletedChanged = existing?.deleted != profile.deleted
+ val suspendedChanged = existing?.suspended != profile.suspended
+ val verificationChanged = existing?.verified != profile.verified ||
+ existing?.verificationStatus != profile.verificationStatus
if (
existing != null &&
!existing.isClientPreviewOnly &&
existing.bio != profile.bio
) {
- ru.fromchat.Logger.d(
+ Logger.d(
"ProfileCache",
"put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " +
"'${profile.bio?.take(48)}' preview=${profile.isClientPreviewOnly}",
)
}
+ if (deletedChanged || suspendedChanged || verificationChanged || existing == null) {
+ Logger.d(
+ "ProfileCache",
+ "put ${profileSummary(profile)} hadExisting=${existing != null} " +
+ "deletedChanged=$deletedChanged suspendedChanged=$suspendedChanged " +
+ "verificationChanged=$verificationChanged",
+ )
+ }
profiles = cur + (profile.id to profile)
- bumpRevision()
+ bumpRevision("put:${profile.id}")
val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) {
ioScope.launch {
runCatching { ProfileCacheStore.put(instanceId, profile) }
+ .onFailure {
+ Logger.w(
+ "ProfileCache",
+ "persist put failed id=${profile.id}: ${it.message}",
+ it,
+ )
+ }
}
}
}
@@ -199,27 +264,56 @@ object ProfileCache {
if (!force) {
val existing = get(profile.id)
if (existing != null && !existing.isClientPreviewOnly) {
+ val lifecycleMismatch =
+ existing.deleted != normalized.deleted ||
+ existing.suspended != normalized.suspended ||
+ isDeletedPlaceholderUsername(existing.username) !=
+ isDeletedPlaceholderUsername(normalized.username)
+ if (lifecycleMismatch) {
+ Logger.w(
+ "ProfileCache",
+ "applyServerProfile force=false lifecycleMismatch " +
+ "cachedDeleted=${existing.deleted} incomingDeleted=${normalized.deleted} " +
+ "cachedSuspended=${existing.suspended} " +
+ "incomingSuspended=${normalized.suspended} " +
+ "cachedUser='${existing.username}' incomingUser='${normalized.username}' " +
+ "— applying full server profile",
+ )
+ put(normalized)
+ fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
+ return
+ }
val patched = existing.copy(
verified = normalized.verified ?: existing.verified,
verificationStatus = normalized.verificationStatus
?: existing.verificationStatus,
)
+ val verificationChanged = patched.verified != existing.verified ||
+ patched.verificationStatus != existing.verificationStatus
if (patched != existing) put(patched)
if (existing.bio != normalized.bio) {
- ru.fromchat.Logger.d(
+ Logger.d(
"ProfileCache",
"applyServerProfile skipped stale HTTP id=${profile.id} " +
- "cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'",
+ "cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}' " +
+ "deleted=${existing.deleted}",
+ )
+ } else {
+ Logger.d(
+ "ProfileCache",
+ "applyServerProfile keepCached force=false id=${profile.id} " +
+ "deleted=${existing.deleted} verificationChanged=$verificationChanged",
)
}
- // Refresh TTL so force=false callers stop refetching.
- fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
+ if (!verificationChanged) {
+ fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
+ }
return
}
}
- ru.fromchat.Logger.d(
+ Logger.d(
"ProfileCache",
- "applyServerProfile applied force=$force id=${profile.id} " +
+ "applyServerProfile applied force=$force ${profileSummary(normalized)} " +
"bio='${normalized.bio?.take(48)}'",
)
put(normalized)
@@ -228,9 +322,13 @@ object ProfileCache {
fun remove(userId: Int) {
val cur = profiles
- if (userId !in cur) return
+ if (userId !in cur) {
+ Logger.d("ProfileCache", "remove miss id=$userId")
+ return
+ }
+ Logger.d("ProfileCache", "remove id=$userId wasDeleted=${cur[userId]?.deleted}")
profiles = cur - userId
- bumpRevision()
+ bumpRevision("remove:$userId")
val instanceId = loadedInstanceId
if (instanceId.isNotEmpty()) {
ioScope.launch {
@@ -251,20 +349,40 @@ object ProfileCache {
if (user.id <= 0) return
val incomingUsername = user.username.trim()
- if (incomingUsername.isEmpty()) return
+ if (incomingUsername.isEmpty()) {
+ Logger.d("ProfileCache", "mergeFromDmUser skipEmptyUsername id=${user.id}")
+ return
+ }
val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
val incomingDisplayName = if (isDeleted) {
null
} else {
- user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername
+ user.displayName?.trim()?.takeIf { it.isNotEmpty() }
+ }
+
+ if (!isDeleted && incomingDisplayName.isNullOrBlank()) {
+ Logger.d(
+ "ProfileCache",
+ "mergeFromDmUser missingDisplayName id=${user.id} user='$incomingUsername'",
+ )
}
val existing = get(user.id)
+ Logger.d(
+ "ProfileCache",
+ "mergeFromDmUser id=${user.id} deleted=$isDeleted " +
+ "incomingDeleted=${user.deleted} hadFull=${existing != null && existing.isClientPreviewOnly != true} " +
+ "user='$incomingUsername' display='${incomingDisplayName.orEmpty()}'",
+ )
if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy(
username = incomingUsername,
- displayName = if (isDeleted) null else incomingDisplayName ?: existing.displayName,
+ displayName = if (isDeleted) {
+ null
+ } else {
+ incomingDisplayName ?: existing.displayName
+ },
profilePicture = if (isDeleted) {
null
} else {
@@ -278,7 +396,14 @@ object ProfileCache {
suspensionReason = user.suspensionReason ?: existing.suspensionReason,
deleted = isDeleted,
)
- if (patched != existing) put(patched)
+ if (patched != existing) {
+ Logger.d(
+ "ProfileCache",
+ "mergeFromDmUser patchFull id=${user.id} " +
+ "deleted ${existing.deleted}→${patched.deleted}",
+ )
+ put(patched)
+ }
return
}
@@ -286,8 +411,11 @@ object ProfileCache {
UserProfile(
id = user.id,
username = incomingUsername,
- displayName = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() }
- ?: incomingDisplayName,
+ displayName = if (isDeleted) {
+ null
+ } else {
+ incomingDisplayName ?: existing?.displayName?.takeIf { it.isNotBlank() }
+ },
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture,
bio = existing?.bio,
@@ -336,9 +464,18 @@ object ProfileCache {
}
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
- if (uname.isBlank()) return
+ val incomingDisplay = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
+ ?: existing?.displayName?.takeIf { it.isNotBlank() }
+ if (uname.isBlank() && incomingDisplay.isNullOrBlank()) return
+ if (uname.isBlank() || incomingDisplay.isNullOrBlank()) {
+ Logger.d(
+ "ProfileCache",
+ "mergePreviewFromPublicMessage missingIdentity id=$uid " +
+ "hasUsername=${uname.isNotBlank()} hasDisplayName=${!incomingDisplay.isNullOrBlank()}",
+ )
+ }
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
- val display = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } ?: uname
+ val display = if (isDeleted) null else incomingDisplay
val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture
@@ -379,6 +516,8 @@ object ProfileCache {
val user = ApiClient.user
return message.copy(
username = message.username.trim().ifBlank { user?.username.orEmpty() },
+ displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
+ ?: user?.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: user?.profile_picture,
reply_to = enrichedReply,
@@ -389,6 +528,8 @@ object ProfileCache {
username = message.username.trim().ifBlank {
profile?.visibleUsername(self).orEmpty()
},
+ displayName = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
+ ?: profile?.displayName?.trim()?.takeIf { it.isNotEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: profile?.profilePicture,
verified = message.verified ?: profile?.verified,
@@ -413,7 +554,7 @@ object ProfileCache {
}
fullProfileFetchedAtMs = emptyMap()
pruneUnusableClientPreviewsLocked()
- bumpRevision()
+ bumpRevision("onActiveInstanceChanged:$instanceId")
}
}
}
@@ -427,6 +568,7 @@ object ProfileCache {
} else {
emptyMap()
}
+ val before = profiles.size
if (profiles.isEmpty()) {
profiles = diskProfiles
} else {
@@ -442,8 +584,14 @@ object ProfileCache {
}
profiles = merged
}
+ val deletedCount = profiles.values.count { it.deleted == true }
+ Logger.d(
+ "ProfileCache",
+ "hydrateFromDisk instanceId=$instanceId before=$before " +
+ "disk=${diskProfiles.size} after=${profiles.size} deletedCount=$deletedCount",
+ )
pruneUnusableClientPreviewsLocked()
- bumpRevision()
+ bumpRevision("hydrateFromDisk")
}
}
@@ -455,6 +603,7 @@ object ProfileCache {
p.displayName.isNullOrBlank()
}.keys
if (toRemove.isEmpty()) return
+ Logger.d("ProfileCache", "pruneUnusablePreviews ids=$toRemove")
var cur = profiles
for (id in toRemove) {
cur = cur - id
@@ -464,10 +613,11 @@ object ProfileCache {
suspend fun clear() {
persistMutex.withLock {
+ Logger.d("ProfileCache", "clear sizeWas=${profiles.size}")
profiles = emptyMap()
fullProfileFetchedAtMs = emptyMap()
loadedInstanceId = ""
- bumpRevision()
+ bumpRevision("clear")
}
}
}
\ No newline at end of file
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 c80de99..30cb239 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,7 +8,6 @@ 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
@@ -65,16 +64,31 @@ object DmInboundMessageProcessor {
suspend fun processDeleted(element: JsonElement) {
val data = runCatching {
ApiClient.json.decodeFromJsonElement(DmDeletedData.serializer(), element)
- }.getOrNull() ?: return
+ }.getOrNull() ?: run {
+ ru.fromchat.Logger.w("DmInbox", "processDeleted decode failed")
+ return
+ }
val currentUserId = ApiClient.user?.id ?: return
- if (data.senderId != currentUserId && data.recipientId != currentUserId) return
+ if (data.senderId != currentUserId && data.recipientId != currentUserId) {
+ ru.fromchat.Logger.d(
+ "DmInbox",
+ "processDeleted skipNotParticipant messageId=${data.id} " +
+ "senderId=${data.senderId} recipientId=${data.recipientId} self=$currentUserId",
+ )
+ return
+ }
val otherUserId = when (currentUserId) {
data.senderId -> data.recipientId
else -> data.senderId
} ?: return
+ ru.fromchat.Logger.i(
+ "DmInbox",
+ "processDeleted messageId=${data.id} otherUserId=$otherUserId " +
+ "senderId=${data.senderId} recipientId=${data.recipientId}",
+ )
withContext(Dispatchers.Default) {
MessageRepository.deleteDmMessageById(otherUserId, data.id)
}
@@ -132,19 +146,31 @@ object DmInboundMessageProcessor {
otherUserId: Int,
): Message {
val dec = parseDmMessageContent(plaintext)
+ val senderUsername = envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }
+ val senderDisplayName = envelope.senderDisplayName?.trim()?.takeIf { it.isNotEmpty() }
if (envelope.senderId != currentUserId) {
- envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }?.let { senderName ->
- ProfileCache.mergePreview(id = envelope.senderId, username = senderName)
+ if (senderUsername != null || senderDisplayName != null) {
+ ProfileCache.mergePreview(
+ id = envelope.senderId,
+ username = senderUsername,
+ displayName = senderDisplayName,
+ )
}
}
val senderProfile = ProfileCache.get(envelope.senderId)
val username = if (envelope.senderId == currentUserId) {
- "You"
+ ApiClient.user?.username.orEmpty()
} else {
- senderProfile?.visibleDisplayName(currentUserId)?.takeIf { it.isNotBlank() }
- ?: envelope.senderUsername?.takeIf { it.isNotBlank() }
+ senderUsername
+ ?: senderProfile?.username?.trim()?.takeIf { it.isNotEmpty() }
?: ""
}
+ val displayName = if (envelope.senderId == currentUserId) {
+ ApiClient.user?.displayName?.trim()?.takeIf { it.isNotEmpty() }
+ } else {
+ senderDisplayName
+ ?: senderProfile?.displayName?.trim()?.takeIf { it.isNotEmpty() }
+ }
return Message(
id = envelope.id,
user_id = envelope.senderId,
@@ -153,6 +179,7 @@ object DmInboundMessageProcessor {
is_read = envelope.senderId == currentUserId,
is_edited = false,
username = username,
+ displayName = displayName,
profile_picture = null,
verified = null,
reply_to = null,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboxCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboxCoordinator.kt
index 37a7b24..a2624d8 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboxCoordinator.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/DmInboxCoordinator.kt
@@ -36,6 +36,7 @@ object DmInboxCoordinator {
}
"dmDeleted" -> message.data?.let { element ->
scope.launch {
+ ru.fromchat.Logger.d("DmInbox", "handleMessage dmDeleted")
DmInboundMessageProcessor.processDeleted(element)
DmConversationListNotifier.notifyChanged()
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/PublicInboxCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/PublicInboxCoordinator.kt
index 1a86d2c..1a4f1ea 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/PublicInboxCoordinator.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/PublicInboxCoordinator.kt
@@ -3,6 +3,7 @@ package ru.fromchat.api.local.messages
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
+import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
@@ -15,7 +16,15 @@ import ru.fromchat.api.schema.websocket.types.MessageDeletedData
*/
object PublicInboxCoordinator {
suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) {
- val message = decodeMessage(element) ?: return@withContext
+ val message = decodeMessage(element) ?: run {
+ Logger.w("PublicInbox", "processNew decode failed")
+ return@withContext
+ }
+ Logger.d(
+ "PublicInbox",
+ "processNew id=${message.id} userId=${message.user_id} " +
+ "clientId=${message.client_message_id}",
+ )
ProfileCache.mergePreviewFromPublicMessage(message)
val clientId = message.client_message_id?.trim().orEmpty()
val currentUserId = ApiClient.user?.id
@@ -49,7 +58,11 @@ object PublicInboxCoordinator {
}
suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) {
- val edited = decodeMessage(element) ?: return@withContext
+ val edited = decodeMessage(element) ?: run {
+ Logger.w("PublicInbox", "processEdited decode failed")
+ return@withContext
+ }
+ Logger.d("PublicInbox", "processEdited id=${edited.id} userId=${edited.user_id}")
ProfileCache.mergePreviewFromPublicMessage(edited)
val existing = MessageRepository.loadPublicMessages()
val merged = existing.map { current ->
@@ -62,14 +75,23 @@ object PublicInboxCoordinator {
if (merged.none { it.id == edited.id }) {
MessageRepository.upsertPublicMessage(edited)
} else {
- MessageRepository.replacePublicMessages(merged)
+ MessageRepository.upsertPublicMessage(
+ merged.first { it.id == edited.id },
+ )
}
}
suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) {
val deleted = runCatching {
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
- }.getOrNull() ?: return@withContext
+ }.getOrNull() ?: run {
+ Logger.w("PublicInbox", "processDeleted decode failed element=${element.toString().take(120)}")
+ return@withContext
+ }
+ Logger.i(
+ "PublicInbox",
+ "processDeleted messageId=${deleted.message_id} — hard-deleting from cache",
+ )
MessageRepository.deletePublicMessageById(deleted.message_id)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/UpdatesBatchApplier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/UpdatesBatchApplier.kt
index 8f87b4f..2660da0 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/UpdatesBatchApplier.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/UpdatesBatchApplier.kt
@@ -18,8 +18,16 @@ object UpdatesBatchApplier {
suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
val envelope = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
- }.getOrNull() ?: return@withLock null
+ }.getOrNull() ?: run {
+ Logger.w("UpdateSync", "applyEnvelope decode failed: ${data.toString().take(160)}")
+ return@withLock null
+ }
+ val types = envelope.updates.map { it.type }
+ Logger.d(
+ "UpdateSync",
+ "applyEnvelope seq=${envelope.seq} count=${envelope.updates.size} types=$types",
+ )
for (update in envelope.updates) {
applyOne(WebSocketMessage(type = update.type, data = update.data))
}
@@ -34,8 +42,14 @@ object UpdatesBatchApplier {
}
"newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) }
"messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) }
- "messageDeleted" -> message.data?.let { PublicInboxCoordinator.processDeleted(it) }
- "dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message)
+ "messageDeleted" -> {
+ Logger.d("UpdateSync", "applyOne messageDeleted")
+ message.data?.let { PublicInboxCoordinator.processDeleted(it) }
+ }
+ "dmNew", "dmDeleted", "dmEdited" -> {
+ Logger.d("UpdateSync", "applyOne ${message.type}")
+ DmInboxCoordinator.handleMessage(message)
+ }
else -> Unit
}
}
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 b26b4fe..39b308a 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
@@ -17,6 +17,7 @@ data class Message(
val is_read: Boolean,
val is_edited: Boolean,
val username: String,
+ @SerialName("display_name") val displayName: String? = null,
val profile_picture: String? = null,
val verified: Boolean? = null,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/ReactionUser.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/ReactionUser.kt
index e2825f0..8d198d5 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/ReactionUser.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/ReactionUser.kt
@@ -1,9 +1,11 @@
package ru.fromchat.api.schema.messages
+import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ReactionUser(
val id: Int,
- val username: String
+ val username: String,
+ @SerialName("display_name") val displayName: String? = null,
)
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmEnvelope.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmEnvelope.kt
index 09c4e92..02ce619 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmEnvelope.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmEnvelope.kt
@@ -9,6 +9,7 @@ data class DmEnvelope(
val senderId: Int,
val recipientId: Int,
@SerialName("sender_username") val senderUsername: String? = null,
+ @SerialName("sender_display_name") val senderDisplayName: String? = null,
@SerialName("iv_b64") val ivB64: String,
@SerialName("ciphertext_b64") val ciphertextB64: String,
@SerialName("wrapped_mek_b64") val wrappedMekB64: String? = null,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/ReactionUpdateData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/ReactionUpdateData.kt
index 7bfd321..46aab02 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/ReactionUpdateData.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/types/ReactionUpdateData.kt
@@ -1,5 +1,6 @@
package ru.fromchat.api.schema.websocket.types
+import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@@ -9,5 +10,6 @@ data class ReactionUpdateData(
val action: String,
val user_id: Int,
val username: String,
+ @SerialName("display_name") val displayName: String? = null,
val reactions: List
)
\ No newline at end of file
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 f6fe72a..9281596 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
@@ -94,11 +94,26 @@ abstract class ChatPanel(
addMessageMutex.withLock {
batchStateUpdates {
updateState { current ->
+ val panelSnap = panelMessagesForDbMerge()
val merged = mergeDatabaseMessagesWithPanelState(
- panelMessagesForDbMerge(),
+ panelSnap,
messages,
)
val withReplies = attachPublicReplyReferences(merged)
+ if (current.messages.size != withReplies.size ||
+ current.messages.map { it.id }.toSet() != withReplies.map { it.id }.toSet()
+ ) {
+ val panelIds = panelSnap.map { it.id }.toSet()
+ val mergedIds = withReplies.map { it.id }.toSet()
+ val dbIds = messages.map { it.id }.toSet()
+ Logger.d(
+ "ChatPanel",
+ "syncMessagesFromDatabase panel=${panelSnap.size} db=${messages.size} " +
+ "merged=${withReplies.size} " +
+ "panelOnlyIds=${(panelIds - mergedIds).take(8)} " +
+ "dbOnlyIds=${(dbIds - mergedIds).take(8)}",
+ )
+ }
if (current.messages == withReplies) current
else current.copy(messages = withReplies)
}
@@ -692,7 +707,8 @@ abstract class ChatPanel(
timestamp = nowMessageTimestampIso(),
is_read = false,
is_edited = false,
- username = "You",
+ username = ApiClient.user?.username.orEmpty(),
+ displayName = ApiClient.user?.displayName,
client_message_id = tempId,
reply_to = resolvedReply,
replyToId = resolvedReply?.id ?: replyToId?.takeIf { it > 0 },
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 d8961b6..4600562 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
@@ -949,7 +949,8 @@ fun ChatScreen(
timestamp = nowMessageTimestampIso(),
is_read = false,
is_edited = false,
- username = "You",
+ username = ApiClient.user?.username.orEmpty(),
+ displayName = ApiClient.user?.displayName,
profile_picture = null,
verified = null,
reply_to = replyTo,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt
index e98dd12..800cf90 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt
@@ -33,6 +33,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -105,7 +106,7 @@ fun ChatTopBarInner(
peerIsDeleted(
userId = userId,
currentUserId = ApiClient.user?.id,
- username = titleAvatar?.displayName ?: title,
+ username = ProfileCache.get(userId)?.username,
)
} == true
Row(
@@ -203,7 +204,10 @@ fun ChatTopBarInner(
overflow = TextOverflow.Ellipsis,
)
profileUserId?.let { userId ->
- val status = resolveVerificationStatus(userId)
+ val profileCacheRevision by ProfileCache.revision.collectAsState()
+ val status = remember(userId, profileCacheRevision) {
+ resolveVerificationStatus(userId)
+ }
if (status != null) {
Spacer(modifier = Modifier.width(4.dp))
StatusBadge(
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt
index af021dc..0fe6964 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt
@@ -18,12 +18,13 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.Reply
@@ -710,26 +711,27 @@ fun ImageFullscreenPreview(
}
// Top bar: back, display name + date/time, 3-dot menu
- Box(
+ AnimatedVisibility(
+ visible = effectiveMenusVisible,
+ enter = androidx.compose.animation.fadeIn(),
+ exit = androidx.compose.animation.fadeOut(),
modifier = Modifier
.align(Alignment.TopStart)
- .fillMaxWidth()
- .windowInsetsPadding(WindowInsets.safeDrawing),
+ .fillMaxWidth(),
) {
- AnimatedVisibility(
- visible = effectiveMenusVisible,
- enter = androidx.compose.animation.fadeIn(),
- exit = androidx.compose.animation.fadeOut(),
- modifier = Modifier.fillMaxWidth(),
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color.Black.copy(alpha = MENU_BG_ALPHA))
+ .statusBarsPadding(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
- .background(Color.Black.copy(alpha = MENU_BG_ALPHA))
.padding(horizontal = 8.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
IconButton(onClick = { dismissRequested = true }) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
@@ -820,32 +822,31 @@ fun ImageFullscreenPreview(
}
// Bottom: message text
- Box(
+ AnimatedVisibility(
+ visible = effectiveMenusVisible && message.content.isNotBlank(),
+ enter = androidx.compose.animation.fadeIn(),
+ exit = androidx.compose.animation.fadeOut(),
modifier = Modifier
.align(Alignment.BottomStart)
- .fillMaxWidth()
- .windowInsetsPadding(WindowInsets.safeDrawing),
+ .fillMaxWidth(),
) {
- AnimatedVisibility(
- visible = effectiveMenusVisible && message.content.isNotBlank(),
- enter = androidx.compose.animation.fadeIn(),
- exit = androidx.compose.animation.fadeOut(),
- modifier = Modifier.fillMaxWidth(),
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color.Black.copy(alpha = MENU_BG_ALPHA))
+ .navigationBarsPadding(),
) {
- if (message.content.isNotBlank()) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(Color.Black.copy(alpha = MENU_BG_ALPHA))
- .padding(16.dp),
- ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
- color = Color.White
+ color = Color.White,
)
}
- }
}
}
}
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 ebdaf25..92fdf8e 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
@@ -15,7 +15,8 @@ import ru.fromchat.ui.profile.isRedactedPeerAccount
import ru.fromchat.ui.profile.peerIsDeleted
/**
- * Resolves [Message.username] for display: localized «Вы», deleted user label, or server-provided name.
+ * Resolves the sender label shown in message bubbles: localized «Вы», deleted user label,
+ * cached/server display name, or login username only as a last resort.
*/
@Composable
fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
@@ -30,12 +31,14 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
if (isDeletedAccountUsername(message.username)) {
return deletedUserDisplayNameForUi()
}
- val cachedUsername = ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
- if (cachedUsername != null) return cachedUsername
+ ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
+ ?.takeIf { it.isNotBlank() }
+ ?.let { return it }
+ message.displayName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
if (message.username.equals("deleted", ignoreCase = true)) {
return deletedUserDisplayNameForUi()
}
- return message.username
+ return message.username.trim()
}
fun messageSenderProfilePicture(
@@ -75,5 +78,5 @@ fun messageSenderAvatarLabel(
if (currentUserId != null && message.user_id == currentUserId) {
return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty()
}
- return message.username.trim()
+ return message.displayName?.trim()?.takeIf { it.isNotEmpty() }.orEmpty()
}
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 d64e3f0..df00a3b 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
@@ -35,6 +35,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -229,11 +230,14 @@ fun MessageItem(
val sendFailedLabel = stringResource(Res.string.message_send_failed)
val replyPhotoLabel = stringResource(Res.string.message_reply_photo)
val displayUsername = messageDisplayUsername(message, currentUserId)
+ val profileCacheRevision by ProfileCache.revision.collectAsState()
val senderProfile = ProfileCache.get(message.user_id)
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
?: message.profile_picture
val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId)
- val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
+ val senderVerificationStatus = remember(message.user_id, message, profileCacheRevision) {
+ resolveVerificationStatus(message.user_id, message)
+ }
val isDeletedSender = messageSenderIsDeleted(message, currentUserId)
val replyRef = message.reply_to
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 e43457c..e737036 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
@@ -159,6 +159,13 @@ class DmPanel(
scope.launch(Dispatchers.Default) {
val cached = ProfileCache.get(otherUserId)
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
+ Logger.d(
+ "DmPanel",
+ "applyCachedPeerProfile otherUserId=$otherUserId " +
+ "deleted=${cached?.deleted} suspended=${cached?.suspended} " +
+ "revision=${ProfileCache.revision.value} " +
+ "titleBlank=${displayName.isBlank()}",
+ )
cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) }
withContext(Dispatchers.Main) {
if (displayName.isNotBlank()) {
@@ -236,17 +243,17 @@ class DmPanel(
// Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner
// when the chat screen re-entered composition (e.g. pop back from profile).
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
- if (cached.isNotEmpty()) {
+ val hadCachedMessages = cached.isNotEmpty()
+ if (hadCachedMessages) {
batchStateUpdates {
clearMessages()
addMessages(cached)
setLoading(false)
}
- messagesLoaded = true
- return
+ } else {
+ setLoading(true)
}
- setLoading(true)
try {
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
CacheContext.requireActiveInstanceId(),
@@ -289,7 +296,7 @@ class DmPanel(
// Persist the most recent DM messages for offline use.
val mergedForCache = _state.messages
- MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache)
+ MessageCacheStore.replaceDmMessages(otherUserId, mergedForCache, replaceAll = true)
messagesLoaded = true
} else {
val error = historyResult.exceptionOrNull()
@@ -299,6 +306,8 @@ class DmPanel(
clearMessages()
setHasMoreMessages(false)
messagesLoaded = true
+ } else if (hadCachedMessages) {
+ messagesLoaded = true
}
}
} finally {
@@ -510,8 +519,6 @@ class DmPanel(
MessageCacheStore.confirmDmMessage(otherUserId, cid, mergedForPersistence)
OutgoingMessageCoordinator.clearAttachmentOutboxAfterAck(cid)
}
- val snapshot = _state.messages
- MessageCacheStore.replaceDmMessages(otherUserId, snapshot)
}
}
@@ -533,31 +540,46 @@ class DmPanel(
}
val outcome = decryptDmEnvelopeForUi(envelope)
val dec = parseDmMessageContent(outcome.plaintext)
+ val editedForCache = (previous ?: createMessage(envelope, outcome.plaintext, outcome.isCorrupted)).copy(
+ content = dec.text,
+ is_edited = true,
+ fileThumbnails = dec.fileThumbnails ?: previous?.fileThumbnails,
+ fileAspectRatios = dec.fileAspectRatios ?: previous?.fileAspectRatios,
+ fileSizes = dec.fileSizes ?: previous?.fileSizes,
+ fileDimensions = dec.fileDimensions ?: previous?.fileDimensions,
+ isContentCorrupted = outcome.isCorrupted,
+ dmEnvelope = envelope,
+ reply_to = previous?.reply_to,
+ )
updateMessage(envelope.id) {
- it.copy(
- content = dec.text,
- is_edited = true,
- fileThumbnails = dec.fileThumbnails ?: it.fileThumbnails,
- fileAspectRatios = dec.fileAspectRatios ?: it.fileAspectRatios,
- fileSizes = dec.fileSizes ?: it.fileSizes,
- fileDimensions = dec.fileDimensions ?: it.fileDimensions,
- isContentCorrupted = outcome.isCorrupted,
- dmEnvelope = envelope,
- reply_to = it.reply_to,
- )
+ editedForCache.copy(reply_to = it.reply_to)
}
- // Persist edit to cache
- MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
+ MessageCacheStore.upsertDmMessage(otherUserId, editedForCache)
}
}
private fun createMessage(envelope: DmEnvelope, plaintext: String, isContentCorrupted: Boolean): Message {
val dec = parseDmMessageContent(plaintext)
- val username = if (envelope.senderId == currentUserId) {
- "You"
+ val senderUsername = if (envelope.senderId == currentUserId) {
+ ApiClient.user?.username.orEmpty()
} else {
- otherDisplayName
+ envelope.senderUsername?.trim()?.takeIf { it.isNotEmpty() }
+ ?: ProfileCache.get(envelope.senderId)?.username?.trim().orEmpty()
+ }
+ val senderDisplayName = if (envelope.senderId == currentUserId) {
+ ApiClient.user?.displayName?.trim()?.takeIf { it.isNotEmpty() }
+ } else {
+ envelope.senderDisplayName?.trim()?.takeIf { it.isNotEmpty() }
+ ?: otherDisplayName.takeIf { it.isNotBlank() }
+ ?: ProfileCache.get(envelope.senderId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
+ }
+ if (envelope.senderId != currentUserId) {
+ ProfileCache.mergePreview(
+ id = envelope.senderId,
+ username = senderUsername.takeIf { it.isNotEmpty() },
+ displayName = senderDisplayName,
+ )
}
return Message(
id = envelope.id,
@@ -569,7 +591,8 @@ class DmPanel(
else -> ActiveDmChatTracker.isActive(otherUserId)
},
is_edited = false,
- username = username,
+ username = senderUsername,
+ displayName = senderDisplayName,
profile_picture = null,
verified = null,
reply_to = null,
@@ -621,12 +644,27 @@ class DmPanel(
private fun processDeletedEnvelope(element: JsonElement) {
val data = runCatching {
json.decodeFromJsonElement(DmDeletedData.serializer(), element)
- }.getOrNull() ?: return
+ }.getOrNull() ?: run {
+ Logger.w("DmPanel", "processDeletedEnvelope decode failed")
+ return
+ }
val involvesPeer =
data.senderId == otherUserId ||
data.recipientId == otherUserId ||
data.senderId == currentUserId
- if (!involvesPeer) return
+ if (!involvesPeer) {
+ Logger.d(
+ "DmPanel",
+ "processDeletedEnvelope skip messageId=${data.id} " +
+ "senderId=${data.senderId} recipientId=${data.recipientId} peer=$otherUserId",
+ )
+ return
+ }
+ Logger.i(
+ "DmPanel",
+ "processDeletedEnvelope messageId=${data.id} peer=$otherUserId " +
+ "uiBefore=${_state.messages.size} inUi=${_state.messages.any { it.id == data.id }}",
+ )
scope.launch(Dispatchers.Default) {
val clientId = _state.messages.find { it.id == data.id }?.client_message_id
DownloadedFileRegistry.invalidateForMessage(data.id)
@@ -639,6 +677,10 @@ class DmPanel(
}
deleteMessageImmediately(data.id)
MessageRepository.deleteDmMessageById(otherUserId, data.id)
+ Logger.d(
+ "DmPanel",
+ "processDeletedEnvelope done messageId=${data.id} uiAfter=${_state.messages.size}",
+ )
}
}
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 a7a54be..fc4e8d0 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
@@ -4,6 +4,8 @@ import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@@ -50,7 +52,7 @@ class PublicChatPanel(
scope = scope
) {
private val typingHandler = PublicChatTypingHandler(scope)
- private var networkHistoryLoaded = false
+ private val loadMessagesMutex = Mutex()
/**
* Whether replacing the list would change **structure or message body** (content / edited).
@@ -377,98 +379,119 @@ class PublicChatPanel(
}
override suspend fun loadMessages() {
- hydrateMessagesFromLocalCache()
- if (networkHistoryLoaded) return
- networkHistoryLoaded = true
+ loadMessagesMutex.withLock {
+ hydrateMessagesFromLocalCache()
- val cached = _state.messages
- if (cached.isEmpty()) {
- withContext(Dispatchers.Main) {
- setLoading(true)
- }
- }
-
- // Refresh from network; this may be fast or slow, but runs entirely off main.
- val responseResult = withContext(Dispatchers.Default) {
- runCatching { ApiClient.getMessages(limit = 50) }
- }
- val response = responseResult.getOrNull()
-
- if (response != null && response.messages.isNotEmpty()) {
- val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
- ProfileCache.mergePreviewFromPublicMessages(networkMessages)
- val optimisticSnapshot = snapshotPendingOptimisticMessages()
- val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
- val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf() }
- val loadMsg = "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds stateCount=${_state.messages.size}"
- Logger.d("PublicChatPanel", loadMsg)
- var mergedForCache: List? = null
- withContext(Dispatchers.Main) {
- val shown = snapshotUiMessagesForNetworkMerge()
- Logger.d("PublicChatPanel", "loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}")
- if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
- Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
- val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
- if (withSenders != shown) {
- updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
- }
- if (_state.hasMoreMessages) setHasMoreMessages(false)
- if (_state.isLoading) setLoading(false)
- mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages)
- } else {
- batchStateUpdates {
- val merged = preserveReplyToFromExisting(
- shown,
- mergeNetworkHistoryWithShown(shown, networkMessages),
- )
- clearMessages()
- addMessages(
- ProfileCache.enrichPublicMessagesForDisplay(merged),
- )
- Logger.d("PublicChatPanel", "loadMessages: after addMessages mergedSize=${merged.size} restoring optimistic count=${optimisticSnapshot.size}")
- restorePendingOptimisticMessages(optimisticSnapshot)
- setHasMoreMessages(false) // TODO: Implement has_more from API
- setLoading(false)
- mergedForCache = mergeNetworkHistoryWithShown(
- panelMessagesForDbMerge(),
- networkMessages,
- )
- }
+ val cached = _state.messages
+ if (cached.isEmpty()) {
+ withContext(Dispatchers.Main) {
+ setLoading(true)
}
}
- withContext(Dispatchers.Default) {
- val toPersist = mergedForCache
- ?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
- Logger.d("PublicChatPanel", "loadMessages: persisting to cache messages=${toPersist.size}")
- MessageCacheStore.replacePublicMessages(toPersist)
+
+ // Always refresh from network on open. A retained panel used to skip this after the
+ // first visit (networkHistoryLoaded), so offline bursts left first+last holes until
+ // slow WS catch-up filled them.
+ Logger.d(
+ "PublicChatPanel",
+ "loadMessages: network refresh cachedCount=${cached.size}",
+ )
+ val responseResult = withContext(Dispatchers.Default) {
+ runCatching { ApiClient.getMessages(limit = 50) }
}
- } else if (responseResult.isFailure) {
- val cause = responseResult.exceptionOrNull()
- if (cause is ClientRequestException && cause.response.status.value == 403) {
- MessageCacheStore.clearPublicMessages()
+ val response = responseResult.getOrNull()
+
+ if (response != null && response.messages.isNotEmpty()) {
+ val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
+ ProfileCache.mergePreviewFromPublicMessages(networkMessages)
+ val optimisticSnapshot = snapshotPendingOptimisticMessages()
+ val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
+ val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf() }
+ Logger.d(
+ "PublicChatPanel",
+ "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds " +
+ "stateCount=${_state.messages.size} networkCount=${networkMessages.size}",
+ )
+ var mergedForCache: List? = null
withContext(Dispatchers.Main) {
- clearMessages()
- if (_state.isLoading) setLoading(false)
- if (_state.hasMoreMessages) setHasMoreMessages(false)
+ val shown = snapshotUiMessagesForNetworkMerge()
+ Logger.d(
+ "PublicChatPanel",
+ "loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}",
+ )
+ if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
+ Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
+ val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
+ if (withSenders != shown) {
+ updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
+ }
+ if (_state.hasMoreMessages) setHasMoreMessages(false)
+ if (_state.isLoading) setLoading(false)
+ mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages)
+ } else {
+ batchStateUpdates {
+ val merged = preserveReplyToFromExisting(
+ shown,
+ mergeNetworkHistoryWithShown(shown, networkMessages),
+ )
+ clearMessages()
+ addMessages(
+ ProfileCache.enrichPublicMessagesForDisplay(merged),
+ )
+ Logger.d(
+ "PublicChatPanel",
+ "loadMessages: after addMessages mergedSize=${merged.size} " +
+ "restoring optimistic count=${optimisticSnapshot.size}",
+ )
+ restorePendingOptimisticMessages(optimisticSnapshot)
+ setHasMoreMessages(false) // TODO: Implement has_more from API
+ setLoading(false)
+ mergedForCache = mergeNetworkHistoryWithShown(
+ panelMessagesForDbMerge(),
+ networkMessages,
+ )
+ }
+ }
+ }
+ withContext(Dispatchers.Default) {
+ val toPersist = mergedForCache
+ ?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
+ Logger.d(
+ "PublicChatPanel",
+ "loadMessages: persisting to cache messages=${toPersist.size} replaceAll=true",
+ )
+ MessageCacheStore.replacePublicMessages(toPersist, replaceAll = true)
+ }
+ } else if (responseResult.isFailure) {
+ val cause = responseResult.exceptionOrNull()
+ if (cause is ClientRequestException && cause.response.status.value == 403) {
+ MessageCacheStore.clearPublicMessages()
+ withContext(Dispatchers.Main) {
+ clearMessages()
+ if (_state.isLoading) setLoading(false)
+ if (_state.hasMoreMessages) setHasMoreMessages(false)
+ }
+ } else if (cached.isEmpty()) {
+ withContext(Dispatchers.Main) {
+ if (_state.isLoading) setLoading(false)
+ if (_state.hasMoreMessages) setHasMoreMessages(false)
+ }
+ } else {
+ withContext(Dispatchers.Main) {
+ if (_state.hasMoreMessages) setHasMoreMessages(false)
+ if (_state.isLoading) setLoading(false)
+ }
}
} else if (cached.isEmpty()) {
- // Nothing to show at all; hide spinner so the user is not stuck.
withContext(Dispatchers.Main) {
if (_state.isLoading) setLoading(false)
if (_state.hasMoreMessages) setHasMoreMessages(false)
}
} else {
- // We already displayed cached messages; just mark pagination state.
withContext(Dispatchers.Main) {
- if (_state.hasMoreMessages) setHasMoreMessages(false)
+ if (_state.isLoading) setLoading(false)
}
}
- } else if (cached.isEmpty()) {
- // Nothing to show at all; hide spinner so the user is not stuck.
- withContext(Dispatchers.Main) {
- if (_state.isLoading) setLoading(false)
- if (_state.hasMoreMessages) setHasMoreMessages(false)
- }
}
}
@@ -494,7 +517,7 @@ class PublicChatPanel(
)
}
withContext(Dispatchers.Default) {
- MessageCacheStore.replacePublicMessages(_state.messages)
+ MessageCacheStore.replacePublicMessages(_state.messages, replaceAll = true)
}
}
setHasMoreMessages(false) // TODO: Implement has_more from API
@@ -529,29 +552,47 @@ class PublicChatPanel(
val data = updateMessage.data ?: return
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
DecryptedImageCache.invalidateForMessage(editedMsg.id)
- updateMessage(editedMsg.id) { existing ->
- editedMsg.copy(reply_to = editedMsg.reply_to ?: existing.reply_to)
+ val existing = _state.messages.find { it.id == editedMsg.id }
+ val persisted = editedMsg.copy(reply_to = editedMsg.reply_to ?: existing?.reply_to)
+ updateMessage(editedMsg.id) { current ->
+ persisted.copy(reply_to = persisted.reply_to ?: current.reply_to)
}
withContext(Dispatchers.Default) {
- MessageCacheStore.replacePublicMessages(_state.messages)
+ MessageCacheStore.upsertPublicMessage(persisted.resolvePublicAttachmentLayout())
}
}
"messageDeleted" -> {
val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
+ Logger.i(
+ "PublicChatPanel",
+ "messageDeleted messageId=${deletedData.message_id} " +
+ "uiBefore=${_state.messages.size} inUi=${_state.messages.any { it.id == deletedData.message_id }}",
+ )
DecryptedImageCache.invalidateForMessage(deletedData.message_id)
removeMessage(deletedData.message_id)
clearReplyReferencesTo(deletedData.message_id)
withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(deletedData.message_id)
}
+ Logger.d(
+ "PublicChatPanel",
+ "messageDeleted done messageId=${deletedData.message_id} " +
+ "uiAfter=${_state.messages.size}",
+ )
}
"reactionUpdate" -> {
val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
- handleReactionUpdate(reactionUpdate)
- withContext(Dispatchers.Default) {
- MessageCacheStore.replacePublicMessages(_state.messages)
+ val existing = _state.messages.find { it.id == reactionUpdate.message_id }
+ if (existing != null) {
+ val updated = existing.copy(reactions = reactionUpdate.reactions)
+ handleReactionUpdate(reactionUpdate)
+ withContext(Dispatchers.Default) {
+ MessageCacheStore.upsertPublicMessage(updated.resolvePublicAttachmentLayout())
+ }
+ } else {
+ handleReactionUpdate(reactionUpdate)
}
}
"typing" -> {
@@ -620,6 +661,7 @@ class PublicChatPanel(
cancelQueuedMessage(message)
return
}
+ Logger.d("PublicChatPanel", "handleDeleteMessage messageId=$messageId")
beginMessageDissolve(message)
withContext(Dispatchers.Default) {
MessageRepository.deletePublicMessageById(messageId)
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 78b28a5..c820a44 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
@@ -72,13 +72,28 @@ internal fun mergeDatabaseMessagesWithPanelState(
val mergedClientIds = mergedDb.mapNotNull { it.client_message_id?.trim()?.takeIf { id -> id.isNotEmpty() } }.toSet()
val mergedIds = mergedDb.map { it.id }.toSet()
// Keep in-flight panel optimistics even when the DB Flow emission already stripped them.
+ // Confirmed (id > 0) rows missing from DB are deletes — do not resurrect them from panel state.
+ val droppedConfirmed = panelMessages.filter { panel ->
+ panel.id > 0 && panel.id !in mergedIds
+ }
+ if (droppedConfirmed.isNotEmpty()) {
+ ru.fromchat.Logger.d(
+ "MessageCache",
+ "mergeDbPanel dropConfirmedDeletes count=${droppedConfirmed.size} " +
+ "ids=${droppedConfirmed.map { it.id }.take(12)} " +
+ "panelSize=${panelMessages.size} dbSize=${dbMessages.size}",
+ )
+ }
val extraPanel = panelMessages.filter { panel ->
val cid = panel.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
- when {
- panel.id < 0 && cid != null && cid !in mergedClientIds -> true
- panel.id > 0 && panel.id !in mergedIds && (cid.isNullOrEmpty() || cid !in mergedClientIds) -> true
- else -> false
- }
+ panel.id < 0 && cid != null && cid !in mergedClientIds
+ }
+ if (extraPanel.isNotEmpty()) {
+ ru.fromchat.Logger.d(
+ "MessageCache",
+ "mergeDbPanel keepOptimistic count=${extraPanel.size} " +
+ "ids=${extraPanel.map { it.id }}",
+ )
}
return dedupeMessagesByClientId(
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 3427718..678eb8d 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
@@ -45,6 +45,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -130,9 +131,13 @@ internal fun ChatListHeadlineWithBadge(
title: String,
userId: Int,
) {
+ val profileCacheRevision by ProfileCache.revision.collectAsState()
+ val verificationStatus = remember(userId, profileCacheRevision) {
+ resolveVerificationStatus(userId)
+ }
DisplayName(
displayName = title,
- verificationStatus = resolveVerificationStatus(userId),
+ verificationStatus = verificationStatus,
textStyle = MaterialTheme.typography.bodyLarge,
)
}
@@ -928,7 +933,7 @@ internal fun DmConversationRowContent(
currentUserId = currentUserId,
deleted = cached?.deleted,
suspended = cached?.suspended,
- username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() },
+ username = cached?.username,
)
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
val peerTitle = when {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt
index 0fc8cc8..5b15f96 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt
@@ -10,9 +10,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -422,6 +425,8 @@ fun DevicesScreen(onBack: () -> Unit) {
}
Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ contentWindowInsets = WindowInsets.navigationBars,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
topBar = {
TopAppBar(
@@ -479,10 +484,15 @@ fun DevicesScreen(onBack: () -> Unit) {
LazyColumn(
modifier = Modifier
- .hazeSource(hazeState)
- .padding()
- .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
- contentPadding = innerPadding
+ .fillMaxSize()
+ .consumeWindowInsets(innerPadding)
+ .hazeSource(hazeState),
+ contentPadding = PaddingValues(
+ start = 16.dp,
+ end = 16.dp,
+ top = innerPadding.calculateTopPadding() + 8.dp,
+ bottom = innerPadding.calculateBottomPadding() + 24.dp,
+ ),
) {
item {
Column(Modifier.fillMaxWidth()) {
@@ -569,9 +579,8 @@ fun DevicesScreen(onBack: () -> Unit) {
}
}
- item {
- // Initial load only — background poll must not add/remove list height (overscroll jump).
- if (refreshing && devices.isEmpty()) {
+ if (refreshing && devices.isEmpty()) {
+ item {
Box(
modifier = Modifier
.fillMaxWidth()