diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml
index 98d156d..3ecb4d5 100644
--- a/app/android/src/main/AndroidManifest.xml
+++ b/app/android/src/main/AndroidManifest.xml
@@ -19,6 +19,9 @@
android:usesCleartextTraffic="true"
android:name=".App"
android:networkSecurityConfig="@xml/network_security_config">
+
GlobalScope.launch(Dispatchers.IO) {
diff --git a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt
index b2e2298..e3f5fae 100644
--- a/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt
+++ b/app/android/src/main/kotlin/ru/fromchat/notifications/NotificationHelper.kt
@@ -403,7 +403,7 @@ object NotificationHelper {
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
- .setSmallIcon(R.drawable.logo_big)
+ .setSmallIcon(R.drawable.logo)
.setContentTitle(title)
.setContentText(body)
.setStyle(
@@ -506,7 +506,7 @@ object NotificationHelper {
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
- .setSmallIcon(R.drawable.logo_big)
+ .setSmallIcon(R.drawable.logo)
.setStyle(
NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.android.kt
new file mode 100644
index 0000000..8d72459
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.android.kt
@@ -0,0 +1,27 @@
+package ru.fromchat.notifications
+
+import android.content.Context
+import androidx.core.app.NotificationManagerCompat
+import ru.fromchat.Logger
+
+actual object ChatNotificationDismissals {
+ /** Must match [ru.fromchat.notifications.NotificationHelper] summary id in app:android. */
+ private const val SUMMARY_NOTIFICATION_ID = 1_000_000
+
+ @Volatile
+ private var appContext: Context? = null
+
+ fun install(context: Context) {
+ appContext = context.applicationContext
+ }
+
+ actual fun dismissAllMessageNotifications() {
+ val context = appContext ?: return
+ runCatching {
+ NotificationManagerCompat.from(context).cancel(SUMMARY_NOTIFICATION_ID)
+ Logger.d("ChatNotificationDismissals", "Cancelled message notifications")
+ }.onFailure {
+ Logger.w("ChatNotificationDismissals", "Failed to cancel notifications: ${it.message}", it)
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
index 51cc3b8..1968105 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -68,6 +68,7 @@ import ru.fromchat.api.schema.calls.LiveKitTokenResponse
import ru.fromchat.api.schema.core.SimpleStatusResponse
import ru.fromchat.api.schema.messages.MarkReadRequest
import ru.fromchat.api.schema.messages.MessagesResponse
+import ru.fromchat.api.schema.messages.dm.DmArchiveRequest
import ru.fromchat.api.schema.messages.dm.DmConversation
import ru.fromchat.api.schema.messages.dm.DmConversationsResponse
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
@@ -558,12 +559,18 @@ object ApiClient {
}
.body()
- suspend fun getProfileById(userId: Int): UserProfile =
- http
+ suspend fun getProfileById(userId: Int, force: Boolean = false): UserProfile {
+ if (!force) {
+ ProfileCache.get(userId)?.takeIf {
+ !it.isClientPreviewOnly && ProfileCache.hasFreshFullProfile(userId)
+ }?.let { return it }
+ }
+ return http
.get("${ServerConfig.apiBaseUrl}/user/id/$userId") {
contentType(ContentType.Application.Json)
}
.body()
+ }
suspend fun getProfileByUsername(username: String): UserProfile =
http
@@ -681,6 +688,13 @@ object ApiClient {
}
}
+ suspend fun archiveDmConversation(otherUserId: Int, archived: Boolean = true) {
+ http.post("${ServerConfig.apiBaseUrl}/dm/conversations/$otherUserId/archive") {
+ contentType(ContentType.Application.Json)
+ setBody(DmArchiveRequest(archived = archived))
+ }
+ }
+
suspend fun searchUsers(query: String): List {
val trimmed = query.trim()
if (trimmed.length < 2) return emptyList()
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 c44935f..37308a2 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ChatListSync.kt
@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.FlowPreview
-import kotlinx.serialization.json.JsonElement
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ConnectionStateStore
@@ -16,14 +15,14 @@ import ru.fromchat.api.local.db.store.ConnectionStatus
import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
+import ru.fromchat.api.local.messages.PublicInboxCoordinator
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
-import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
/**
- * Keeps the chats tab list in sync: DM conversations from the server and the latest public-chat
- * message for list previews (without opening each chat first).
+ * Keeps the chats tab list in sync: DM conversations from the server and public-chat
+ * previews (cache is filled by the updates pipeline / history rebuild).
*/
@OptIn(FlowPreview::class)
object ChatListSync {
@@ -58,7 +57,14 @@ object ChatListSync {
suspend fun syncFromNetwork() {
if (!canSync()) return
syncDmConversations()
- syncPublicChatPreview()
+ // Preview only — never the sole catch-up path for missed messages.
+ refreshPublicChatPreviewFromLatest()
+ }
+
+ /** Used by tooLong rebuild before per-chat history fetch. */
+ suspend fun syncDmConversationsForRebuild() {
+ if (!canSync()) return
+ syncDmConversations()
}
private fun canSync(): Boolean {
@@ -78,12 +84,13 @@ object ChatListSync {
}
}
- private suspend fun syncPublicChatPreview() {
+ private suspend fun refreshPublicChatPreviewFromLatest() {
runCatching {
val response = ApiClient.getMessages(limit = 1)
val latest = response.messages.maxByOrNull { message ->
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
} ?: return@runCatching
+ // Upsert only — does not wipe older cached messages.
MessageRepository.upsertPublicMessage(latest)
}
}
@@ -100,16 +107,14 @@ object ChatListSync {
}
}
"newMessage" -> message.data?.let { element ->
- scope.launch { ingestPublicMessage(element) }
+ scope.launch { PublicInboxCoordinator.processNew(element) }
+ }
+ "messageEdited" -> message.data?.let { element ->
+ scope.launch { PublicInboxCoordinator.processEdited(element) }
+ }
+ "messageDeleted" -> message.data?.let { element ->
+ scope.launch { PublicInboxCoordinator.processDeleted(element) }
}
}
}
-
- private suspend fun ingestPublicMessage(element: JsonElement) {
- val newMsg = runCatching {
- ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
- }.getOrNull() ?: return
- ProfileCache.mergePreviewFromPublicMessage(newMsg)
- MessageRepository.upsertPublicMessage(newMsg)
- }
}
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 b6424af..77d0be3 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt
@@ -1,28 +1,40 @@
package ru.fromchat.api
import com.pr0gramm3r101.utils.settings.settings
-import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.Logger
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.ConnectionStateStore
+import ru.fromchat.api.local.db.store.MessageCacheStore
+import ru.fromchat.api.local.db.store.MessageRepository
+import ru.fromchat.api.local.db.store.ProfileCache
+import ru.fromchat.api.local.messages.DmInboundMessageProcessor
+import ru.fromchat.api.local.messages.UpdatesBatchApplier
+import ru.fromchat.api.local.messages.parseMessageTimestampMillis
+import ru.fromchat.api.schema.messages.Message
+import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.websocket.WebSocketCredentials
import ru.fromchat.api.schema.websocket.WebSocketMessage
+import ru.fromchat.api.schema.websocket.requests.AckUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse
import kotlin.concurrent.Volatile
/**
- * Tracks the last seen WebSocket update sequence for the current user and
- * persists it between sessions so we can ask the backend for missed updates.
+ * Per-device update cursor: advance + ack only after batches are applied successfully.
*/
object UpdateSyncManager {
private const val KEY_UPDATES_LAST_SEQ_PREFIX = "updates_last_seq_user_"
+ private const val HISTORY_PAGE_SIZE = 50
+ private const val MAX_HISTORY_PAGES = 20
private val _lastSeq = MutableStateFlow(0)
val lastSeq: StateFlow = _lastSeq.asStateFlow()
@@ -30,6 +42,8 @@ object UpdateSyncManager {
private val _lastMissedCount = MutableStateFlow(null)
val lastMissedCount: StateFlow = _lastMissedCount.asStateFlow()
+ private val applyMutex = Mutex()
+
@Volatile
private var gapDetectionInProgress: Boolean = false
@@ -42,22 +56,15 @@ object UpdateSyncManager {
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null)
}
- @OptIn(DelicateCoroutinesApi::class)
- fun onUpdatesBatch(seq: Int) {
- if (seq <= 0) return
- val currentUserId = ApiClient.user?.id ?: return
- val newSeq = seq.coerceAtLeast(_lastSeq.value)
- if (newSeq == _lastSeq.value) return
-
- _lastSeq.value = newSeq
- ConnectionStateStore.updateSeqAndMissed(lastSeq = newSeq, missedCount = _lastMissedCount.value)
-
- val key = KEY_UPDATES_LAST_SEQ_PREFIX + currentUserId
- GlobalScope.launch(Dispatchers.Default) {
- runCatching {
- settings.putInt(key, newSeq)
- }.onFailure {
- Logger.w("UpdateSyncManager", "Failed to persist lastSeq=$newSeq for userId=$currentUserId: ${it.message}", it)
+ /**
+ * Apply a live or replayed updates envelope, then advance cursor and ack the server.
+ */
+ suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
+ applyMutex.withLock {
+ val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: return@withLock
+ if (seq > _lastSeq.value) {
+ persistLastSeq(seq)
+ sendAck(seq)
}
}
}
@@ -81,9 +88,8 @@ object UpdateSyncManager {
}
/**
- * Ask the backend for missed updates between our lastSeq and the current sequence.
- * This call is idempotent while in progress and will no-op if there is no active
- * WebSocket session or no authenticated user.
+ * Catch up from [lastSeq]: chunked getUpdates, or tooLong → history rebuild.
+ * Does not advance the cursor until apply/rebuild succeeds.
*/
suspend fun runGapDetectionIfNeeded() {
if (gapDetectionInProgress) {
@@ -98,52 +104,158 @@ object UpdateSyncManager {
}
gapDetectionInProgress = true
- val startSeq = _lastSeq.value
+ ConnectionStateStore.onUpdating(start = true)
try {
- Logger.i("UpdateSyncManager", "Running gap detection from lastSeq=$startSeq")
- if (startSeq > 0) {
- ConnectionStateStore.onUpdating(start = true)
- }
+ var rounds = 0
+ while (rounds < 100) {
+ rounds++
+ val startSeq = _lastSeq.value
+ Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
- val requestMessage = WebSocketMessage(
- type = "getUpdates",
- credentials = WebSocketCredentials(
- scheme = "Bearer",
- credentials = token
- ),
- data = ApiClient.json.encodeToJsonElement(
- GetUpdatesRequest.serializer(),
- GetUpdatesRequest(lastSeq = startSeq)
+ val response = requestGetUpdates(token, startSeq) ?: break
+ Logger.i(
+ "UpdateSyncManager",
+ "Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
+ "missed=${response.missedCount}, hasMore=${response.hasMore}",
)
- )
+ updateMissedCount(response.missedCount)
- val response = WebSocketManager.request(requestMessage)
- val data = response?.data
-
- if (data != null) {
- runCatching {
- val parsed = ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
- Logger.i(
- "UpdateSyncManager",
- "Gap detection result: status=${parsed.status}, lastSeq=${parsed.lastSeq}, missed=${parsed.missedCount}"
- )
- onUpdatesBatch(parsed.lastSeq)
- updateMissedCount(parsed.missedCount)
- }.onFailure {
- Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it)
+ when (response.status) {
+ "tooLong" -> {
+ val ok = rebuildStateFromHistory()
+ if (ok) {
+ persistLastSeq(response.lastSeq)
+ sendAck(response.lastSeq)
+ } else {
+ Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
+ }
+ break
+ }
+ "ok" -> {
+ if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
+ persistLastSeq(response.lastSeq)
+ sendAck(response.lastSeq)
+ }
+ if (!response.hasMore) break
+ }
+ else -> {
+ Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
+ break
+ }
}
- } else {
- Logger.d("UpdateSyncManager", "No data returned from getUpdates; treating as no-op")
}
} catch (t: Throwable) {
Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t)
} finally {
- if (startSeq > 0) {
- ConnectionStateStore.onUpdating(start = false)
- }
+ ConnectionStateStore.onUpdating(start = false)
gapDetectionInProgress = false
}
}
-}
+ private suspend fun requestGetUpdates(token: String, lastSeq: Int): GetUpdatesResponse? {
+ val requestMessage = WebSocketMessage(
+ type = "getUpdates",
+ credentials = WebSocketCredentials(
+ scheme = "Bearer",
+ credentials = token,
+ ),
+ data = ApiClient.json.encodeToJsonElement(
+ GetUpdatesRequest.serializer(),
+ GetUpdatesRequest(lastSeq = lastSeq),
+ ),
+ )
+ val response = WebSocketManager.request(requestMessage)
+ val data = response?.data ?: return null
+ return runCatching {
+ ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
+ }.onFailure {
+ Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it)
+ }.getOrNull()
+ }
+
+ private suspend fun sendAck(seq: Int) {
+ val token = ApiClient.token ?: return
+ if (seq <= 0) return
+ runCatching {
+ WebSocketManager.request(
+ WebSocketMessage(
+ type = "ackUpdates",
+ credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
+ data = ApiClient.json.encodeToJsonElement(
+ AckUpdatesRequest.serializer(),
+ AckUpdatesRequest(lastSeq = seq),
+ ),
+ ),
+ )
+ }.onFailure {
+ Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
+ }
+ }
+
+ private suspend fun persistLastSeq(seq: Int) {
+ val currentUserId = ApiClient.user?.id ?: return
+ if (seq <= _lastSeq.value) return
+ _lastSeq.value = seq
+ ConnectionStateStore.updateSeqAndMissed(lastSeq = seq, missedCount = _lastMissedCount.value)
+ withContext(Dispatchers.Default) {
+ runCatching {
+ settings.putInt(KEY_UPDATES_LAST_SEQ_PREFIX + currentUserId, seq)
+ }.onFailure {
+ Logger.w("UpdateSyncManager", "Failed to persist lastSeq=$seq: ${it.message}", it)
+ }
+ }
+ }
+
+ suspend fun rebuildStateFromHistory(): Boolean = withContext(Dispatchers.Default) {
+ Logger.i("UpdateSyncManager", "Rebuilding local state from history (tooLong)")
+ runCatching {
+ ChatListSync.syncDmConversationsForRebuild()
+ rebuildPublicHistory()
+ rebuildDmHistories()
+ true
+ }.onFailure {
+ Logger.w("UpdateSyncManager", "rebuildStateFromHistory failed: ${it.message}", it)
+ }.getOrDefault(false)
+ }
+
+ private suspend fun rebuildPublicHistory() {
+ val collected = LinkedHashMap()
+ var beforeId: Int? = null
+ repeat(MAX_HISTORY_PAGES) {
+ val page = ApiClient.getMessages(limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
+ if (page.messages.isEmpty()) return@repeat
+ page.messages.forEach { msg ->
+ ProfileCache.mergePreviewFromPublicMessage(msg)
+ collected[msg.id] = msg
+ }
+ val oldest = page.messages.minByOrNull { it.id } ?: return@repeat
+ if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
+ beforeId = oldest.id
+ }
+ val ordered = collected.values.sortedBy {
+ parseMessageTimestampMillis(it.timestamp) ?: 0L
+ }
+ MessageRepository.replacePublicMessages(ordered)
+ }
+
+ private suspend fun rebuildDmHistories() {
+ val conversations = MessageRepository.loadCachedDmConversations()
+ for (conversation in conversations) {
+ val otherId = conversation.otherUserId
+ MessageCacheStore.clearDmMessages(otherId)
+ var beforeId: Int? = null
+ repeat(MAX_HISTORY_PAGES) {
+ val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
+ if (page.messages.isEmpty()) return@repeat
+ for (envelope in page.messages) {
+ val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope)
+ DmInboundMessageProcessor.processNew(element)
+ }
+ val oldest = page.messages.minByOrNull { it.id } ?: return@repeat
+ if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
+ beforeId = oldest.id
+ }
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt
index 138273d..4a9665d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/WebSocketManager.kt
@@ -38,7 +38,6 @@ import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.websocket.WebSocketCredentials
import ru.fromchat.api.schema.websocket.WebSocketMessage
-import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.config.ServerConfig
import kotlin.concurrent.Volatile
import kotlin.time.Clock
@@ -234,12 +233,11 @@ object WebSocketManager {
val msg = when (messageType) {
"updates" -> {
+ // Apply add/edit/delete before advancing; ack happens inside.
runCatching {
- val updatesData = json.decodeFromJsonElement(
- WebSocketUpdatesData.serializer(), jsonTree)
- UpdateSyncManager.onUpdatesBatch(updatesData.seq)
+ UpdateSyncManager.onUpdatesEnvelope(jsonTree)
}.onFailure {
- logW("Failed to decode updates envelope for seq tracking: ${it.message}", it)
+ logW("Failed to apply updates envelope: ${it.message}", it)
}
WebSocketMessage(
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 3ac83ec..bb47f27 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
@@ -539,7 +539,7 @@ object MessageCacheStore {
.filter { it.type == "dm" }
localDm.forEach { row ->
val otherId = row.otherUserId?.toInt() ?: return@forEach
- if (otherId !in serverOtherUserIds) {
+ if (otherId !in serverOtherUserIds && row.archived == 0L) {
db.messageDatabaseQueries.deleteConversationById(instanceId, row.id)
}
}
@@ -690,6 +690,7 @@ object MessageCacheStore {
id = convId,
)
}
+ DmConversationListNotifier.notifyChanged()
}
suspend fun deleteDmConversation(otherUserId: Int) {
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 854ab75..fa756cc 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
@@ -117,6 +117,7 @@ object MessageRepository {
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
+ ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
}
suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) {
@@ -137,6 +138,7 @@ object MessageRepository {
runCatching { ApiClient.markMessagesRead(ids) }
}
MessageCacheStore.markPublicMessagesReadLocally()
+ ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
}
suspend fun archiveDmConversation(otherUserId: Int) =
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 32a9c65..d68f893 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
@@ -20,6 +20,8 @@ import ru.fromchat.api.schema.user.profile.UserProfile
import ru.fromchat.api.schema.user.profile.VerificationStatus
import ru.fromchat.api.local.cache.CacheContext
import kotlin.concurrent.Volatile
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
/**
* Returns true when a profile entry should not expose its `username` field
@@ -56,18 +58,34 @@ object ProfileCache {
@Volatile
private var loadedInstanceId: String = ""
+ /** Epoch millis when a full (non-preview) profile was last applied from the server. */
+ @Volatile
+ private var fullProfileFetchedAtMs: Map = emptyMap()
+
private val _revision = MutableStateFlow(0)
val revision: StateFlow = _revision.asStateFlow()
private val persistMutex = Mutex()
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ /** 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++
}
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
+ }
+
/** Emits whenever this user's cached profile changes (including bio). */
fun observeUser(userId: Int): Flow =
revision.map { get(userId) }.distinctUntilChanged()
@@ -173,12 +191,20 @@ object ProfileCache {
* When [force] is false, an existing full (non-preview) cache row is kept so a slow HTTP
* response cannot overwrite a fresher WebSocket update.
*/
+ @OptIn(ExperimentalTime::class)
fun applyServerProfile(profile: UserProfile, force: Boolean = false) {
if (profile.id <= 0) return
val normalized = profile.copy(isClientPreviewOnly = false)
+ val nowMs = Clock.System.now().toEpochMilliseconds()
if (!force) {
val existing = get(profile.id)
if (existing != null && !existing.isClientPreviewOnly) {
+ val patched = existing.copy(
+ verified = normalized.verified ?: existing.verified,
+ verificationStatus = normalized.verificationStatus
+ ?: existing.verificationStatus,
+ )
+ if (patched != existing) put(patched)
if (existing.bio != normalized.bio) {
ru.fromchat.Logger.d(
"ProfileCache",
@@ -186,6 +212,8 @@ object ProfileCache {
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'",
)
}
+ // Refresh TTL so force=false callers stop refetching.
+ fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
return
}
}
@@ -195,6 +223,7 @@ object ProfileCache {
"bio='${normalized.bio?.take(48)}'",
)
put(normalized)
+ fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
}
fun remove(userId: Int) {
@@ -297,7 +326,14 @@ object ProfileCache {
val uid = message.user_id
if (uid <= 0) return
val existing = get(uid)
- if (existing != null && !existing.isClientPreviewOnly) return
+ if (existing != null && !existing.isClientPreviewOnly) {
+ val patched = existing.copy(
+ verified = message.verified ?: existing.verified,
+ verificationStatus = message.verificationStatus ?: existing.verificationStatus,
+ )
+ if (patched != existing) put(patched)
+ return
+ }
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
if (uname.isBlank()) return
@@ -375,6 +411,7 @@ object ProfileCache {
} else {
emptyMap()
}
+ fullProfileFetchedAtMs = emptyMap()
pruneUnusableClientPreviewsLocked()
bumpRevision()
}
@@ -428,8 +465,9 @@ object ProfileCache {
suspend fun clear() {
persistMutex.withLock {
profiles = emptyMap()
+ fullProfileFetchedAtMs = emptyMap()
loadedInstanceId = ""
bumpRevision()
}
}
-}
+}
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageOrdering.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageOrdering.kt
index 9b595f0..e2ef8c0 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageOrdering.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageOrdering.kt
@@ -4,16 +4,21 @@ import ru.fromchat.api.schema.messages.Message
/**
* Chat list order: confirmed messages by time, then outgoing queued (negative id) at the bottom.
- * Avoids optimistic rows jumping mid-thread when client clock lags the server.
+ * Pending rows sort by enqueue sequence (monotonic negative id), not hash or wall-clock.
*/
internal fun sortMessagesForChatDisplay(messages: List): List {
if (messages.size <= 1) return messages
val (pending, confirmed) = messages.partition { it.id < 0 }
- val comparator = compareBy(
+ val confirmedComparator = compareBy(
{ messageSortEpochMillis(it) },
{ it.id.toLong() },
)
- return confirmed.sortedWith(comparator) + pending.sortedWith(comparator)
+ // Monotonic ids are -1, -2, … so -id ascending = enqueue order.
+ val pendingComparator = compareBy(
+ { -it.id.toLong() },
+ { it.client_message_id.orEmpty() },
+ )
+ return confirmed.sortedWith(confirmedComparator) + pending.sortedWith(pendingComparator)
}
internal fun messageSortEpochMillis(message: Message): Long =
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt
index 4d9c905..d70ee85 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/MessageTimestamps.kt
@@ -20,8 +20,8 @@ fun nowMessageTimestampIso(): String = Clock.System.now().toString()
* Parse message timestamps from server or client.
*
* - Strings with `Z` / an offset (optimistic client stamps, proper UTC) are true instants.
- * - Zone-less ISO from the API is naive server wall time (`datetime.now().isoformat()`),
- * interpreted in the device zone so HH:mm matches the user's clock.
+ * - Zone-less ISO from the API is naive UTC wall time (`datetime.now().isoformat()`),
+ * interpreted as UTC so local formatting shows the user's clock.
*/
internal fun parseMessageInstant(timestamp: String): Instant? {
val raw = timestamp.trim()
@@ -32,7 +32,7 @@ internal fun parseMessageInstant(timestamp: String): Instant? {
}
val local = parseLocalDateTimeOrNull(normalized) ?: return null
return runCatching {
- local.toInstant(TimeZone.currentSystemDefault())
+ local.toInstant(TimeZone.UTC)
}.getOrNull()
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/OptimisticMessageIds.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/OptimisticMessageIds.kt
index f463d6f..cc7bc60 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/OptimisticMessageIds.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/OptimisticMessageIds.kt
@@ -1,13 +1,42 @@
package ru.fromchat.api.local.messages
+import kotlinx.coroutines.sync.Mutex
import ru.fromchat.api.schema.messages.Message
-import kotlin.math.abs
-/** Stable negative row id for an optimistic / outbox [clientMessageId]. */
+private val optimisticIdMutex = Mutex()
+private var nextOptimisticSeq = 0
+private val idByClientMessageId = mutableMapOf()
+
+private inline fun withOptimisticIdLock(block: () -> T): T {
+ while (!optimisticIdMutex.tryLock()) {
+ // spin — allocation is rare and short
+ }
+ try {
+ return block()
+ } finally {
+ optimisticIdMutex.unlock()
+ }
+}
+
+/**
+ * Negative row id for an optimistic / outbox [clientMessageId].
+ * Monotonic enqueue order (-1, -2, …); stable for the same client id within process.
+ */
fun optimisticMessageIdForClientMessageId(clientMessageId: String): Int {
- val hc = clientMessageId.hashCode()
- val absHc = if (hc == Int.MIN_VALUE) Int.MAX_VALUE else abs(hc)
- return -(if (absHc == 0) 1 else absHc)
+ val key = clientMessageId.trim()
+ if (key.isEmpty()) return nextOptimisticMessageId()
+ return withOptimisticIdLock {
+ idByClientMessageId.getOrPut(key) {
+ nextOptimisticSeq += 1
+ -nextOptimisticSeq
+ }
+ }
+}
+
+/** Fresh monotonic negative id when no client message id is available yet. */
+fun nextOptimisticMessageId(): Int = withOptimisticIdLock {
+ nextOptimisticSeq += 1
+ -nextOptimisticSeq
}
fun Message.isQueuedOutbound(): Boolean = id < 0
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
new file mode 100644
index 0000000..1a86d2c
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/PublicInboxCoordinator.kt
@@ -0,0 +1,80 @@
+package ru.fromchat.api.local.messages
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.JsonElement
+import ru.fromchat.api.ApiClient
+import ru.fromchat.api.local.db.store.MessageRepository
+import ru.fromchat.api.local.db.store.ProfileCache
+import ru.fromchat.api.schema.messages.Message
+import ru.fromchat.api.schema.websocket.types.MessageDeletedData
+
+/**
+ * Global public-chat inbox: persists add/edit/delete into [MessageRepository] even when
+ * no [ru.fromchat.ui.chat.panels.publicchat.PublicChatPanel] is open.
+ */
+object PublicInboxCoordinator {
+ suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) {
+ val message = decodeMessage(element) ?: return@withContext
+ ProfileCache.mergePreviewFromPublicMessage(message)
+ val clientId = message.client_message_id?.trim().orEmpty()
+ val currentUserId = ApiClient.user?.id
+ if (currentUserId != null && message.user_id == currentUserId) {
+ if (clientId.isNotEmpty()) {
+ MessageRepository.confirmPublicMessage(clientId, message)
+ return@withContext
+ }
+ // Server omitted client_message_id — still clear a matching pending row if unique.
+ val pending = MessageRepository.loadPublicMessages()
+ .filter { it.user_id == currentUserId && it.id < 0 }
+ val matchCid = when {
+ pending.size == 1 -> pending.first().client_message_id?.trim()?.takeIf { it.isNotEmpty() }
+ else -> {
+ val content = message.content.trim()
+ pending.singleOrNull {
+ it.content.trim() == content ||
+ (!it.files.isNullOrEmpty() && !message.files.isNullOrEmpty())
+ }?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
+ }
+ }
+ if (matchCid != null) {
+ MessageRepository.confirmPublicMessage(
+ matchCid,
+ message.copy(client_message_id = matchCid),
+ )
+ return@withContext
+ }
+ }
+ MessageRepository.upsertPublicMessage(message)
+ }
+
+ suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) {
+ val edited = decodeMessage(element) ?: return@withContext
+ ProfileCache.mergePreviewFromPublicMessage(edited)
+ val existing = MessageRepository.loadPublicMessages()
+ val merged = existing.map { current ->
+ if (current.id == edited.id) {
+ edited.copy(reply_to = edited.reply_to ?: current.reply_to)
+ } else {
+ current
+ }
+ }
+ if (merged.none { it.id == edited.id }) {
+ MessageRepository.upsertPublicMessage(edited)
+ } else {
+ MessageRepository.replacePublicMessages(merged)
+ }
+ }
+
+ suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) {
+ val deleted = runCatching {
+ ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
+ }.getOrNull() ?: return@withContext
+ MessageRepository.deletePublicMessageById(deleted.message_id)
+ }
+
+ private fun decodeMessage(element: JsonElement): Message? =
+ runCatching {
+ ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
+ }.getOrNull()
+}
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
new file mode 100644
index 0000000..8f87b4f
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/messages/UpdatesBatchApplier.kt
@@ -0,0 +1,45 @@
+package ru.fromchat.api.local.messages
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import ru.fromchat.Logger
+import ru.fromchat.api.ApiClient
+import ru.fromchat.api.schema.websocket.WebSocketMessage
+import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
+
+/**
+ * Applies a single updates batch in order (add/edit/delete) before the cursor advances.
+ */
+object UpdatesBatchApplier {
+ private val mutex = Mutex()
+ private val publicTypes = setOf("newMessage", "messageEdited", "messageDeleted")
+ private val dmTypes = setOf("dmNew", "dmDeleted", "dmEdited")
+
+ suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
+ val envelope = runCatching {
+ ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
+ }.getOrNull() ?: return@withLock null
+
+ for (update in envelope.updates) {
+ applyOne(WebSocketMessage(type = update.type, data = update.data))
+ }
+ envelope.seq
+ }
+
+ suspend fun applyOne(message: WebSocketMessage) {
+ when (message.type) {
+ "updates" -> {
+ val data = message.data ?: return
+ applyEnvelope(data)
+ }
+ "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)
+ else -> Unit
+ }
+ }
+
+ fun isCacheAffecting(type: String): Boolean =
+ type in publicTypes || type in dmTypes
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/AckUpdatesRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/AckUpdatesRequest.kt
new file mode 100644
index 0000000..13b4c25
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/AckUpdatesRequest.kt
@@ -0,0 +1,9 @@
+package ru.fromchat.api.schema.websocket.requests
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class AckUpdatesRequest(
+ @SerialName("lastSeq") val lastSeq: Int,
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/GetUpdatesResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/GetUpdatesResponse.kt
index 68f2d20..39c5242 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/GetUpdatesResponse.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/websocket/requests/GetUpdatesResponse.kt
@@ -7,5 +7,6 @@ import kotlinx.serialization.Serializable
data class GetUpdatesResponse(
val status: String,
@SerialName("lastSeq") val lastSeq: Int,
- @SerialName("missedCount") val missedCount: Int
-)
\ No newline at end of file
+ @SerialName("missedCount") val missedCount: Int,
+ @SerialName("hasMore") val hasMore: Boolean = false,
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.kt
new file mode 100644
index 0000000..d300df3
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.kt
@@ -0,0 +1,6 @@
+package ru.fromchat.notifications
+
+/** Platform bridge so shared mark-read can dismiss message notifications. */
+expect object ChatNotificationDismissals {
+ fun dismissAllMessageNotifications()
+}
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 6079d0a..f6fe72a 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
@@ -12,6 +12,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.messages.generateClientMessageId
import ru.fromchat.api.local.messages.nowMessageTimestampIso
+import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.messages.Message
@@ -611,7 +612,7 @@ abstract class ChatPanel(
val tempId = generateClientMessageId()
val newOptimistic = message.copy(
- id = uniqueOptimisticMessageId(),
+ id = optimisticMessageIdForClientMessageId(tempId),
client_message_id = tempId
)
@@ -698,7 +699,7 @@ abstract class ChatPanel(
)
// Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
- val optimistic = tempMessage.copy(id = uniqueOptimisticMessageId())
+ val optimistic = tempMessage.copy(id = optimisticMessageIdForClientMessageId(tempId))
addMessage(optimistic)
Logger.d(
@@ -743,14 +744,6 @@ abstract class ChatPanel(
}
}
- private suspend fun uniqueOptimisticMessageId(): Int = addMessageMutex.withLock {
- var id: Int
- do {
- id = -kotlin.random.Random.nextInt(1, Int.MAX_VALUE)
- } while (_state.messages.any { it.id == id })
- id
- }
-
/** Persist optimistic row for offline / process death; no-op by default. */
protected open suspend fun persistOptimisticMessage(message: Message) {}
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 d36a890..d8961b6 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
@@ -315,10 +315,19 @@ fun ChatScreen(
visitEnterSyncComplete = true
}
- // First non-empty load for this visit: never animate existing history.
+ // First non-empty load for this visit: seed history without animating.
+ // Exception: empty → single live/own message should animate (first bubble in chat).
if (!enterAnimationsSeeded || previousFingerprint.isEmpty()) {
- seedWithoutAnimating("seed_first_load")
- return@LaunchedEffect
+ val isLiveFirstMessage =
+ previousCount == 0 &&
+ messages.size == 1 &&
+ sizeDelta == 1
+ if (!isLiveFirstMessage) {
+ seedWithoutAnimating("seed_first_load")
+ return@LaunchedEffect
+ }
+ enterAnimationsSeeded = true
+ visitEnterSyncComplete = true
}
previousNewestFingerprint = fingerprint
@@ -413,6 +422,7 @@ fun ChatScreen(
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
val haptic = rememberHapticFeedback()
val navController = LocalNavController.current
+ val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val profileUserId = panelState.profileUserId
@@ -701,6 +711,7 @@ fun ChatScreen(
msg.id == menuMessage.id
}
}
+
if (liveMessage == null) {
contextMenuState = contextMenuState.copy(isOpen = false, message = null)
return@LaunchedEffect
@@ -1376,7 +1387,9 @@ fun ChatScreen(
ChatTopBar(
hazeState = hazeState,
- onBack = { navController.navigateUp() },
+ onBack = {
+ runNav { navController.navigateUp() }
+ },
backContentDescription = stringResource(Res.string.back),
showCallButton = panel.showCallButton() && !isReadOnly,
onCallClick = {
@@ -1391,7 +1404,9 @@ fun ChatScreen(
title = panelState.title,
titleAvatar = panelState.titleAvatar,
profileUserId = profileUserId,
- onTitleClick = onTitleClick,
+ onTitleClick = onTitleClick?.let { click ->
+ { runNav(click) }
+ },
hideTitleBarAvatar = hideTitleBarAvatar,
onAvatarSlotBounds = onAvatarSlotBounds,
sharedTransitionScope = sharedTransitionScope,
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 80a28da..e98dd12 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
@@ -32,8 +32,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
+import androidx.lifecycle.Lifecycle
+import androidx.navigation.NavController
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
@@ -68,6 +74,7 @@ import ru.fromchat.ui.profile.StatusBadge
import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.ui.profile.resolveVerificationStatus
import ru.fromchat.api.local.db.store.ProfileCache
+import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.ApiClient
import com.pr0gramm3r101.utils.scaleOnPress
import kotlin.math.PI
@@ -258,8 +265,12 @@ fun ChatTopBarInner(
}
key == "typing" -> {
+ val me = ApiClient.user?.id
TypingIndicator(
- typingUsers = currentTypingUsers.map { it.username },
+ typingUsers = currentTypingUsers.map { user ->
+ ProfileCache.get(user.userId)?.visibleDisplayName(me)
+ ?: user.username
+ },
showUsernames = typingShowsUsernames,
modifier = Modifier.padding(top = 2.dp),
)
@@ -447,3 +458,28 @@ fun rememberChatSurfaceContainerHazeStyle(): HazeStyle {
)
}
}
+
+/**
+ * Single-flight gate for chat title/avatar navigate vs back during shared-element transitions.
+ * Ignores taps while a transition is running or the current destination is not resumed.
+ */
+@Composable
+fun rememberChatNavigationGate(
+ navController: NavController,
+ animatedVisibilityScope: AnimatedVisibilityScope? = null,
+): (() -> Unit) -> Unit {
+ var locked by remember { mutableStateOf(false) }
+ val entry = navController.currentBackStackEntry
+ LaunchedEffect(entry) {
+ locked = false
+ }
+ return { action ->
+ val transitionRunning = animatedVisibilityScope?.transition?.isRunning == true
+ val lifecycleOk = navController.currentBackStackEntry?.lifecycle?.currentState
+ ?.let { it == Lifecycle.State.RESUMED } != false
+ if (!locked && !transitionRunning && lifecycleOk) {
+ locked = true
+ action()
+ }
+ }
+}
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 7662a90..af021dc 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
@@ -22,7 +22,6 @@ 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.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
@@ -715,7 +714,7 @@ fun ImageFullscreenPreview(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
- .windowInsetsPadding(WindowInsets.systemBars),
+ .windowInsetsPadding(WindowInsets.safeDrawing),
) {
AnimatedVisibility(
visible = effectiveMenusVisible,
@@ -796,20 +795,24 @@ fun ImageFullscreenPreview(
},
)
}
- DropdownMenuItem(
- text = {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(Icons.Rounded.Delete, null, tint = Color.White)
- Spacer(Modifier.width(8.dp))
- Text(labelDelete, color = Color.White)
- }
- },
- onClick = {
- menuExpanded = false
- onDelete(message)
- dismissRequested = true
+ if (currentUserId != null &&
+ (message.user_id == currentUserId || currentUserId == 1)
+ ) {
+ DropdownMenuItem(
+ text = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Rounded.Delete, null, tint = Color.White)
+ Spacer(Modifier.width(8.dp))
+ Text(labelDelete, color = Color.White)
+ }
+ },
+ onClick = {
+ menuExpanded = false
+ onDelete(message)
+ dismissRequested = true
+ },
+ )
}
- )
}
}
}
@@ -821,7 +824,7 @@ fun ImageFullscreenPreview(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
- .windowInsetsPadding(WindowInsets.systemBars),
+ .windowInsetsPadding(WindowInsets.safeDrawing),
) {
AnimatedVisibility(
visible = effectiveMenusVisible && message.content.isNotBlank(),
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 9604745..d64e3f0 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
@@ -204,7 +204,6 @@ fun MessageItem(
isMessageCorrupted(message)
}
val primaryIsImageMessage = remember(
- message.reply_to,
message.pendingFileUri,
message.pendingFilename,
message.files,
@@ -219,10 +218,8 @@ fun MessageItem(
)
else -> false
}
- message.reply_to == null && (
- pendingIsImage ||
- message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
- )
+ pendingIsImage ||
+ message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
}
val formattedTime = remember(message.timestamp) {
formatMessageTimeLocal(message.timestamp)
@@ -569,10 +566,8 @@ fun MessageItem(
} else {
null
}
- val primaryIsImageContent = message.reply_to == null && (
- pendingIsImage ||
- message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
- )
+ val primaryIsImageContent = pendingIsImage ||
+ message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
val hasImageCaption =
message.content.isNotBlank() &&
!isFilenameOnlyMessageCaption(message)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt
index ed23961..494f841 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt
@@ -12,6 +12,7 @@ import ru.fromchat.api.local.cache.CacheContext
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import ru.fromchat.api.local.db.store.ProfileCache
+import ru.fromchat.ui.chat.rememberChatNavigationGate
import ru.fromchat.utils.haptic.HapticFeedbackEvent
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.utils.haptic.rememberHapticFeedback
@@ -46,6 +47,7 @@ fun DmChatRoute(
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
val haptic = rememberHapticFeedback()
+ val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
DmScreen(
@@ -54,8 +56,10 @@ fun DmChatRoute(
modifier = modifier.fillMaxSize(),
scrollToMessageId = scrollToMessageId,
onTitleClick = {
- haptic(HapticFeedbackEvent.ProfileOpened)
- navController.navigate(DmNav.profileRoute(otherUserId))
+ runNav {
+ haptic(HapticFeedbackEvent.ProfileOpened)
+ navController.navigate(DmNav.profileRoute(otherUserId))
+ }
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
@@ -74,6 +78,7 @@ fun DmProfileRoute(
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
val haptic = rememberHapticFeedback()
+ val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
@@ -92,12 +97,16 @@ fun DmProfileRoute(
userId = otherUserId,
showBackButton = true,
onBack = {
- haptic(HapticFeedbackEvent.ProfileClosed)
- navController.popBackStack()
+ runNav {
+ haptic(HapticFeedbackEvent.ProfileClosed)
+ navController.popBackStack()
+ }
},
onChat = {
- haptic(HapticFeedbackEvent.ProfileClosed)
- navController.popBackStack()
+ runNav {
+ haptic(HapticFeedbackEvent.ProfileClosed)
+ navController.popBackStack()
+ }
},
modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt
index 3f0e360..12c9a15 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt
@@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.PublicChatProfileCache
+import ru.fromchat.ui.chat.rememberChatNavigationGate
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.ui.profile.PublicChatProfileScreen
import ru.fromchat.utils.haptic.HapticFeedbackEvent
@@ -31,6 +32,7 @@ fun PublicChatChatRoute(
modifier: Modifier = Modifier,
) {
val haptic = rememberHapticFeedback()
+ val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
PublicChatScreen(
scrollToMessageId = scrollToMessageId,
@@ -38,8 +40,10 @@ fun PublicChatChatRoute(
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
onTitleClick = {
- haptic(HapticFeedbackEvent.ProfileOpened)
- navController.navigate(PublicChatNav.PROFILE_ROUTE)
+ runNav {
+ haptic(HapticFeedbackEvent.ProfileOpened)
+ navController.navigate(PublicChatNav.PROFILE_ROUTE)
+ }
},
modifier = modifier.fillMaxSize(),
)
@@ -57,6 +61,7 @@ fun PublicChatProfileRoute(
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
}
val haptic = rememberHapticFeedback()
+ val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
?: stateSnapshot.title.takeIf { it.isNotBlank() }
@@ -65,12 +70,16 @@ fun PublicChatProfileRoute(
PublicChatProfileScreen(
showBackButton = true,
onBack = {
- haptic(HapticFeedbackEvent.ProfileClosed)
- navController.popBackStack()
+ runNav {
+ haptic(HapticFeedbackEvent.ProfileClosed)
+ navController.popBackStack()
+ }
},
onChat = {
- haptic(HapticFeedbackEvent.ProfileClosed)
- navController.popBackStack()
+ runNav {
+ haptic(HapticFeedbackEvent.ProfileClosed)
+ navController.popBackStack()
+ }
},
modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope,
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 ebcda37..a7a54be 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
@@ -236,6 +236,7 @@ class PublicChatPanel(
/**
* Match optimistic rows via [Message.client_message_id] from the server ack (never by text).
+ * When the server omits client_message_id, fall back to a single pending own optimistic.
*/
private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) {
val laidOut = newMsg.resolvePublicAttachmentLayout()
@@ -247,12 +248,56 @@ class PublicChatPanel(
return
}
if (laidOut.id > 0 && _state.messages.any { it.id == laidOut.id }) {
+ // Already confirmed in UI — still try to clear a leftover optimistic.
+ matchPendingOptimisticClientId(laidOut)?.let { pendingCid ->
+ handleMessageConfirmed(
+ pendingCid,
+ laidOut.copy(client_message_id = pendingCid),
+ )
+ }
+ return
+ }
+ matchPendingOptimisticClientId(laidOut)?.let { pendingCid ->
+ handleMessageConfirmed(
+ pendingCid,
+ laidOut.copy(client_message_id = pendingCid),
+ )
return
}
}
ingestIncomingPublicMessage(laidOut)
}
+ /** Best-effort: unique pending own optimistic that looks like [confirmed]. */
+ private fun matchPendingOptimisticClientId(confirmed: Message): String? {
+ val pending = snapshotPendingOptimisticMessages().filter {
+ it.user_id == confirmed.user_id && it.id < 0
+ }
+ if (pending.isEmpty()) return null
+ val confirmedCid = confirmed.client_message_id?.trim().orEmpty()
+ if (confirmedCid.isNotEmpty()) {
+ pending.firstOrNull { it.client_message_id?.trim() == confirmedCid }
+ ?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
+ ?.let { return it }
+ }
+ if (pending.size == 1) {
+ return pending.first().client_message_id?.trim()?.takeIf { it.isNotEmpty() }
+ }
+ val confirmedTime = ru.fromchat.api.local.messages.parseMessageTimestampMillis(confirmed.timestamp)
+ val content = confirmed.content.trim()
+ val matches = pending.filter { opt ->
+ val optCid = opt.client_message_id?.trim().orEmpty()
+ if (optCid.isEmpty()) return@filter false
+ if (opt.content.trim() != content && confirmed.files.isNullOrEmpty() && opt.files.isNullOrEmpty()) {
+ return@filter false
+ }
+ val optTime = ru.fromchat.api.local.messages.parseMessageTimestampMillis(opt.timestamp)
+ confirmedTime == null || optTime == null ||
+ kotlin.math.abs(confirmedTime - optTime) <= 180_000L
+ }
+ return matches.singleOrNull()?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
+ }
+
private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
ProfileCache.mergePreviewFromPublicMessage(newMsg)
val withReply = attachPublicReplyReferences(_state.messages + newMsg).last()
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageListDedup.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageListDedup.kt
index 6b12bc1..c39a69f 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageListDedup.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/MessageListDedup.kt
@@ -35,10 +35,10 @@ internal fun messageDedupeKey(msg: Message): String {
/**
* Drops optimistic rows already represented by a confirmed message (same client id),
- * or legacy near-duplicate own rows that have no client id.
+ * or near-duplicate own rows when the confirmed ack omitted client_message_id.
*
- * In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept —
- * time-based heuristics must not remove them (that aborted enter animations mid-spring).
+ * In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept
+ * unless a confirmed own row without client id is a clear near-duplicate (1:1 pairing).
*/
internal fun dropSupersededOptimisticMessages(
messages: List,
@@ -48,17 +48,27 @@ internal fun dropSupersededOptimisticMessages(
val confirmed = messages.filter { it.id > 0 }
val confirmedClientIds = confirmed.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet()
val self = currentUserId
+ val usedConfirmedIds = mutableSetOf()
return messages.filter { msg ->
if (msg.id >= 0) return@filter true
val cid = msg.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty() && cid in confirmedClientIds) return@filter false
- // Stable client id still in flight — never drop via time heuristics.
- if (cid.isNotEmpty()) return@filter true
- // In-flight uploads (file or image): keep until a confirmed row shares the same client id.
- if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true
if (self == null || msg.user_id != self) return@filter true
+
+ // Match confirmed acks that omitted client_message_id (phantom duplicate).
+ if (cid.isNotEmpty()) {
+ val matched = findNearOwnConfirmedWithoutClientId(msg, confirmed, self, usedConfirmedIds)
+ if (matched != null) {
+ usedConfirmedIds.add(matched.id)
+ return@filter false
+ }
+ return@filter true
+ }
+
+ // Legacy: no client id on pending — time-based near-dup.
+ if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true
val msgTime = parseMessageTimestampMillis(msg.timestamp)
- val nearOwnConfirmed = confirmed.filter { it.user_id == self }.any { confirmedMsg ->
+ val nearOwnConfirmed = confirmed.filter { it.user_id == self && it.id !in usedConfirmedIds }.any { confirmedMsg ->
val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp)
msgTime != null && confirmedTime != null &&
abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS
@@ -77,6 +87,34 @@ internal fun dropSupersededOptimisticMessages(
private const val NEAR_DUPLICATE_MS = 180_000L
+private fun findNearOwnConfirmedWithoutClientId(
+ pending: Message,
+ confirmed: List,
+ self: Int,
+ usedConfirmedIds: Set,
+): Message? {
+ val msgTime = parseMessageTimestampMillis(pending.timestamp) ?: return null
+ val pendingIsAttachment = !pending.files.isNullOrEmpty() ||
+ pending.pendingFileUri != null ||
+ !pending.uploadJobId.isNullOrBlank()
+ val candidates = confirmed.filter { confirmedMsg ->
+ if (confirmedMsg.user_id != self) return@filter false
+ if (confirmedMsg.id in usedConfirmedIds) return@filter false
+ if (!confirmedMsg.client_message_id.isNullOrBlank()) return@filter false
+ val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp) ?: return@filter false
+ if (abs(msgTime - confirmedTime) > NEAR_DUPLICATE_MS) return@filter false
+ val confirmedHasAttachment = !confirmedMsg.files.isNullOrEmpty()
+ val contentMatches = pending.content.trim() == confirmedMsg.content.trim()
+ when {
+ pendingIsAttachment && confirmedHasAttachment -> true
+ !pendingIsAttachment && !confirmedHasAttachment && contentMatches -> true
+ !pendingIsAttachment && confirmedHasAttachment && contentMatches -> true
+ else -> false
+ }
+ }
+ return candidates.minByOrNull { abs((parseMessageTimestampMillis(it.timestamp) ?: 0L) - msgTime) }
+}
+
private fun preferMessageForDedupe(existing: Message, incoming: Message): Message {
val preferred = when {
incoming.id > 0 && existing.id < 0 -> incoming
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 49e41c9..78b28a5 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
@@ -28,7 +28,12 @@ internal fun attachPublicReplyReferences(
return messages.map { msg ->
val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg
val nested = msg.reply_to
- if (nested != null && nested.content.isNotBlank()) return@map msg
+ if (
+ nested != null &&
+ (nested.content.isNotBlank() || !nested.files.isNullOrEmpty())
+ ) {
+ return@map msg
+ }
byId[replyId]?.let { msg.copy(reply_to = it, replyToId = replyId) } ?: msg
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt
index 5b7fd67..446dd02 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.imePadding
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.foundation.lazy.rememberLazyListState
@@ -247,6 +248,7 @@ fun ChatsSearchScreen(
placeholder = searchBarHint,
modifier = Modifier
.fillMaxWidth()
+ .statusBarsPadding()
.padding(12.dp),
leadingIcon = {
IconButton(onClick = {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
index eeee630..9e3db6b 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt
@@ -654,27 +654,11 @@ fun ChatsTab(
fun deleteChats(userIds: Set) {
scope.launch {
- var failures = 0
-
userIds.forEach { otherUserId ->
- var ok = true
-
- val messages = runCatching { MessageRepository.loadDmMessages(otherUserId) }
- .getOrDefault(emptyList())
- .filter { it.id > 0 }
-
- messages.forEach { msg ->
- runCatching {
- ApiClient.deleteDm(msg.id, otherUserId)
- }.onFailure {
- ok = false
- }
- }
runCatching {
- MessageRepository.deleteDmConversation(otherUserId)
- }.onFailure { ok = false }
-
- if (!ok) failures++
+ ApiClient.archiveDmConversation(otherUserId, archived = true)
+ MessageRepository.archiveDmConversation(otherUserId)
+ }
}
refreshDmList()
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 afffe27..0fc8cc8 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
@@ -390,7 +390,7 @@ fun DevicesScreen(onBack: () -> Unit) {
}
val hadContent = devices.isNotEmpty()
- if (hadContent) refreshing = true
+ if (!hadContent) refreshing = true
runCatching { ApiClient.listDevices() }
.onSuccess { list ->
@@ -570,11 +570,8 @@ fun DevicesScreen(onBack: () -> Unit) {
}
item {
- AnimatedVisibility(
- visible = refreshing,
- enter = fadeIn(),
- exit = fadeOut(),
- ) {
+ // Initial load only — background poll must not add/remove list height (overscroll jump).
+ if (refreshing && devices.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerificationStatusResolver.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerificationStatusResolver.kt
index 6c73289..867d99c 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerificationStatusResolver.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerificationStatusResolver.kt
@@ -18,13 +18,17 @@ fun resolveVerificationStatus(
message: Message? = null,
user: User? = null,
): VerificationStatus? {
- ProfileCache.get(userId)?.effectiveVerificationStatus()?.let { cached ->
- if (cached != VerificationStatus.None || ProfileCache.get(userId)?.verificationStatus != null) {
- return cached
+ ProfileCache.get(userId)?.let { cached ->
+ if (cached.verificationStatus != null || cached.verified != null) {
+ return cached.effectiveVerificationStatus()
}
}
- user?.effectiveVerificationStatus()?.let { return it }
+ user?.let { u ->
+ if (u.verificationStatus != null || u.verified != null) {
+ return u.effectiveVerificationStatus()
+ }
+ }
message?.verificationStatus?.let { return it }
message?.verified?.let { return if (it) VerificationStatus.Verified else null }
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.ios.kt
new file mode 100644
index 0000000..8811b42
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/notifications/ChatNotificationDismissals.ios.kt
@@ -0,0 +1,5 @@
+package ru.fromchat.notifications
+
+actual object ChatNotificationDismissals {
+ actual fun dismissAllMessageNotifications() = Unit
+}