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 +}