From 27ff5e2e4a784f2ccc694cfc46cc42b7be923e6c Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 5 Jul 2026 22:04:55 +0300 Subject: [PATCH] Clear cache on logout Signed-off-by: denis0001-dev --- .../local/cache/FromChatCacheDirs.android.kt | 8 +++ .../kotlin/ru/fromchat/api/ApiClient.kt | 39 ++++++------ .../ru/fromchat/api/PublicChatProfileSync.kt | 10 +++- .../api/local/cache/DecryptedFileCache.kt | 6 ++ .../api/local/cache/DecryptedImageCache.kt | 6 ++ .../api/local/cache/FromChatCacheDirs.kt | 3 + .../local/cache/PendingFileSaveRegistry.kt | 21 ++++++- .../fromchat/api/local/db/LocalCacheWipe.kt | 59 ++++++++++++++++--- .../local/db/store/InstanceRegistryStore.kt | 9 +++ .../api/local/db/store/MessageCacheStore.kt | 19 +++--- .../api/local/db/store/MessageRepository.kt | 4 ++ .../api/local/db/store/ProfileCache.kt | 41 ++++++++++++- .../download/AttachmentDownloadNotifier.kt | 7 +++ .../download/AttachmentDownloadScheduler.kt | 7 +++ .../local/download/DownloadedFileRegistry.kt | 21 ++++++- .../commonMain/kotlin/ru/fromchat/ui/App.kt | 12 ++++ .../kotlin/ru/fromchat/ui/auth/AuthScreen.kt | 30 ++++++++++ .../kotlin/ru/fromchat/ui/chat/ChatInput.kt | 6 +- .../ru/fromchat/ui/chat/MessageDisplayName.kt | 26 ++++++++ .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 11 +++- .../chat/panels/publicchat/PublicChatPanel.kt | 38 ++++++++++-- .../ru/fromchat/ui/main/chats/ChatsTab.kt | 5 +- .../api/local/cache/FromChatCacheDirs.ios.kt | 17 ++++++ 23 files changed, 353 insertions(+), 52 deletions(-) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.android.kt index 649af2d..01b5119 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.android.kt @@ -19,3 +19,11 @@ actual suspend fun wipeAttachmentCacheDirectories() { } } } + +actual suspend fun wipeInstanceAuxiliaryCacheDirectory(instanceId: String) { + withContext(Dispatchers.IO) { + val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_") + if (safe.isEmpty()) return@withContext + File(UtilsLibrary.context.cacheDir, "fromchat/instances/$safe").deleteRecursively() + } +} 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 31855b7..47ab6d8 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -52,12 +52,11 @@ import ru.fromchat.api.instance.InstanceIdGuard import ru.fromchat.api.instance.InstanceIdResolveResult import ru.fromchat.api.instance.resolveInstanceId import ru.fromchat.api.local.WebSocketManager -import ru.fromchat.api.local.db.store.MessageRepository -import ru.fromchat.api.local.send.cancelOutboxProcessing import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.readOutboundFileBytes +import ru.fromchat.api.local.db.clearAccountCacheOnLogout +import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.ProfileCache -import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.download.streamEncryptedFileToDisk import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.schema.calls.CallSignalingLiveKitControl @@ -115,8 +114,6 @@ import ru.fromchat.api.schema.websocket.types.DmTypingData import ru.fromchat.api.schema.websocket.types.SubscribeStatusData import ru.fromchat.config.ServerConfig import ru.fromchat.config.Settings -import ru.fromchat.ui.chat.panels.dm.DmPanelCache -import ru.fromchat.ui.chat.utils.PublicChatPanelCache import kotlin.concurrent.Volatile import kotlin.time.Duration.Companion.milliseconds @@ -236,13 +233,10 @@ object ApiClient { val path = response.call.request.url.encodedPath val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register") if (!isCredentialCheck) { - token = null - user = null - clearSuspensionState() - onAuthError?.let { - MainScope().launch { - it() - } + MainScope().launch { + runCatching { WebSocketManager.disconnect() } + runCatching { clearLocalSession() } + onAuthError?.invoke() } } } @@ -370,6 +364,7 @@ object ApiClient { } Settings.lastKnownServerInstanceId = id CacheContext.setActiveInstance(id, user?.id) + PublicChatProfileSync.ensureStarted() scheduleOutboxProcessing(id) } else -> Unit @@ -1150,6 +1145,17 @@ object ApiClient { } } + /** Clears in-memory and on-disk partial download state on logout. */ + suspend fun clearAllDownloadCachesOnLogout() { + val keys = partialDownloadMetaCache.keys.toList() + keys.forEach { clearPartialEncryptedDownload(it) } + partialDownloadMetaCache.clear() + pausedDownloadIndexCache = emptySet() + pausedDownloadIndexPath()?.let { path -> + runCatching { PlatformFileSystem.delete(path) } + } + } + private fun pausedDownloadIndexPath(): String? { val dir = encryptedDownloadsDir() ?: return null return "$dir/paused_keys.txt" @@ -1383,10 +1389,7 @@ object ApiClient { */ suspend fun clearLocalSession() { val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("") - if (instanceId.isNotEmpty()) { - runCatching { cancelOutboxProcessing(instanceId) } - runCatching { MessageRepository.purgeAllPendingForInstance() } - } + runCatching { clearAccountCacheOnLogout(instanceId) } val uid = user?.id secureSettings.remove("auth_token") settings.remove("user_info") @@ -1399,10 +1402,6 @@ object ApiClient { uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) } UpdateSyncManager.resetInMemoryOnLogout() runCatching { IdentityKeyManager.clearLocalKeys() } - runCatching { ProfileCache.clear() } - runCatching { DmPanelCache.clearAll() } - runCatching { PublicChatProfileCache.clear() } - runCatching { PublicChatPanelCache.clear() } runCatching { CacheContext.clearActive() } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/PublicChatProfileSync.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/PublicChatProfileSync.kt index b2e0b85..7d5ef43 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/PublicChatProfileSync.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/PublicChatProfileSync.kt @@ -2,6 +2,7 @@ package ru.fromchat.api import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -22,6 +23,7 @@ object PublicChatProfileSync { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var started = false + private var syncJob: Job? = null fun ensureStarted() { if (started) return @@ -31,12 +33,18 @@ object PublicChatProfileSync { scope.launch { refreshFromNetworkIfNeeded() } } - scope.launch { + syncJob = scope.launch { runCatching { PublicChatProfileCache.hydrateFromDisk() } syncUntilLoaded() } } + fun resetOnLogout() { + syncJob?.cancel() + syncJob = null + started = false + } + suspend fun refreshFromNetwork(): PublicChatProfile { if (ApiClient.token.isNullOrEmpty()) { return PublicChatProfileCache.profile diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt index 6db6b49..e063483 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt @@ -420,4 +420,10 @@ object DecryptedFileCache { private fun sanitizeKeyPart(value: String): String = value.replace(Regex("[^a-zA-Z0-9._-]"), "_") + + suspend fun clearMemoryCache() { + cacheMutex.withLock { + memoryCache.clear() + } + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedImageCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedImageCache.kt index ae71879..1eeaee6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedImageCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedImageCache.kt @@ -501,4 +501,10 @@ object DecryptedImageCache { private fun invalidatePath(path: String) { runCatching { PlatformFileSystem.delete(path) } } + + suspend fun clearMemoryCache() { + cacheMutex.withLock { + memoryCache.clear() + } + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.kt index f6df1e7..ee66bd6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.kt @@ -8,6 +8,9 @@ expect suspend fun wipeFromChatCacheDirectory() /** Removes decrypted attachment blobs and partial download state outside `fromchat/`. */ expect suspend fun wipeAttachmentCacheDirectories() +/** Deletes `cacheDir/fromchat/instances//` auxiliary files (export index, pending saves). */ +expect suspend fun wipeInstanceAuxiliaryCacheDirectory(instanceId: String) + suspend fun wipeAllOnDiskAttachmentCaches() { wipeFromChatCacheDirectory() wipeAttachmentCacheDirectories() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.kt index 5ae4e5a..e8c807f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/PendingFileSaveRegistry.kt @@ -80,16 +80,31 @@ object PendingFileSaveRegistry { } } - private fun indexPath(): String? { + private fun indexPath(): String? = instanceIndexPath( + runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default", + ) + + private fun instanceIndexPath(instanceId: String): String? { val base = PlatformFileSystem.getAppCacheDirectory() if (base.isEmpty()) return null - val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default" - val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_") + val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_") + if (safe.isEmpty()) return null val dir = "$base/fromchat/instances/$safe" PlatformFileSystem.ensureDirectory(dir) return "$dir/$INDEX_FILE" } + suspend fun clearForInstance(instanceId: String) { + val path = instanceIndexPath(instanceId) ?: return + mutex.withLock { + memory.clear() + diskLoaded = false + } + withContext(Dispatchers.Default) { + runCatching { PlatformFileSystem.delete(path) } + } + } + private suspend fun readIndexFromDisk(): List { val path = indexPath() ?: return emptyList() if (!PlatformFileSystem.exists(path)) return emptyList() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt index 2e8091c..f466bd5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt @@ -2,18 +2,31 @@ package ru.fromchat.api.local.db import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import ru.fromchat.api.local.db.store.MessageDatabaseProvider -import ru.fromchat.api.local.send.cancelOutboxProcessing -import ru.fromchat.api.local.db.store.PublicChatProfileCache +import ru.fromchat.api.ApiClient +import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.cache.DecryptedFileCache +import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.local.cache.PendingFileSaveRegistry import ru.fromchat.api.local.cache.wipeAttachmentCacheDirectories import ru.fromchat.api.local.cache.wipeFromChatCacheDirectory +import ru.fromchat.api.local.cache.wipeInstanceAuxiliaryCacheDirectory +import ru.fromchat.api.local.db.store.InstanceRegistryStore +import ru.fromchat.api.local.db.store.MessageDatabaseProvider +import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.PublicChatProfileCache +import ru.fromchat.api.local.download.AttachmentDownloadNotifier +import ru.fromchat.api.local.download.AttachmentDownloadScheduler +import ru.fromchat.api.local.download.DownloadedFileRegistry +import ru.fromchat.api.local.download.LocalDecodedImageCache +import ru.fromchat.api.local.send.cancelOutboxProcessing import ru.fromchat.ui.chat.panels.dm.DmPanelCache import ru.fromchat.ui.chat.utils.PublicChatPanelCache /** * Drops the on-disk FromChat cache tree and reopens SQLite on next access. - * Call [writeFromChatCacheGeneration] after this when wiping from settings. + * Call [ru.fromchat.api.local.cache.writeFromChatCacheGeneration] after this when wiping from settings. */ suspend fun wipeLocalCacheOnDisk() { val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("") @@ -25,7 +38,39 @@ suspend fun wipeLocalCacheOnDisk() { } wipeFromChatCacheDirectory() wipeAttachmentCacheDirectories() - PublicChatProfileCache.clear() - PublicChatPanelCache.clear() - DmPanelCache.clearAll() + clearInMemoryAccountCaches() +} + +/** + * Clears all per-account cache for the active server instance on logout. + * Preserves other server-instance partitions in SQLite for multi-server use. + */ +suspend fun clearAccountCacheOnLogout(instanceId: String) { + val id = instanceId.trim() + PublicChatProfileSync.resetOnLogout() + if (id.isNotEmpty()) { + cancelOutboxProcessing(id) + runCatching { MessageRepository.purgeAllPendingForInstance() } + runCatching { AttachmentDownloadScheduler.cancelAllOnLogout() } + runCatching { InstanceRegistryStore.purgePartition(id) } + runCatching { InstanceRegistryStore.clearServerBindingForCurrentConfig() } + runCatching { wipeInstanceAuxiliaryCacheDirectory(id) } + runCatching { DownloadedFileRegistry.clearForInstance(id) } + runCatching { PendingFileSaveRegistry.clearForInstance(id) } + } + runCatching { ApiClient.clearAllDownloadCachesOnLogout() } + wipeAttachmentCacheDirectories() + clearInMemoryAccountCaches() +} + +private suspend fun clearInMemoryAccountCaches() { + runCatching { ProfileCache.clear() } + runCatching { PublicChatProfileCache.clear() } + runCatching { DmPanelCache.clearAll() } + PublicChatPanelCache.clear() + AttachmentDownloadNotifier.resetOnLogout() + DecryptedImageCache.clearMemoryCache() + DecryptedFileCache.clearMemoryCache() + MessageRepository.resetListPreviewStringsOnLogout() + LocalDecodedImageCache.evictPrefix("img_") } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt index 55e0d6a..1a99b18 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt @@ -9,6 +9,7 @@ import ru.fromchat.api.instance.configKey import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.ui.chat.utils.PublicChatPanelCache import ru.fromchat.ui.chat.panels.dm.DmPanelCache +import ru.fromchat.config.Settings import kotlin.time.Clock object InstanceRegistryStore { @@ -111,4 +112,12 @@ object InstanceRegistryStore { } } } + + suspend fun clearServerBindingForCurrentConfig() { + withContext(Dispatchers.Default) { + MessageDatabaseProvider.withDatabaseRecover { + db.messageDatabaseQueries.deleteServerBinding(Settings.serverConfig.configKey()) + } + } + } } 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 75d516a..f5e3eee 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 @@ -91,10 +91,10 @@ object MessageCacheStore { } suspend fun loadPublicMessages(): List = - loadMessages(conversationIdForPublic()) + ProfileCache.enrichPublicMessagesForDisplay(loadMessages(conversationIdForPublic())) suspend fun loadRecentPublicMessages(limit: Long): List = - loadRecentMessages(conversationIdForPublic(), limit) + ProfileCache.enrichPublicMessagesForDisplay(loadRecentMessages(conversationIdForPublic(), limit)) fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List { if (instanceId.isBlank()) return emptyList() @@ -105,11 +105,13 @@ object MessageCacheStore { .map { row: DbMessage -> row.toAppMessage() } .reversed() val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) - return sortMessagesForChatDisplay( - validatedOrEmpty( - convId, - dedupeMessagesByClientId( - enrichQueuedOutboundUi(withoutSuperseded, convId), + return ProfileCache.enrichPublicMessagesForDisplay( + sortMessagesForChatDisplay( + validatedOrEmpty( + convId, + dedupeMessagesByClientId( + enrichQueuedOutboundUi(withoutSuperseded, convId), + ), ), ), ) @@ -150,6 +152,7 @@ object MessageCacheStore { } suspend fun replacePublicMessages(messages: List) { + ProfileCache.mergePreviewFromPublicMessages(messages) conversationIdForPublic().let { replaceMessages( it, @@ -225,6 +228,7 @@ object MessageCacheStore { } suspend fun upsertPublicMessage(message: Message) { + ProfileCache.mergePreviewFromPublicMessage(message) upsertSingle(conversationIdForPublic(), message) } @@ -280,6 +284,7 @@ object MessageCacheStore { } suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) { + ProfileCache.mergePreviewFromPublicMessage(confirmed) confirmMessage(conversationIdForPublic(), clientMessageId, confirmed) } 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 7c859d0..172ae51 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 @@ -149,4 +149,8 @@ object MessageRepository { MessageCacheStore.pruneEmptyConversations() suspend fun clearAllCache() = MessageCacheStore.clearAll() + + fun resetListPreviewStringsOnLogout() { + MessageCacheStore.listPreviewStrings = null + } } 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 ca0560b..6056f6f 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 @@ -6,6 +6,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import ru.fromchat.api.ApiClient import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.user.User import ru.fromchat.api.schema.user.profile.UserProfile @@ -182,7 +183,8 @@ object ProfileCache { val existing = get(uid) if (existing != null && !existing.isClientPreviewOnly) return - val uname = message.username.ifBlank { existing?.username ?: return } + val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() } + if (uname.isBlank()) return val display = existing?.displayName?.takeIf { it.isNotBlank() } ?: uname val pic = message.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture @@ -206,6 +208,43 @@ object ProfileCache { ) } + fun mergePreviewFromPublicMessages(messages: Iterable) { + messages.forEach(::mergePreviewFromPublicMessage) + } + + /** + * Fills blank sender fields on a public-chat [Message] from [ProfileCache] or the current user. + */ + fun enrichPublicMessageForDisplay( + message: Message, + currentUserId: Int? = ApiClient.user?.id, + ): Message { + val self = currentUserId + if (self != null && message.user_id == self) { + val user = ApiClient.user + return message.copy( + username = message.username.trim().ifBlank { user?.username.orEmpty() }, + profile_picture = message.profile_picture?.takeIf { it.isNotBlank() } + ?: user?.profile_picture, + ) + } + val profile = get(message.user_id) + return message.copy( + username = message.username.trim().ifBlank { + profile?.visibleUsername(self).orEmpty() + }, + profile_picture = message.profile_picture?.takeIf { it.isNotBlank() } + ?: profile?.profilePicture, + verified = message.verified ?: profile?.verified, + verificationStatus = message.verificationStatus ?: profile?.verificationStatus, + ) + } + + fun enrichPublicMessagesForDisplay( + messages: List, + currentUserId: Int? = ApiClient.user?.id, + ): List = messages.map { enrichPublicMessageForDisplay(it, currentUserId) } + fun onActiveInstanceChanged(instanceId: String) { ioScope.launch { persistMutex.withLock { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadNotifier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadNotifier.kt index 6cf8fb3..548bf35 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadNotifier.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadNotifier.kt @@ -369,4 +369,11 @@ object AttachmentDownloadNotifier { val id = keys.firstOrNull() ?: return DownloadProgressThrottle() return progressThrottleByKey.getOrPut(id) { DownloadProgressThrottle() } } + + fun resetOnLogout() { + _progressPercentByKey.value = emptyMap() + _failedKeys.value = emptySet() + _cancelledKeys.value = emptySet() + progressThrottleByKey.clear() + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadScheduler.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadScheduler.kt index 4eac7d4..38efd4e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadScheduler.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/AttachmentDownloadScheduler.kt @@ -202,6 +202,13 @@ object AttachmentDownloadScheduler { }.thenBy { it.enqueuedAt }, ) } + + suspend fun cancelAllOnLogout() { + val keys = mutex.withLock { + (activeJobs.keys + waiting.map { it.storageKey } + keyToDeferred.keys).toSet() + } + keys.forEach { cancel(it) } + } } internal fun checkAttachmentDownloadActive(storageKey: String) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/DownloadedFileRegistry.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/DownloadedFileRegistry.kt index 04ef180..929b2cd 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/DownloadedFileRegistry.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/DownloadedFileRegistry.kt @@ -142,16 +142,31 @@ object DownloadedFileRegistry { private fun sanitizeKeyPart(value: String): String = value.replace(Regex("[^a-zA-Z0-9._-]"), "_") - private fun indexPath(): String? { + private fun indexPath(): String? = instanceIndexPath( + runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default", + ) + + private fun instanceIndexPath(instanceId: String): String? { val base = PlatformFileSystem.getAppCacheDirectory() if (base.isEmpty()) return null - val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default" - val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_") + val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_") + if (safe.isEmpty()) return null val dir = "$base/fromchat/instances/$safe" PlatformFileSystem.ensureDirectory(dir) return "$dir/$INDEX_FILE" } + suspend fun clearForInstance(instanceId: String) { + val path = instanceIndexPath(instanceId) ?: return + mutex.withLock { + memory.clear() + diskIndexLoaded = false + } + withContext(Dispatchers.Default) { + runCatching { PlatformFileSystem.delete(path) } + } + } + private suspend fun readIndexFromDisk(): Map { val path = indexPath() ?: return emptyMap() if (!PlatformFileSystem.exists(path)) return emptyMap() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 584d9ba..c445695 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -59,8 +59,10 @@ import ru.fromchat.api.DeferredStartupNetwork import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.calls.CallStore +import ru.fromchat.api.instance.bootstrapSessionInstance import ru.fromchat.api.instance.bootstrapSessionOnStartup import ru.fromchat.api.instance.logoutIfInstanceUnsupported +import ru.fromchat.api.instance.scheduleSessionInstanceNetworkRefresh import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration @@ -427,6 +429,16 @@ fun App( composable("auth") { AuthScreen( onAuthSuccess = { + MainScope().launch { + runCatching { + bootstrapSessionInstance( + hasToken = true, + forceNetwork = false, + ) + } + PublicChatProfileSync.ensureStarted() + scheduleSessionInstanceNetworkRefresh() + } WebSocketManager.connect(forceRestart = true) navController.navigateAndWipeBackStack("chat") }, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt index 10f14dd..2810f73 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import com.pr0gramm3r101.utils.crypto.deriveAuthSecret import io.ktor.client.call.body @@ -22,6 +23,8 @@ import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient import ru.fromchat.api.crypto.IdentityKeyManager +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.db.clearAccountCacheOnLogout import ru.fromchat.api.instance.ServerProbeResult import ru.fromchat.api.instance.probeServer import ru.fromchat.api.schema.core.ErrorResponse @@ -98,6 +101,8 @@ private suspend fun fullLogin( password: String, request: suspend () -> LoginResponse, ) { + val previousInstanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("") + runCatching { clearAccountCacheOnLogout(previousInstanceId) } ApiClient.clearMemorySession() val response = request() @@ -257,6 +262,31 @@ fun AuthScreen( bio = "" } + LaunchedEffect(flowState.pagerState) { + var settledPage = flowState.pagerState.currentPage + snapshotFlow { flowState.pagerState.currentPage } + .collect { page -> + if (page < settledPage) { + when (page) { + AuthFlowStep.Username.ordinal -> { + password = "" + confirmPassword = "" + } + + AuthFlowStep.Password.ordinal -> { + password = "" + confirmPassword = "" + } + + AuthFlowStep.ConfirmPassword.ordinal -> { + confirmPassword = "" + } + } + } + settledPage = page + } + } + ExpressiveStepFlowScaffold( flowState = flowState, pages = listOf( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index 19e3e97..460b7b3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -434,6 +434,7 @@ fun ChatInput( enabled = !isReadOnly, modifier = Modifier .weight(1f) + .fillMaxWidth() .align(Alignment.Bottom), textStyle = inputTextStyle, singleLine = false, @@ -452,6 +453,7 @@ fun ChatInput( modifier = Modifier .fillMaxWidth() .defaultMinSize(minHeight = ChatInputIconSlotSize) + .animateContentSize() .align(Alignment.BottomStart), contentAlignment = Alignment.CenterStart, ) { @@ -469,9 +471,7 @@ fun ChatInput( modifier = Modifier.fillMaxWidth(), ) } - Box(modifier = Modifier.animateContentSize()) { - innerTextField() - } + innerTextField() } } }, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt index 213af5b..ac6acc4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageDisplayName.kt @@ -3,9 +3,11 @@ package ru.fromchat.ui.chat import androidx.compose.runtime.Composable import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.api.ApiClient import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.local.db.store.visibleUsername +import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.message_sender_you import ru.fromchat.user_fallback @@ -38,3 +40,27 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String { } return message.username } + +fun messageSenderProfilePicture( + message: Message, + currentUserId: Int? = ApiClient.user?.id, +): String? { + if (currentUserId != null && message.user_id == currentUserId) { + return message.profile_picture?.takeIf { it.isNotBlank() } + ?: ApiClient.user?.profile_picture?.takeIf { it.isNotBlank() } + } + return message.profile_picture?.takeIf { it.isNotBlank() } + ?: ProfileCache.get(message.user_id)?.profilePicture?.takeIf { it.isNotBlank() } +} + +fun messageSenderAvatarLabel( + message: Message, + currentUserId: Int? = ApiClient.user?.id, +): String { + if (currentUserId != null && message.user_id == currentUserId) { + return ApiClient.user?.username?.takeIf { it.isNotBlank() }.orEmpty() + } + return message.username.trim().ifBlank { + ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId).orEmpty() + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 1ec24f8..5ef52bc 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 @@ -56,6 +56,8 @@ import com.pr0gramm3r101.utils.conditional import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.local.cache.DecryptedImageCache +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.api.local.messages.formatMessageTimeLocal import ru.fromchat.api.local.messages.isQueuedOutbound import ru.fromchat.api.schema.messages.Message @@ -123,6 +125,11 @@ fun MessageItem( val editedSuffix = stringResource(Res.string.message_edited_suffix) val sendFailedLabel = stringResource(Res.string.message_send_failed) val displayUsername = messageDisplayUsername(message, currentUserId) + val senderProfile = ProfileCache.get(message.user_id) + val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() } + ?: message.profile_picture + val avatarDisplayName = senderProfile?.visibleUsername(currentUserId)?.takeIf { it.isNotBlank() } + ?: message.username val senderVerificationStatus = resolveVerificationStatus(message.user_id, message) val replyRef = message.reply_to @@ -184,8 +191,8 @@ fun MessageItem( } ) { Avatar( - profilePictureUrl = message.profile_picture, - displayName = message.username, + profilePictureUrl = avatarPictureUrl, + displayName = avatarDisplayName, modifier = Modifier.size(32.dp) ) } 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 eea549a..9982461 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 @@ -12,6 +12,7 @@ import ru.fromchat.api.ApiClient import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID @@ -63,6 +64,25 @@ class PublicChatPanel( return false } + private fun mergePublicSenderFieldsFromNetwork( + shown: List, + fromNetwork: List, + ): List { + val byId = fromNetwork.associateBy { it.id } + return shown.map { message -> + val fresh = byId[message.id] ?: return@map message + ProfileCache.mergePreviewFromPublicMessage(fresh) + ProfileCache.enrichPublicMessageForDisplay( + message.copy( + username = fresh.username, + profile_picture = fresh.profile_picture, + verified = fresh.verified, + verificationStatus = fresh.verificationStatus, + ), + ) + } + } + override val supportsNavigateToSenderProfile: Boolean get() = true @@ -187,7 +207,9 @@ class PublicChatPanel( } private suspend fun ingestIncomingPublicMessage(newMsg: Message) { - addMessage(newMsg) + ProfileCache.mergePreviewFromPublicMessage(newMsg) + val displayMessage = ProfileCache.enrichPublicMessageForDisplay(newMsg) + addMessage(displayMessage) withContext(Dispatchers.Default) { MessageCacheStore.upsertPublicMessage(newMsg) } @@ -252,17 +274,24 @@ class PublicChatPanel( val response = responseResult.getOrNull() if (response != null && response.messages.isNotEmpty()) { + ProfileCache.mergePreviewFromPublicMessages(response.messages) withContext(Dispatchers.Main) { val shown = _state.messages if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, response.messages)) { Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add") + val withSenders = mergePublicSenderFieldsFromNetwork(shown, response.messages) + if (withSenders != shown) { + updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) } + } if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.isLoading) setLoading(false) } else { batchStateUpdates { val merged = mergeNetworkHistoryWithShown(shown, response.messages) clearMessages() - addMessages(merged) + addMessages( + ProfileCache.enrichPublicMessagesForDisplay(merged), + ) setHasMoreMessages(false) // TODO: Implement has_more from API setLoading(false) } @@ -315,10 +344,11 @@ class PublicChatPanel( ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id) } if (response.messages.isNotEmpty()) { - // Prepend older messages (they come in reverse chronological order) + ProfileCache.mergePreviewFromPublicMessages(response.messages) + val older = ProfileCache.enrichPublicMessagesForDisplay(response.messages.reversed()) updateState { currentState -> currentState.copy( - messages = response.messages.reversed() + currentState.messages + messages = older + currentState.messages ) } } 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 3170594..41a38c5 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 @@ -108,6 +108,7 @@ import ru.fromchat.chat_preview_image import ru.fromchat.chat_preview_image_emoji import ru.fromchat.chats_selected_count import ru.fromchat.config.ServerConfig +import ru.fromchat.public_chat import ru.fromchat.search_title import ru.fromchat.status_connecting import ru.fromchat.status_updating @@ -517,7 +518,7 @@ fun ChatsTab( } } - LaunchedEffect(serverConfig, activeInstanceId) { + LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) { if (activeInstanceId.isBlank()) return@LaunchedEffect runCatching { @@ -547,7 +548,9 @@ fun ChatsTab( val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount) val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message) val suspendDefaultReason = stringResource(Res.string.suspended_default_reason) + val publicChatFallbackTitle = stringResource(Res.string.public_chat) val publicChatTitle = publicChatProfile?.title?.takeIf { it.isNotBlank() } + ?: publicChatFallbackTitle.takeIf { activeInstanceId.isNotBlank() } val publicChatLink = publicChatProfile?.let { "https://fromchat.ru/chats/${it.id}" } val deleteConfirmTitle = stringResource(Res.string.chat_delete_confirm_title) val deleteConfirmBody = stringResource(Res.string.chat_delete_confirm_body) diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.ios.kt index 6ebc89a..b3b06a3 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.ios.kt @@ -38,3 +38,20 @@ actual suspend fun wipeAttachmentCacheDirectories() { } } } + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun wipeInstanceAuxiliaryCacheDirectory(instanceId: String) { + withContext(Dispatchers.Default) { + val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_") + if (safe.isEmpty()) return@withContext + val url = NSFileManager.defaultManager.URLForDirectory( + directory = NSCachesDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null, + ) ?: return@withContext + val base = url.path ?: return@withContext + NSFileManager.defaultManager.removeItemAtPath("$base/fromchat/instances/$safe", error = null) + } +}