From 5a28f0c756549442f00413f66258e682be542db3 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 5 Jul 2026 21:14:03 +0300 Subject: [PATCH] Fix bugs from test #2 Signed-off-by: denis0001-dev --- app/android/build.gradle.kts | 20 +- app/android/proguard-rules.pro | 54 +- app/shared/build.gradle.kts | 2 - .../kotlin/ru/fromchat/api/ApiClient.kt | 14 +- .../ru/fromchat/api/PublicChatProfileSync.kt | 77 +++ .../api/instance/SessionInstanceBootstrap.kt | 6 +- .../ru/fromchat/api/local/WebSocketManager.kt | 67 +- .../fromchat/api/local/cache/CacheContext.kt | 2 + .../local/db/MessageDatabaseExpectedSchema.kt | 11 + .../local/db/MessageDatabaseSchemaGuard.kt | 1 + .../local/db/store/InstanceRegistryStore.kt | 1 + .../api/local/db/store/MessageCacheStore.kt | 87 ++- .../api/local/db/store/MessageRepository.kt | 20 + .../api/local/db/store/ProfileCache.kt | 9 +- .../local/db/store/PublicChatProfileCache.kt | 80 ++- .../db/store/PublicChatProfileCacheStore.kt | 52 ++ .../workers/AttachmentTransferBootstrap.kt | 2 + .../fromchat/api/schema/messages/Message.kt | 3 + .../ru/fromchat/api/schema/user/User.kt | 5 +- .../schema/user/profile/ProfileDialogData.kt | 1 + .../schema/user/profile/SimilarityResult.kt | 9 - .../api/schema/user/profile/UserProfile.kt | 1 + .../schema/user/profile/VerificationStatus.kt | 23 + .../api/schema/user/profile/VerifyResponse.kt | 4 +- .../commonMain/kotlin/ru/fromchat/ui/App.kt | 21 +- .../ru/fromchat/ui/calls/CallOverlay.kt | 12 +- .../ru/fromchat/ui/chat/AttachmentPreview.kt | 83 ++- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 13 +- .../kotlin/ru/fromchat/ui/chat/ChatTopBar.kt | 28 +- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 49 +- .../chat/panels/publicchat/PublicChatPanel.kt | 105 ++- .../panels/publicchat/PublicChatScreen.kt | 5 +- .../fromchat/ui/main/chats/ChatListShared.kt | 24 +- .../ui/main/chats/ChatsSearchScreen.kt | 45 +- .../ru/fromchat/ui/main/chats/ChatsTab.kt | 78 ++- .../ui/main/settings/DevicesScreen.kt | 61 +- .../ru/fromchat/ui/profile/DisplayName.kt | 44 ++ .../ru/fromchat/ui/profile/ProfileScreen.kt | 603 +++++++++--------- .../ru/fromchat/ui/profile/StatusBadge.kt | 42 +- .../ui/profile/VerificationStatusResolver.kt | 33 + .../ru/fromchat/db/MessageDatabase.sq | 17 + gradle.properties | 4 + utils/shared/build.gradle.kts | 1 - .../pr0gramm3r101/components/Components.kt | 3 +- 44 files changed, 1233 insertions(+), 589 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/PublicChatProfileSync.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCacheStore.kt delete mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/SimilarityResult.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerificationStatus.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/DisplayName.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerificationStatusResolver.kt diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts index 2e1839b..10edc72 100644 --- a/app/android/build.gradle.kts +++ b/app/android/build.gradle.kts @@ -64,6 +64,10 @@ extensions.configure { targetSdk = 37 versionCode = 1 versionName = "1.0" + + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } } signingConfigs { @@ -109,7 +113,19 @@ extensions.configure { packaging { resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += setOf( + "/META-INF/{AL2.0,LGPL2.1}", + "/META-INF/DEPENDENCIES", + "/META-INF/LICENSE", + "/META-INF/LICENSE.txt", + "/META-INF/LICENSE.md", + "/META-INF/NOTICE", + "/META-INF/NOTICE.txt", + "/META-INF/NOTICE.md", + "/META-INF/*.kotlin_module", + "/META-INF/versions/**", + "DebugProbesKt.bin", + ) } } @@ -135,10 +151,10 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) implementation(libs.play.services.base) debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.adaptive.android) implementation(libs.ktor.client.okhttp) diff --git a/app/android/proguard-rules.pro b/app/android/proguard-rules.pro index cdc313f..e2812f3 100644 --- a/app/android/proguard-rules.pro +++ b/app/android/proguard-rules.pro @@ -1,21 +1,39 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html +# Release shrinking: obfuscate/rename classes and members as aggressively as R8 allows. +# proguard-android-optimize.txt (from build.gradle) supplies repackageclasses, overloadaggressively, etc. -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. +# Crash reports: keep real .kt file names and line numbers; class names stay obfuscated. -keepattributes SourceFile,LineNumberTable -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Kotlin serialization — keep structure required at runtime; names may still be shortened. +-keepattributes *Annotation*, InnerClasses, Signature +-dontnote kotlinx.serialization.AnnotationsKt + +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers,allowobfuscation,allowshrinking class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +-keep,allowobfuscation,allowshrinking,includedescriptorclasses class **$$serializer { + *; +} + +-keepclasseswithmembers,allowobfuscation,allowshrinking class ** { + kotlinx.serialization.KSerializer serializer(...); +} + +# SQLDelight generated adapters — referenced directly, safe to obfuscate names. +-keep,allowobfuscation,allowshrinking class ru.fromchat.db.** { + *; +} + +# Reflection-heavy runtime deps (strip unused code; suppress benign missing-class noise). +-dontwarn org.bouncycastle.** +-dontwarn org.slf4j.** +-dontwarn io.livekit.** diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 0972b6e..19f1923 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -43,7 +43,6 @@ kotlin { implementation(libs.compose.material3) implementation(libs.compose.ui) implementation(libs.compose.components.resources) - implementation(libs.compose.ui.tooling.preview) implementation(libs.constraintlayout) implementation(libs.navigation.compose) implementation(libs.compose.materialIconsExtended) @@ -59,7 +58,6 @@ kotlin { // Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3 implementation(libs.ktor.client.core) - implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.websockets) 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 5080ed4..31855b7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -101,7 +101,6 @@ import ru.fromchat.api.schema.user.devices.DevicesListResponse import ru.fromchat.api.schema.user.keys.BackupBlobRequest import ru.fromchat.api.schema.user.keys.BackupBlobResponse import ru.fromchat.api.schema.user.keys.PublicKeyResponse -import ru.fromchat.api.schema.user.profile.SimilarityResult import ru.fromchat.api.schema.user.profile.UpdateProfileRequest import ru.fromchat.api.schema.user.profile.UpdateProfileResponse import ru.fromchat.api.schema.user.profile.UserProfile @@ -251,6 +250,10 @@ object ApiClient { handleForbiddenAsPotentialSuspension(response) } + if (response.status.value in 200..299 && token != null) { + WebSocketManager.onServerLikelyReachable() + } + if (response.status.value !in (200..299) + 101) { throw ClientRequestException( response, @@ -601,15 +604,6 @@ object ApiClient { .body() .count - suspend fun checkSimilarity(userId: Int): SimilarityResult? = - runCatching { - http - .get("${ServerConfig.apiBaseUrl}/user/check-similarity/$userId") { - contentType(ContentType.Application.Json) - } - .body() - }.getOrNull() - suspend fun verifyUser(userId: Int): VerifyResponse? = runCatching { http diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/PublicChatProfileSync.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/PublicChatProfileSync.kt new file mode 100644 index 0000000..b2e0b85 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/PublicChatProfileSync.kt @@ -0,0 +1,77 @@ +package ru.fromchat.api + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import ru.fromchat.api.local.WebSocketManager +import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.db.store.PublicChatProfileCache +import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile + +/** + * Hydrates [PublicChatProfileCache] from disk on startup and fetches from the network until the + * profile is fully loaded. Background sync stops after that; use [refreshFromNetwork] when the user + * opens [ru.fromchat.ui.profile.PublicChatProfileScreen]. + */ +object PublicChatProfileSync { + private const val RETRY_DELAY_MS = 1_000L + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var started = false + + fun ensureStarted() { + if (started) return + started = true + + WebSocketManager.addSessionReadyHandler { + scope.launch { refreshFromNetworkIfNeeded() } + } + + scope.launch { + runCatching { PublicChatProfileCache.hydrateFromDisk() } + syncUntilLoaded() + } + } + + suspend fun refreshFromNetwork(): PublicChatProfile { + if (ApiClient.token.isNullOrEmpty()) { + return PublicChatProfileCache.profile + ?: error("No auth token for public chat profile refresh") + } + if (CacheContext.activeInstanceId.value.trim().isEmpty()) { + return PublicChatProfileCache.profile + ?: error("No active instance for public chat profile refresh") + } + val profile = ApiClient.getPublicChatProfile() + PublicChatProfileCache.put(profile) + return profile + } + + private suspend fun syncUntilLoaded() { + while (currentCoroutineContext().isActive && !PublicChatProfileCache.isFullyLoaded()) { + if (!canAttemptNetworkSync()) { + delay(RETRY_DELAY_MS) + continue + } + refreshFromNetworkIfNeeded() + if (PublicChatProfileCache.isFullyLoaded()) break + delay(RETRY_DELAY_MS) + } + } + + private fun canAttemptNetworkSync(): Boolean { + if (ApiClient.token.isNullOrEmpty()) return false + if (CacheContext.activeInstanceId.value.trim().isEmpty()) return false + if (!WebSocketManager.isConnected) return false + return true + } + + private suspend fun refreshFromNetworkIfNeeded() { + if (PublicChatProfileCache.isFullyLoaded()) return + runCatching { refreshFromNetwork() } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt index ad8ddb6..b43e389 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt @@ -7,8 +7,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import ru.fromchat.api.ApiClient +import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.local.db.store.InstanceRegistryStore import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.download.AttachmentDownloadNotifier import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.config.Settings @@ -32,8 +34,10 @@ private fun scheduleAttachmentResumeAfterSession() { } } -private fun activateInstance(instanceId: String) { +private suspend fun activateInstance(instanceId: String) { CacheContext.setActiveInstance(instanceId, ApiClient.user?.id) + runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) } + PublicChatProfileSync.ensureStarted() scheduleOutboxProcessing(instanceId) scheduleAttachmentResumeAfterSession() ApiClient.user?.id?.let { userId -> 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 9ddc642..4dc7cd8 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 @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive +import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout @@ -47,14 +48,17 @@ import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) object WebSocketManager { private const val TAG = "WebSocketManager" - private const val MIN_RECONNECT_DELAY_MS = 1_000L - private const val MAX_RECONNECT_DELAY_MS = 60_000L + private const val RECONNECT_DELAY_MS = 1_000L private const val NETWORK_AVAILABLE_DEBOUNCE_MS = 2_000L + private const val SERVER_REACHABLE_DEBOUNCE_MS = 500L private const val FOREGROUND_DELAY_CHUNK_MS = 200L @Volatile private var lastOnNetworkAvailableWallMs: Long = 0L + @Volatile + private var lastServerReachableNotifyWallMs: Long = 0L + private val scope = CoroutineScope(Dispatchers.IO) private val json = Json { ignoreUnknownKeys = true } @@ -145,10 +149,16 @@ object WebSocketManager { if (forceRestart) { connectionJob?.cancel() connectionJob = null + session?.cancel() + session = null + connecting = false } else { val existingJob = connectionJob if (existingJob != null && existingJob.isActive) { logD("connect() ignored: connectionJob already running") + if (session != null) { + ConnectionStateStore.onConnected() + } return } } @@ -156,7 +166,6 @@ object WebSocketManager { ConnectionStateStore.onConnecting() connectionJob = scope.launch { - var reconnectDelayMs = MIN_RECONNECT_DELAY_MS while (isActive) { awaitForeground() @@ -166,7 +175,7 @@ object WebSocketManager { if (token.isNullOrEmpty()) { logD("No auth token available; staying in CONNECTING and retrying later") ConnectionStateStore.onConnecting() - delayWhileForeground(MIN_RECONNECT_DELAY_MS) + delayWhileForeground(RECONNECT_DELAY_MS) continue } @@ -186,7 +195,6 @@ object WebSocketManager { } } ) { - reconnectDelayMs = MIN_RECONNECT_DELAY_MS session = this connecting = false logD("WebSocket connected. connecting set to false") @@ -256,17 +264,20 @@ object WebSocketManager { } catch (e: Throwable) { logW("An error occurred during WebSocket connection: ${e.message}", e) } finally { - logW("WebSocket disconnected. session set to null, connecting set to false") - session = null - connecting = false - ConnectionStateStore.onConnecting() + val isCurrentJob = connectionJob === currentCoroutineContext()[Job] + if (!isCurrentJob) { + logD("Stale connection job finished; skipping session cleanup") + } else { + logW("WebSocket disconnected. session set to null, connecting set to false") + session = null + connecting = false + ConnectionStateStore.onConnecting() + } if (isActive) { awaitForeground() - logD("Reconnecting in ${reconnectDelayMs}ms...") - delayWhileForeground(reconnectDelayMs) - reconnectDelayMs = - (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_DELAY_MS) + logD("Reconnecting in ${RECONNECT_DELAY_MS}ms...") + delayWhileForeground(RECONNECT_DELAY_MS) } } } @@ -365,8 +376,38 @@ object WebSocketManager { return } lastOnNetworkAvailableWallMs = now + if (session != null) { + logD("onNetworkAvailable: already connected, flushing outbox") + OutgoingMessageCoordinator.onTransportReady() + return + } logD("onNetworkAvailable: reconnect and flush outbox") connect(forceRestart = true) OutgoingMessageCoordinator.onTransportReady() } + + /** + * Called when an authenticated HTTP request succeeds while the WebSocket is down. + * Restarts the reconnect loop immediately instead of waiting out exponential backoff. + */ + fun onServerLikelyReachable() { + if (!AppForeground.isInForeground.value) return + if (session != null) return + if (ApiClient.token.isNullOrEmpty()) return + + val now = Clock.System.now().toEpochMilliseconds() + if (now - lastServerReachableNotifyWallMs < SERVER_REACHABLE_DEBOUNCE_MS) { + return + } + lastServerReachableNotifyWallMs = now + + val job = connectionJob + if (job != null && job.isActive) { + logD("onServerLikelyReachable: HTTP ok while WS down; restarting reconnect") + connect(forceRestart = true) + } else { + logD("onServerLikelyReachable: HTTP ok while WS down; starting reconnect") + connect() + } + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheContext.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheContext.kt index 77a7442..b8b12d5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheContext.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/CacheContext.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.MessageDatabaseProvider import ru.fromchat.api.local.cache.CacheContext.activeInstanceId import ru.fromchat.ui.chat.utils.PublicChatPanelCache @@ -27,6 +28,7 @@ object CacheContext { _activeUserId.value = userId if (changed) { ProfileCache.onActiveInstanceChanged(trimmed) + runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(trimmed) } PublicChatPanelCache.onActiveInstanceChanged(trimmed) DmPanelCache.onActiveInstanceChanged(trimmed) if (trimmed.isNotEmpty()) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseExpectedSchema.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseExpectedSchema.kt index 2a88cef..06dc6fb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseExpectedSchema.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseExpectedSchema.kt @@ -12,6 +12,7 @@ internal object MessageDatabaseExpectedSchema { "attachment", "outbox", "profile_cache", + "public_chat_profile", ) val tableCreateSql: Map = mapOf( @@ -99,6 +100,12 @@ internal object MessageDatabaseExpectedSchema { PRIMARY KEY (instanceId, userId) ) """.trimIndent(), + "public_chat_profile" to """ + CREATE TABLE public_chat_profile ( + instanceId TEXT NOT NULL PRIMARY KEY, + json TEXT NOT NULL + ) + """.trimIndent(), ) /** Columns that must exist; value is ALTER TABLE suffix when addable without rebuild. */ @@ -168,6 +175,10 @@ internal object MessageDatabaseExpectedSchema { "userId" to "INTEGER NOT NULL", "json" to "TEXT NOT NULL", ), + "public_chat_profile" to mapOf( + "instanceId" to "TEXT NOT NULL", + "json" to "TEXT NOT NULL", + ), ) /** Tables that require [instanceId]; missing it triggers rebuild-with-copy, not wipe. */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseSchemaGuard.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseSchemaGuard.kt index 79d145c..ee005cb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseSchemaGuard.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseSchemaGuard.kt @@ -416,6 +416,7 @@ private fun recreateMessageDatabaseSchema(driver: SqlDriver) { "DROP TABLE IF EXISTS attachment__migrate", "DROP TABLE IF EXISTS outbox", "DROP TABLE IF EXISTS profile_cache", + "DROP TABLE IF EXISTS public_chat_profile", "DROP TABLE IF EXISTS conversation", "DROP TABLE IF EXISTS conversation__migrate", "DROP TABLE IF EXISTS server_binding", 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 dc86e78..55e0d6a 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 @@ -99,6 +99,7 @@ object InstanceRegistryStore { db.messageDatabaseQueries.deleteAllOutboxForInstance(id) db.messageDatabaseQueries.deleteAllAttachmentsForInstance(id) db.messageDatabaseQueries.deleteAllProfilesForInstance(id) + db.messageDatabaseQueries.deletePublicChatProfileForInstance(id) } } } 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 a3bb8f9..75d516a 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 @@ -96,6 +96,42 @@ object MessageCacheStore { suspend fun loadRecentPublicMessages(limit: Long): List = loadRecentMessages(conversationIdForPublic(), limit) + fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List { + if (instanceId.isBlank()) return emptyList() + val convId = conversationIdForPublic() + val raw = db.messageDatabaseQueries + .selectRecentMessagesByConversation(instanceId, convId, limit) + .executeAsList() + .map { row: DbMessage -> row.toAppMessage() } + .reversed() + val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) + return sortMessagesForChatDisplay( + validatedOrEmpty( + convId, + dedupeMessagesByClientId( + enrichQueuedOutboundUi(withoutSuperseded, convId), + ), + ), + ) + } + + fun loadRecentPublicChatPreviewStateImmediate( + instanceId: String, + strings: ChatListPreviewStrings, + ): ChatListPreviewState? { + if (instanceId.isBlank()) return null + val convId = conversationIdForPublic() + val recent = resolvePreviewSourceMessageRow(instanceId, convId) ?: return null + val message = enrichQueuedOutboundUi(listOf(recent.toAppMessage()), convId).firstOrNull() + ?: return null + return buildChatListPreviewState(message, strings, ApiClient.user?.id) + .let { state -> + state.copy( + text = state.text?.trim()?.takeIf { it.isNotEmpty() }, + ) + } + } + suspend fun loadRecentPublicChatPreviewState( strings: ChatListPreviewStrings, limit: Long = 1, @@ -556,31 +592,39 @@ object MessageCacheStore { } } + fun loadCachedDmConversationsImmediate(instanceId: String): List { + if (instanceId.isBlank()) return emptyList() + return loadCachedDmConversationsRows(instanceId) + } + suspend fun loadCachedDmConversations(): List = withContext(Dispatchers.Default) { - val iid = instanceId() - val previewStrings = listPreviewStrings - val currentUserId = ApiClient.user?.id - db.messageDatabaseQueries - .selectActiveDmConversationsForInstance(iid) - .executeAsList() - .map { row: Conversation -> - val previewState = previewStrings?.let { strings -> - previewStateForRecentMessage(iid, row.id, strings, currentUserId) - } - CachedConversation( - id = row.id, - otherUserId = row.otherUserId?.toInt() ?: 0, - displayName = row.displayName ?: "", - lastMessagePreview = previewState?.text ?: row.lastMessagePreview, - lastMessagePendingIndicator = previewState?.pendingIndicator - ?: ChatListPreviewPendingIndicator.None, - lastMessageUploadProgress = previewState?.uploadProgress, - unreadCount = row.unreadCount.toInt(), - ) - } + loadCachedDmConversationsRows(instanceId()) } + private fun loadCachedDmConversationsRows(instanceId: String): List { + val previewStrings = listPreviewStrings + val currentUserId = ApiClient.user?.id + return db.messageDatabaseQueries + .selectActiveDmConversationsForInstance(instanceId) + .executeAsList() + .map { row: Conversation -> + val previewState = previewStrings?.let { strings -> + previewStateForRecentMessage(instanceId, row.id, strings, currentUserId) + } + CachedConversation( + id = row.id, + otherUserId = row.otherUserId?.toInt() ?: 0, + displayName = row.displayName ?: "", + lastMessagePreview = previewState?.text ?: row.lastMessagePreview, + lastMessagePendingIndicator = previewState?.pendingIndicator + ?: ChatListPreviewPendingIndicator.None, + lastMessageUploadProgress = previewState?.uploadProgress, + unreadCount = row.unreadCount.toInt(), + ) + } + } + @OptIn(ExperimentalCoroutinesApi::class) fun observeActiveDmConversations(instanceId: String): Flow> { val conversationsFlow = db.messageDatabaseQueries @@ -959,6 +1003,7 @@ object MessageCacheStore { username = usernameResolved, profile_picture = pictureResolved, verified = profile?.verified, + verificationStatus = profile?.verificationStatus, reply_to = null, client_message_id = clientMessageId, reactions = null, 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 b34469b..7c859d0 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 @@ -31,11 +31,25 @@ object MessageRepository { suspend fun loadRecentPublicMessages(limit: Long): List = MessageCacheStore.loadRecentPublicMessages(limit) + fun loadRecentPublicMessagesImmediate(limit: Long = 128): List { + val instanceId = CacheContext.activeInstanceId.value.trim() + if (instanceId.isBlank()) return emptyList() + return MessageCacheStore.loadRecentPublicMessagesImmediate(instanceId, limit) + } + suspend fun loadRecentPublicChatPreviewState( strings: ChatListPreviewStrings, limit: Long = 1, ): ChatListPreviewState? = MessageCacheStore.loadRecentPublicChatPreviewState(strings, limit) + fun loadRecentPublicChatPreviewStateImmediate( + strings: ChatListPreviewStrings, + ): ChatListPreviewState? { + val instanceId = CacheContext.activeInstanceId.value.trim() + if (instanceId.isBlank()) return null + return MessageCacheStore.loadRecentPublicChatPreviewStateImmediate(instanceId, strings) + } + fun observePublicChatPreviewState(strings: ChatListPreviewStrings): Flow = MessageCacheStore.observePublicChatPreviewState(activeInstance(), strings) @@ -85,6 +99,12 @@ object MessageRepository { suspend fun loadCachedDmConversations(): List = MessageCacheStore.loadCachedDmConversations() + fun loadCachedDmConversationsImmediate(): List { + val instanceId = CacheContext.activeInstanceId.value.trim() + if (instanceId.isBlank()) return emptyList() + return MessageCacheStore.loadCachedDmConversationsImmediate(instanceId) + } + suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) = MessageCacheStore.ensureDmConversationRow(otherUserId, displayName) 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 854fcbf..ca0560b 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 @@ -9,6 +9,7 @@ import kotlinx.coroutines.sync.withLock import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.user.User 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 @@ -61,6 +62,7 @@ object ProfileCache { username: String? = null, displayName: String? = null, profilePicture: String? = null, + verificationStatus: VerificationStatus? = null, ) { if (id <= 0) return val existing = get(id) @@ -86,6 +88,7 @@ object ProfileCache { lastSeen = existing?.lastSeen, createdAt = existing?.createdAt, verified = existing?.verified, + verificationStatus = verificationStatus ?: existing?.verificationStatus, suspended = existing?.suspended, suspensionReason = existing?.suspensionReason, deleted = existing?.deleted, @@ -163,7 +166,8 @@ object ProfileCache { online = user.online, lastSeen = user.last_seen.takeIf { it.isNotBlank() } ?: existing?.lastSeen, createdAt = user.created_at.takeIf { it.isNotBlank() } ?: existing?.createdAt, - verified = existing?.verified, + verified = user.verified ?: existing?.verified, + verificationStatus = user.verificationStatus ?: existing?.verificationStatus, suspended = existing?.suspended, suspensionReason = existing?.suspensionReason, deleted = existing?.deleted, @@ -192,7 +196,8 @@ object ProfileCache { online = existing?.online ?: false, lastSeen = existing?.lastSeen, createdAt = existing?.createdAt, - verified = existing?.verified, + verified = message.verified ?: existing?.verified, + verificationStatus = message.verificationStatus ?: existing?.verificationStatus, suspended = existing?.suspended, suspensionReason = existing?.suspensionReason, deleted = existing?.deleted, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCache.kt index fd13f07..bcfe1f7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCache.kt @@ -1,19 +1,87 @@ package ru.fromchat.api.local.db.store +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile /** - * In-memory cache of the server-provided public chat profile (title, bio, member count). + * In-memory cache of the server-provided public chat profile (title, bio, member count), + * backed by SQLDelight [public_chat_profile] per instance partition. */ object PublicChatProfileCache { - var profile: PublicChatProfile? = null - private set + private val _profile = MutableStateFlow(null) + val profileState: StateFlow = _profile.asStateFlow() + + private var loadedInstanceId: String = "" + + val profile: PublicChatProfile? + get() = _profile.value + + fun isFullyLoaded(): Boolean { + val instanceId = resolvedInstanceId() + if (instanceId.isEmpty() || loadedInstanceId != instanceId) return false + return isCompleteProfile(_profile.value) + } + + /** + * Synchronous disk hydrate for cold start / chats list first frame (mirrors DM list immediate load). + */ + fun hydrateFromDiskImmediate(instanceId: String): PublicChatProfile? { + val id = instanceId.trim() + synchronized(this) { + if (id.isEmpty()) { + return _profile.value + } + if (loadedInstanceId == id && isCompleteProfile(_profile.value)) { + return _profile.value + } + val previousInstanceId = loadedInstanceId + loadedInstanceId = id + val disk = PublicChatProfileCacheStore.getImmediate(id) + _profile.value = when { + disk != null -> disk + previousInstanceId.isEmpty() -> _profile.value + previousInstanceId != id -> null + else -> _profile.value + } + return _profile.value + } + } fun put(profile: PublicChatProfile) { - this.profile = profile + val instanceId: String + synchronized(this) { + _profile.value = profile + instanceId = resolvedInstanceId() + if (instanceId.isNotEmpty()) { + loadedInstanceId = instanceId + } + } + if (instanceId.isNotEmpty()) { + runCatching { PublicChatProfileCacheStore.putImmediate(instanceId, profile) } + } } - fun clear() { - profile = null + suspend fun onActiveInstanceChanged(instanceId: String) { + hydrateFromDiskImmediate(instanceId) } + + suspend fun hydrateFromDisk() { + hydrateFromDiskImmediate(CacheContext.activeInstanceId.value) + } + + suspend fun clear() { + synchronized(this) { + _profile.value = null + loadedInstanceId = "" + } + } + + private fun resolvedInstanceId(): String = + loadedInstanceId.ifEmpty { CacheContext.activeInstanceId.value.trim() } + + private fun isCompleteProfile(profile: PublicChatProfile?): Boolean = + profile != null && profile.id.isNotBlank() && profile.title.isNotBlank() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCacheStore.kt new file mode 100644 index 0000000..a6edc9a --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCacheStore.kt @@ -0,0 +1,52 @@ +package ru.fromchat.api.local.db.store + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile + +object PublicChatProfileCacheStore { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } + + fun getImmediate(instanceId: String): PublicChatProfile? { + val id = instanceId.trim() + if (id.isEmpty()) return null + val raw = MessageDatabaseProvider.database.messageDatabaseQueries + .selectPublicChatProfile(id) + .executeAsOneOrNull() ?: return null + return runCatching { json.decodeFromString(PublicChatProfile.serializer(), raw) }.getOrNull() + } + + fun putImmediate(instanceId: String, profile: PublicChatProfile) { + val id = instanceId.trim() + if (id.isEmpty()) return + MessageDatabaseProvider.database.messageDatabaseQueries.upsertPublicChatProfile( + instanceId = id, + json = json.encodeToString(PublicChatProfile.serializer(), profile), + ) + } + + suspend fun get(instanceId: String): PublicChatProfile? = withContext(Dispatchers.Default) { + getImmediate(instanceId) + } + + suspend fun put(instanceId: String, profile: PublicChatProfile) { + val id = instanceId.trim() + if (id.isEmpty()) return + withContext(Dispatchers.Default) { + putImmediate(id, profile) + } + } + + suspend fun remove(instanceId: String) { + val id = instanceId.trim() + if (id.isEmpty()) return + withContext(Dispatchers.Default) { + MessageDatabaseProvider.database.messageDatabaseQueries.deletePublicChatProfileForInstance(id) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt index 858af7b..8d3d2af 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/workers/AttachmentTransferBootstrap.kt @@ -13,6 +13,7 @@ import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.repairInterruptedUploadArtifacts +import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.instance.applyCachedSessionInstanceIfAvailable import ru.fromchat.api.instance.scheduleSessionInstanceNetworkRefresh @@ -34,6 +35,7 @@ object AttachmentTransferBootstrap { AttachmentDownloadNotifier.hydrateFromDisk() if (ApiClient.token.isNullOrEmpty()) return applyCachedSessionInstanceIfAvailable() + PublicChatProfileSync.ensureStarted() resumeAttachmentsForActiveInstance() scheduleSessionInstanceNetworkRefresh() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt index f05e7af..a5a4e8a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/Message.kt @@ -1,7 +1,9 @@ package ru.fromchat.api.schema.messages +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient +import ru.fromchat.api.schema.user.profile.VerificationStatus import ru.fromchat.api.schema.messages.dm.DmEnvelope import ru.fromchat.api.schema.messages.dm.DmFile import ru.fromchat.api.schema.websocket.types.ReactionData @@ -17,6 +19,7 @@ data class Message( val username: String, val profile_picture: String? = null, val verified: Boolean? = null, + @SerialName("verification_status") val verificationStatus: VerificationStatus? = null, val reply_to: Message? = null, val client_message_id: String? = null, val reactions: List? = null, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/User.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/User.kt index 55e392c..d284398 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/User.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/User.kt @@ -2,6 +2,7 @@ package ru.fromchat.api.schema.user import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import ru.fromchat.api.schema.user.profile.VerificationStatus @Serializable data class User( @@ -14,7 +15,9 @@ data class User( val admin: Boolean? = null, val bio: String? = null, val profile_picture: String? = null, + val verified: Boolean? = null, + @SerialName("verification_status") val verificationStatus: VerificationStatus? = null, val suspended: Boolean? = null, - @SerialName("suspension_reason") val suspensionReason: String? = null + @SerialName("suspension_reason") val suspensionReason: String? = null, ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/ProfileDialogData.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/ProfileDialogData.kt index e047b58..ccf78f2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/ProfileDialogData.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/ProfileDialogData.kt @@ -14,6 +14,7 @@ data class ProfileDialogData( val online: Boolean? = null, @SerialName("is_own_profile") val isOwnProfile: Boolean = false, val verified: Boolean? = null, + @SerialName("verification_status") val verificationStatus: VerificationStatus? = null, val suspended: Boolean? = null, @SerialName("suspension_reason") val suspensionReason: String? = null, val deleted: Boolean? = null diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/SimilarityResult.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/SimilarityResult.kt deleted file mode 100644 index 578b143..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/SimilarityResult.kt +++ /dev/null @@ -1,9 +0,0 @@ -package ru.fromchat.api.schema.user.profile - -import kotlinx.serialization.Serializable - -@Serializable -data class SimilarityResult( - val isSimilar: Boolean, - val similarTo: String? = null -) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UserProfile.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UserProfile.kt index ef64042..8a82d4b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UserProfile.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UserProfile.kt @@ -14,6 +14,7 @@ data class UserProfile( @SerialName("last_seen") val lastSeen: String? = null, @SerialName("created_at") val createdAt: String? = null, val verified: Boolean? = null, + @SerialName("verification_status") val verificationStatus: VerificationStatus? = null, val suspended: Boolean? = null, @SerialName("suspension_reason") val suspensionReason: String? = null, val deleted: Boolean? = null, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerificationStatus.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerificationStatus.kt new file mode 100644 index 0000000..6e8bd53 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerificationStatus.kt @@ -0,0 +1,23 @@ +package ru.fromchat.api.schema.user.profile + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class VerificationStatus { + @SerialName("verified") + Verified, + + @SerialName("warning") + Warning, + + @SerialName("none") + None, +} + +fun VerificationStatus?.orFromLegacyVerified(verified: Boolean?): VerificationStatus = + this ?: when (verified) { + true -> VerificationStatus.Verified + false -> VerificationStatus.None + null -> VerificationStatus.None + } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerifyResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerifyResponse.kt index 2e384f3..c2aef88 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerifyResponse.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/VerifyResponse.kt @@ -1,8 +1,10 @@ package ru.fromchat.api.schema.user.profile +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class VerifyResponse( - val verified: Boolean + val verified: Boolean, + @SerialName("verification_status") val verificationStatus: VerificationStatus? = null, ) \ No newline at end of file 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 fafdc50..584d9ba 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -56,6 +56,7 @@ import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.AppForeground import ru.fromchat.Logger 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.bootstrapSessionOnStartup @@ -224,6 +225,17 @@ fun App( } val hasToken = ApiClient.token?.isNotEmpty() == true + + if (hasToken) { + runCatching { + bootstrapSessionOnStartup( + hasToken = true, + onLogoutRequired = { sessionLogoutRequired = true }, + ) + } + PublicChatProfileSync.ensureStarted() + } + startDestination = when { hasToken && startAtDmConversationUserId != null -> "chat" hasToken && startAtPublicChat -> "chats/publicChat" @@ -239,15 +251,6 @@ fun App( if (!hasToken) return@LaunchedEffect - launch(Dispatchers.Default) { - runCatching { - bootstrapSessionOnStartup( - hasToken = true, - onLogoutRequired = { sessionLogoutRequired = true }, - ) - } - } - launch(Dispatchers.Default) { runCatching { ProfileCache.hydrateFromDisk() } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt index 2f43480..40e3cf5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/calls/CallOverlay.kt @@ -33,6 +33,9 @@ import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallUiState import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.visibleDisplayName +import ru.fromchat.ui.profile.DisplayName +import ru.fromchat.ui.profile.effectiveVerificationStatus +import ru.fromchat.ui.profile.resolveVerificationStatus import ru.fromchat.call_accept import ru.fromchat.call_decline import ru.fromchat.call_dismiss @@ -104,10 +107,11 @@ fun CallOverlay(modifier: Modifier = Modifier) { modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, + DisplayName( + displayName = title, + verificationStatus = cached?.effectiveVerificationStatus() + ?: resolveVerificationStatus(s.fromUserId), + textStyle = MaterialTheme.typography.headlineSmall, ) Spacer(Modifier.height(8.dp)) Text( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index 2ef9ba4..95f17b3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -313,18 +313,25 @@ private fun ChatImageTileContent( val initialFull = remember(decryptCacheKey) { LocalDecodedImageCache.peekFull(decryptCacheKey) } val hadInstantFull = initialFull != null - val hasLocalSource = !localUri.isNullOrBlank() + var cachedPath by remember(decryptCacheKey) { + mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)) + } + val diskCacheUri = cachedPath + val hasDiskCache = diskCacheUri != null + val displayLocalUri = localUri?.trim()?.takeIf { it.isNotEmpty() } ?: diskCacheUri + val hasLocalSource = displayLocalUri != null val hasServerThumb = thumbnailBase64?.isNotBlank() == true + val useBlurredThumbPlaceholder = hasServerThumb && !hasDiskCache var fullBitmap by remember(decryptCacheKey) { mutableStateOf(initialFull) } - var didRevealFull by remember(decryptCacheKey) { mutableStateOf(hadInstantFull) } - val thumbBlurAlpha = remember(decryptCacheKey, hasServerThumb, hasLocalSource) { + var didRevealFull by remember(decryptCacheKey) { mutableStateOf(hadInstantFull || hasDiskCache) } + val thumbBlurAlpha = remember(decryptCacheKey, useBlurredThumbPlaceholder, hasLocalSource) { Animatable( when { - hadInstantFull -> 0f - hasServerThumb || hasLocalSource -> 1f + hadInstantFull || hasDiskCache -> 0f + useBlurredThumbPlaceholder || hasLocalSource -> 1f else -> 0f }, ) @@ -374,19 +381,17 @@ private fun ChatImageTileContent( } } - var cachedPath by remember(decryptCacheKey) { - mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)) - } - val thumbnailBytes = remember(thumbnailBase64) { - thumbnailBase64?.let { decodeAttachmentThumbnailBase64(it) } + val thumbnailBytes = remember(thumbnailBase64, hasDiskCache) { + if (hasDiskCache) null else thumbnailBase64?.let { decodeAttachmentThumbnailBase64(it) } } val thumbBitmap by produceState( - initialValue = LocalDecodedImageCache.peekThumb(decryptCacheKey), + initialValue = if (hasDiskCache) null else LocalDecodedImageCache.peekThumb(decryptCacheKey), decryptCacheKey, thumbnailBytes, decodeSize, + hasDiskCache, ) { - if (thumbnailBytes == null) { + if (hasDiskCache || thumbnailBytes == null) { value = null return@produceState } @@ -401,14 +406,22 @@ private fun ChatImageTileContent( val imageContentScale = ContentScale.Fit val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f) + LaunchedEffect(messageId, fileIndex, cacheClientId) { + DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.let { uri -> + if (cachedPath != uri) cachedPath = uri + } + } + LaunchedEffect( decryptCacheKey, localUri, + diskCacheUri, serverFile?.path, messageId, isOutboundPending, cacheClientId, loadAttempt, + decodeSize, ) { AttachmentMediaLog.tileLoad( "load_start", @@ -416,9 +429,11 @@ private fun ChatImageTileContent( "msgId" to messageId, "pending" to isOutboundPending, "localUri" to (localUri?.take(48) ?: "null"), + "diskCache" to (diskCacheUri?.take(48) ?: "null"), "target" to "${decodeSize.widthPx}x${decodeSize.heightPx}", ) - val diskUri = DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId) + val diskUri = diskCacheUri + ?: DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)?.also { cachedPath = it } val localPaths = buildList { localUri?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) } diskUri?.let { cached -> if (none { it == cached }) add(cached) } @@ -444,12 +459,10 @@ private fun ChatImageTileContent( fullBitmap = loaded cachedPath = diskUri ?: localPaths.firstOrNull() decryptFinished = true - if (thumbBitmap == null) { - didRevealFull = true - fullRevealAlpha.snapTo(1f) - thumbBlurAlpha.snapTo(0f) - } - onFullyLoaded(true) + didRevealFull = true + fullRevealAlpha.snapTo(1f) + thumbBlurAlpha.snapTo(0f) + onFullyLoaded(true) return@LaunchedEffect } AttachmentMediaLog.tileLoad( @@ -484,11 +497,9 @@ private fun ChatImageTileContent( ) fullBitmap = loaded decryptFinished = true - if (thumbBitmap == null) { - didRevealFull = true - fullRevealAlpha.snapTo(1f) - thumbBlurAlpha.snapTo(0f) - } + didRevealFull = true + fullRevealAlpha.snapTo(1f) + thumbBlurAlpha.snapTo(0f) onFullyLoaded(true) return@LaunchedEffect } @@ -542,11 +553,9 @@ private fun ChatImageTileContent( ) if (fullBitmap != null) { AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) - if (thumbBitmap == null) { - didRevealFull = true - fullRevealAlpha.snapTo(1f) - thumbBlurAlpha.snapTo(0f) - } + didRevealFull = true + fullRevealAlpha.snapTo(1f) + thumbBlurAlpha.snapTo(0f) onFullyLoaded(true) } } @@ -574,15 +583,19 @@ private fun ChatImageTileContent( } } - LaunchedEffect(fullBitmap, thumbBitmap, hadInstantFull) { + LaunchedEffect(fullBitmap, thumbBitmap, hadInstantFull, hasDiskCache) { when { fullBitmap == null -> { + if (hasDiskCache) { + thumbBlurAlpha.snapTo(0f) + return@LaunchedEffect + } didRevealFull = false fullRevealAlpha.snapTo(0f) if (thumbBitmap != null) thumbBlurAlpha.snapTo(1f) } didRevealFull -> return@LaunchedEffect - hadInstantFull -> { + hadInstantFull || hasDiskCache -> { didRevealFull = true fullRevealAlpha.snapTo(1f) thumbBlurAlpha.snapTo(0f) @@ -641,10 +654,10 @@ private fun ChatImageTileContent( ) if (fullBitmap == null && hasLocalSource && !showOutboundBlurOverlay) { AsyncImage( - model = localUri, - contentDescription = null, + model = displayLocalUri, + contentDescription = null, contentScale = imageContentScale, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), ) } thumbBitmap?.let { thumb -> @@ -759,7 +772,7 @@ private fun ChatImageTileContent( } if (showOutboundBlurOverlay && outboundOverlayAlpha.value > 0.01f) { UploadingImageOverlay( - model = localUri, + model = localUri ?: displayLocalUri!!, uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null, clipShape = clipShape, contentScale = imageContentScale, 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 9a40b81..bdebaaf 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 @@ -41,8 +41,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.utils.resetFocus import com.pr0gramm3r101.utils.supportClipboardManagerImpl import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.hazeEffect @@ -151,6 +154,8 @@ fun ChatScreen( val saveMessageFile = rememberSaveMessageFile { /* best-effort */ } val haptic = rememberHapticFeedback() val navController = LocalNavController.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current val profileUserId = panelState.profileUserId val hazeState = rememberHazeState( blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty()) @@ -173,9 +178,8 @@ fun ChatScreen( } val subtitleKey = when { - !online -> "connecting" connectionStatus == ConnectionStatus.UPDATING -> "updating" - connectionStatus != ConnectionStatus.CONNECTED -> "connecting" + !online || (connectionStatus == ConnectionStatus.CONNECTING && !WebSocketManager.isConnected) -> "connecting" currentTypingUsers.isNotEmpty() -> "typing" panel.usesPublicGroupSubtitle -> { if (panelState.publicGroupMetaLoading || panelState.publicGroupMemberCount == null) { @@ -716,7 +720,10 @@ fun ChatScreen( } else { null }, - onImageClick = { msg, idx -> expandedImage = msg to idx }, + onImageClick = { msg, idx -> + resetFocus(keyboardController, focusManager) + expandedImage = msg to idx + }, onImageBounds = { key, rect -> imageThumbBounds[key] = rect }, 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 6379dc0..7146abd 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 @@ -64,6 +64,8 @@ import ru.fromchat.chat_members_count import ru.fromchat.ui.chat.utils.TypingUser import ru.fromchat.ui.components.ConnectingEllipsis import ru.fromchat.ui.components.Text +import ru.fromchat.ui.profile.StatusBadge +import ru.fromchat.ui.profile.resolveVerificationStatus import com.pr0gramm3r101.utils.scaleOnPress import kotlin.math.PI import kotlin.math.tan @@ -169,12 +171,26 @@ fun ChatTopBarInner( .padding(horizontal = 4.dp, vertical = 2.dp), horizontalAlignment = Alignment.Start, ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + profileUserId?.let { userId -> + val status = resolveVerificationStatus(userId) + if (status != null) { + Spacer(modifier = Modifier.width(4.dp)) + StatusBadge( + verificationStatus = status, + size = 18.dp, + ) + } + } + } AnimatedContent( targetState = subtitleKey, 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 9c74749..1ec24f8 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 @@ -67,6 +67,8 @@ import ru.fromchat.ui.chat.components.getReplyMessageGradient import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage import ru.fromchat.ui.chat.utils.imageAttachmentKey import ru.fromchat.ui.components.Text +import ru.fromchat.ui.profile.StatusBadge +import ru.fromchat.ui.profile.resolveVerificationStatus /** True when [Message.content] is only a filename placeholder (no real caption). */ internal fun isFilenameOnlyMessageCaption(message: Message): Boolean { @@ -121,6 +123,7 @@ fun MessageItem( val editedSuffix = stringResource(Res.string.message_edited_suffix) val sendFailedLabel = stringResource(Res.string.message_send_failed) val displayUsername = messageDisplayUsername(message, currentUserId) + val senderVerificationStatus = resolveVerificationStatus(message.user_id, message) val replyRef = message.reply_to // No AnimatedVisibility here: visible=true still ran enter transitions for every item on first @@ -342,23 +345,41 @@ fun MessageItem( onClick = onUsernameClick ) ) { - Text( - text = displayUsername, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - modifier = usernameInset - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = usernameInset, + ) { + Text( + text = displayUsername, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + StatusBadge( + verificationStatus = senderVerificationStatus, + size = 14.dp, + ) + } } } else { Box(modifier = usernameOutset) { - Text( - text = displayUsername, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - modifier = usernameInset - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = usernameInset, + ) { + Text( + text = displayUsername, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + StatusBadge( + verificationStatus = senderVerificationStatus, + size = 14.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 f906048..eea549a 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 @@ -9,12 +9,14 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.Logger 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.PublicChatProfileCache import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.conversationIdForGroup +import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile import ru.fromchat.api.schema.messages.Message @@ -40,7 +42,7 @@ class PublicChatPanel( scope = scope ) { private val typingHandler = PublicChatTypingHandler(scope) - private var messagesLoaded = false + private var networkHistoryLoaded = false /** * Whether replacing the list would change **structure or message body** (content / edited). @@ -68,11 +70,12 @@ class PublicChatPanel( get() = true init { - val cachedProfile = PublicChatProfileCache.profile - if (cachedProfile != null) { - applyPublicChatProfile(cachedProfile) - } else { - updateState { + val instanceId = CacheContext.activeInstanceId.value.trim() + if (instanceId.isNotEmpty()) { + PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) + } + PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) } + ?: updateState { it.copy( title = "", titleAvatar = null, @@ -80,6 +83,18 @@ class PublicChatPanel( publicGroupMemberCount = null, ) } + if (instanceId.isNotEmpty()) { + val cached = runCatching { + MessageRepository.loadRecentPublicMessagesImmediate(limit = 128) + }.getOrDefault(emptyList()) + if (cached.isNotEmpty()) { + updateState { currentState -> + currentState.copy( + messages = sortMessagesForChatDisplay(cached), + isLoading = false, + ) + } + } } scope.launch { typingHandler.typingUsers.collect { users -> @@ -87,17 +102,52 @@ class PublicChatPanel( updateState { it.copy(typingUsers = users.filter { it.userId != currentUserId }) } } } - scope.launch(Dispatchers.Default) { - runCatching { ApiClient.getPublicChatProfile() } - .onSuccess { profile -> - PublicChatProfileCache.put(profile) + scope.launch { + PublicChatProfileCache.profileState.collect { profile -> + if (profile != null) { applyPublicChatProfile(profile) } - .onFailure { - if (cachedProfile == null) { - updateState { s -> s.copy(publicGroupMetaLoading = false) } - } - } + } + } + scope.launch(Dispatchers.Default) { + runCatching { PublicChatProfileCache.hydrateFromDisk() } + PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) } + if (_state.messages.isEmpty()) { + hydrateMessagesFromLocalCache() + } + } + } + + /** Shown when server profile is unavailable but the chat list already has a localized title. */ + fun applyFallbackTitle(title: String) { + val trimmed = title.trim() + if (trimmed.isEmpty() || _state.title.isNotBlank()) return + updateState { s -> + s.copy( + title = trimmed, + titleAvatar = s.titleAvatar ?: AvatarInfo(displayName = trimmed, profilePictureUrl = null), + ) + } + } + + suspend fun hydrateFromLocalCache() { + hydrateMessagesFromLocalCache() + runCatching { PublicChatProfileCache.hydrateFromDisk() } + PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) } + } + + private suspend fun hydrateMessagesFromLocalCache() { + val cached = withContext(Dispatchers.Default) { + runCatching { MessageRepository.loadRecentPublicMessagesImmediate(limit = 128) } + .getOrDefault(emptyList()) + } + if (cached.isEmpty()) return + withContext(Dispatchers.Main) { + batchStateUpdates { + clearMessages() + addMessages(cached) + setLoading(false) + } } } @@ -184,31 +234,18 @@ class PublicChatPanel( } override suspend fun loadMessages() { - if (messagesLoaded) return + hydrateMessagesFromLocalCache() + if (networkHistoryLoaded) return + networkHistoryLoaded = true - messagesLoaded = true - - // 1) Read cache off main first. Do NOT setLoading(true) before this: that forced an extra - // frame (spinner + msgs=0) and a second heavy recomposition before the cached list applied. - val cached = withContext(Dispatchers.Default) { - // Bounded read: public conversation can accumulate many rows; SQLDelight is thin — the cost is SQLite I/O. - runCatching { MessageCacheStore.loadRecentPublicMessages(limit = 128) }.getOrDefault(emptyList()) - } - if (cached.isNotEmpty()) { - withContext(Dispatchers.Main) { - batchStateUpdates { - clearMessages() - addMessages(cached) - setLoading(false) - } - } - } else { + val cached = _state.messages + if (cached.isEmpty()) { withContext(Dispatchers.Main) { setLoading(true) } } - // 2) Refresh from network; this may be fast or slow, but runs entirely off main. + // Refresh from network; this may be fast or slow, but runs entirely off main. val responseResult = withContext(Dispatchers.Default) { runCatching { ApiClient.getMessages(limit = 50) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt index f2434b7..4e7c0ee 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt @@ -44,9 +44,10 @@ fun PublicChatScreen( LaunchedEffect(panel, activeInstanceId) { if (activeInstanceId.isBlank()) return@LaunchedEffect - if (panel.getState().messages.isEmpty()) { - panel.loadMessages() + withContext(Dispatchers.Default) { + panel.hydrateFromLocalCache() } + panel.loadMessages() } LaunchedEffect(activeInstanceId, online, connectionStatus) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt index 567773b..00c0f07 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt @@ -80,6 +80,8 @@ import ru.fromchat.cd_chat_preview_sending import ru.fromchat.cd_chat_preview_uploading import ru.fromchat.cd_chat_selected import ru.fromchat.ui.chat.Avatar +import ru.fromchat.ui.profile.DisplayName +import ru.fromchat.ui.profile.resolveVerificationStatus import ru.fromchat.ui.chat.ExpressiveUploadIndicator import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.components.Text @@ -113,6 +115,18 @@ private val ChatListCategoryMargin = PaddingValues( bottom = 20.dp, ) +@Composable +internal fun ChatListHeadlineWithBadge( + title: String, + userId: Int, +) { + DisplayName( + displayName = title, + verificationStatus = resolveVerificationStatus(userId), + textStyle = MaterialTheme.typography.bodyLarge, + ) +} + @Composable internal fun ChatListItemSpacer() { Spacer( @@ -165,7 +179,7 @@ internal fun ChatConversationsList( onRowPositioned: (lazyIndex: Int, offset: Offset, size: IntSize) -> Unit, ) { val scrollBlocked = contextMenuState.isOverlayActive - val showPublicChat = listFilter == ChatListFilter.Active + val showPublicChat = listFilter == ChatListFilter.Active && !publicChatTitle.isNullOrBlank() val groupCount = (if (showPublicChat) 1 else 0) + conversations.size LazyColumn( @@ -394,6 +408,7 @@ internal fun SearchConversationsList( ) { ListItem( headline = peerTitle, + headlineSlot = { ChatListHeadlineWithBadge(peerTitle, user.id) }, supportingText = username, containerColor = Color.Transparent, position = position, @@ -703,11 +718,7 @@ internal fun PublicChatRowContent( ) } }, - trailingContent = { - if (publicChatTitle == null) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - } - }, + trailingContent = {}, bodyModifier = Modifier .fillMaxWidth() .fillMaxHeight(), @@ -834,6 +845,7 @@ internal fun DmConversationRowContent( val listSurfaceColor = MaterialTheme.colorScheme.surfaceContainerLow ListItem( headline = peerTitle, + headlineSlot = { ChatListHeadlineWithBadge(peerTitle, conversation.otherUserId) }, supportingSlot = { if (isTyping) { TypingIndicator(typingUsers = typingUsers) 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 041f11a..e2a38d4 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 @@ -26,6 +26,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -100,6 +101,8 @@ fun ChatsSearchScreen( val searchBarHint = stringResource(Res.string.search_title) var dmConversations by remember { mutableStateOf>(emptyList()) } var remoteUsers by remember { mutableStateOf>(emptyList()) } + var isSearchInFlight by remember { mutableStateOf(false) } + var lastCompletedSearchQuery by remember { mutableStateOf(null) } val statusSubscriptionScope = rememberCoroutineScope() var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } val keyboardController = LocalSoftwareKeyboardController.current @@ -131,27 +134,39 @@ fun ChatsSearchScreen( } val searchUiState = when { - searchText.isBlank() -> SearchScreenState.EmptyQuery - searchResults.isEmpty() -> SearchScreenState.NotFound - else -> SearchScreenState.Results + searchText.isBlank() || searchQuery.length < 2 -> SearchScreenState.EmptyQuery + searchResults.isNotEmpty() -> SearchScreenState.Results + isSearchInFlight || lastCompletedSearchQuery != searchQuery -> SearchScreenState.Loading + else -> SearchScreenState.NotFound } LaunchedEffect(searchQuery) { if (searchQuery.length < 2) { remoteUsers = emptyList() + isSearchInFlight = false + lastCompletedSearchQuery = null return@LaunchedEffect } + isSearchInFlight = true delay(300) val querySnapshot = searchQuery - runCatching { - ApiClient.searchUsers(querySnapshot) - }.onSuccess { users -> + if (querySnapshot != searchText.trim().lowercase().trimStart('@')) { + return@LaunchedEffect + } + try { + val users = ApiClient.searchUsers(querySnapshot) if (querySnapshot == searchText.trim().lowercase().trimStart('@')) { users.forEach { ProfileCache.mergeFromDmUser(it) } remoteUsers = users + lastCompletedSearchQuery = querySnapshot } - }.onFailure { + } catch (_: Throwable) { if (querySnapshot == searchText.trim().lowercase().trimStart('@')) { remoteUsers = emptyList() + lastCompletedSearchQuery = querySnapshot + } + } finally { + if (querySnapshot == searchText.trim().lowercase().trimStart('@')) { + isSearchInFlight = false } } } @@ -272,10 +287,19 @@ fun ChatsSearchScreen( ) } + SearchScreenState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + SearchScreenState.NotFound -> { SearchScreenInfo( - title = stringResource(Res.string.search_not_found), - subtitle = stringResource(Res.string.search_not_found_message), + title = stringResource(Res.string.search_not_found), + subtitle = stringResource(Res.string.search_not_found_message), showSearchIcon = false ) } @@ -328,8 +352,9 @@ private sealed interface SearchResult { private enum class SearchScreenState { EmptyQuery, + Loading, NotFound, - Results + Results, } @Composable 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 597179e..3170594 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 @@ -84,6 +84,7 @@ import ru.fromchat.action_delete import ru.fromchat.action_mark_read import ru.fromchat.api.ApiClient import ru.fromchat.api.calls.CallStore +import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.db.store.CachedConversation import ru.fromchat.api.local.db.store.ConnectionStateStore @@ -285,21 +286,49 @@ fun ChatsTab( val scope = rememberCoroutineScope() val connectionStatus by ConnectionStateStore.status.collectAsState() val online by NetworkConnectivity.isOnline.collectAsState(initial = true) - var dmConversations by remember { mutableStateOf>(emptyList()) } - var publicChatPreviewState by remember { mutableStateOf(null) } - var publicChatProfile by remember { mutableStateOf(PublicChatProfileCache.profile) } - val searchBarHint = stringResource(Res.string.search_title) - val tabListState = rememberLazyListState() - val statusMap by UserStatusStore.status.collectAsState() - var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } - val statusSubscriptionScope = rememberCoroutineScope() - val suspensionState by ApiClient.suspensionState.collectAsState() + val activeInstanceId by CacheContext.activeInstanceId.collectAsState() + var dmConversations by remember(activeInstanceId) { + val cached = if (activeInstanceId.isBlank()) { + emptyList() + } else { + runCatching { MessageRepository.loadCachedDmConversationsImmediate() } + .getOrDefault(emptyList()) + } + cached.forEach { ProfileCache.mergeFromCachedConversation(it) } + mutableStateOf(ChatListReorderController.applyOrdered(cached)) + } val imageEmoji = stringResource(Res.string.chat_preview_image_emoji) val previewStrings = ChatListPreviewStrings( imageEmoji = imageEmoji, imageOnly = stringResource(Res.string.chat_preview_image, imageEmoji), attachmentOnly = stringResource(Res.string.chat_preview_attachment), ) + var publicChatPreviewState by remember(activeInstanceId, previewStrings.imageOnly, previewStrings.attachmentOnly) { + if (activeInstanceId.isBlank()) { + mutableStateOf(null) + } else { + runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(activeInstanceId) } + val preview = runCatching { + MessageRepository.loadRecentPublicChatPreviewStateImmediate(previewStrings) + }.getOrNull() + mutableStateOf(preview) + } + } + val publicChatProfileFromDisk = remember(activeInstanceId) { + if (activeInstanceId.isBlank()) { + null + } else { + runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(activeInstanceId) }.getOrNull() + } + } + val publicChatProfileFromFlow by PublicChatProfileCache.profileState.collectAsState() + val publicChatProfile = publicChatProfileFromFlow ?: publicChatProfileFromDisk + val searchBarHint = stringResource(Res.string.search_title) + val tabListState = rememberLazyListState() + val statusMap by UserStatusStore.status.collectAsState() + var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } + val statusSubscriptionScope = rememberCoroutineScope() + val suspensionState by ApiClient.suspensionState.collectAsState() val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) { @@ -360,7 +389,9 @@ fun ChatsTab( fun refreshDmList() { scope.launch { runCatching { - dmConversations = MessageRepository.loadCachedDmConversations() + dmConversations = ChatListReorderController.applyOrdered( + MessageRepository.loadCachedDmConversations(), + ) } } } @@ -459,14 +490,13 @@ fun ChatsTab( } val serverConfig by ServerConfig.serverConfig.collectAsState() - val activeInstanceId by CacheContext.activeInstanceId.collectAsState() LaunchedEffect(activeInstanceId, previewStrings.imageOnly, previewStrings.attachmentOnly) { if (activeInstanceId.isBlank()) { publicChatPreviewState = null return@LaunchedEffect } - publicChatProfile = PublicChatProfileCache.profile + runCatching { PublicChatProfileCache.hydrateFromDisk() } runCatching { publicChatPreviewState = MessageRepository.loadRecentPublicChatPreviewState(previewStrings) } @@ -490,36 +520,24 @@ fun ChatsTab( LaunchedEffect(serverConfig, activeInstanceId) { if (activeInstanceId.isBlank()) return@LaunchedEffect - runCatching { - MessageRepository.loadCachedDmConversations() - }.onSuccess { conversations -> - conversations.forEach { ProfileCache.mergeFromCachedConversation(it) } - dmConversations = conversations - } - runCatching { ApiClient.getDmConversations() }.onSuccess { conversations -> runCatching { conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } MessageRepository.replaceDmConversations(conversations, previewStrings) - dmConversations = MessageRepository.loadCachedDmConversations() + dmConversations = ChatListReorderController.applyOrdered( + MessageRepository.loadCachedDmConversations(), + ) } } - publicChatProfile = PublicChatProfileCache.profile - - runCatching { ApiClient.getPublicChatProfile() } - .onSuccess { profile -> - PublicChatProfileCache.put(profile) - publicChatProfile = profile - } + runCatching { PublicChatProfileCache.hydrateFromDisk() } } val titleKey = when { - !online -> "connecting" connectionStatus == ConnectionStatus.UPDATING -> "updating" - connectionStatus == ConnectionStatus.CONNECTING -> "connecting" + !online || (connectionStatus == ConnectionStatus.CONNECTING && !WebSocketManager.isConnected) -> "connecting" else -> "fromchat" } @@ -529,7 +547,7 @@ 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 publicChatTitle = publicChatProfile?.title + val publicChatTitle = publicChatProfile?.title?.takeIf { it.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/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt index 3f81d7f..afffe27 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/DevicesScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding @@ -29,11 +30,11 @@ import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.LaptopMac import androidx.compose.material.icons.rounded.PhoneAndroid import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -68,12 +69,18 @@ import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.rememberHazeState +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.HttpRequestTimeoutException +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.schema.user.devices.DeviceSessionInfo import ru.fromchat.back import ru.fromchat.cancel @@ -102,6 +109,15 @@ import ru.fromchat.unknown private const val DEVICE_SESSIONS_POLL_INTERVAL_MS = 15_000L +private fun isUnreachableDevicesFetchError(error: Throwable): Boolean = when (error) { + is TimeoutCancellationException, + is HttpRequestTimeoutException, + is SocketTimeoutException, + is ConnectTimeoutException -> true + is ClientRequestException -> error.response.status.value !in 400..499 + else -> true +} + private fun enrichDevicesList(list: List): List { val currentIndex = list.indexOfFirst { it.current } if (currentIndex < 0) return list @@ -368,25 +384,40 @@ fun DevicesScreen(onBack: () -> Unit) { } suspend fun fetchDevices() { - refreshing = true + if (!WebSocketManager.isConnected) { + if (devices.isEmpty()) refreshing = true + return + } + + val hadContent = devices.isNotEmpty() + if (hadContent) refreshing = true runCatching { ApiClient.listDevices() } .onSuccess { list -> Settings.writeDeviceSessionsCache(list) devices = enrichDevicesList(list) + refreshing = false } - .onFailure { - snackbarHostState.showSnackbar(errUnexpected) + .onFailure { error -> + if (isUnreachableDevicesFetchError(error)) { + refreshing = devices.isEmpty() + } else { + refreshing = false + snackbarHostState.showSnackbar(errUnexpected) + } } - - refreshing = false } LaunchedEffect(Unit) { - fetchDevices() while (isActive) { - delay(DEVICE_SESSIONS_POLL_INTERVAL_MS) + if (!WebSocketManager.isConnected) { + if (devices.isEmpty()) refreshing = true + delay(1_000) + continue + } + fetchDevices() + delay(DEVICE_SESSIONS_POLL_INTERVAL_MS) } } @@ -434,6 +465,18 @@ fun DevicesScreen(onBack: () -> Unit) { current to mutable } + if (devices.isEmpty() && refreshing) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + LoadingIndicator(modifier = Modifier.padding(24.dp)) + } + return@Scaffold + } + LazyColumn( modifier = Modifier .hazeSource(hazeState) @@ -538,7 +581,7 @@ fun DevicesScreen(onBack: () -> Unit) { .padding(vertical = 16.dp), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) + LoadingIndicator(modifier = Modifier.padding(24.dp)) } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/DisplayName.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/DisplayName.kt new file mode 100644 index 0000000..079fc73 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/DisplayName.kt @@ -0,0 +1,44 @@ +package ru.fromchat.ui.profile + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import ru.fromchat.api.schema.user.profile.VerificationStatus + +@Composable +fun DisplayName( + displayName: String, + verificationStatus: VerificationStatus?, + modifier: Modifier = Modifier, + textStyle: TextStyle, + badgeSize: Dp = 16.dp, + maxLines: Int = 1, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = displayName, + style = textStyle, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (verificationStatus != null && verificationStatus != VerificationStatus.None) { + Spacer(Modifier.width(4.dp)) + StatusBadge( + verificationStatus = verificationStatus, + size = badgeSize, + ) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 6a1a5c0..1a58a78 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -43,7 +43,6 @@ import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile import ru.fromchat.back @@ -81,8 +80,6 @@ import androidx.compose.material.icons.rounded.Call import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.TextButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarDuration @@ -115,6 +112,8 @@ import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import io.ktor.client.plugins.ClientRequestException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource @@ -122,8 +121,9 @@ import ru.fromchat.Logger import ru.fromchat.Res import ru.fromchat.action_copy import ru.fromchat.action_edit -import ru.fromchat.action_retry_send import ru.fromchat.api.ApiClient +import ru.fromchat.api.PublicChatProfileSync +import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.calls.CallStore import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.UserStatus @@ -148,6 +148,7 @@ import ru.fromchat.profile_load_failed import ru.fromchat.profile_not_found import ru.fromchat.profile_verified_support import ru.fromchat.profile_verify_prompt_support +import ru.fromchat.ui.profile.effectiveVerificationStatus import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.TypingIndicator @@ -286,8 +287,6 @@ fun ProfileScreen( mutableStateOf(hasDisplayableProfile(state.profile, initialDisplayName, ownUserId)) } - var reloadAttempt by remember(lookupKey) { mutableIntStateOf(0) } - val latestUi by rememberUpdatedState(state) val backStackEntry = navController.currentBackStackEntry @@ -309,109 +308,116 @@ fun ProfileScreen( } } - LaunchedEffect(lookupMode, lookupIdentifier, reloadAttempt) { - if (reloadAttempt > 0) { - state = latestUi.copy(isLoading = true, error = null) + LaunchedEffect(lookupMode, lookupIdentifier) { + ProfileCache.hydrateFromDisk() + resolveCachedProfile(targetUserId, targetUsername, ownUserId)?.let { cached -> + if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) { + state = latestUi.copy(profile = cached, isLoading = false, error = null) + } } - Logger.d( - "ProfileScreen", - "load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId" - ) - - try { - val profile = when { - targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile() - targetUsername != null -> ApiClient.getProfileByUsername(targetUsername) - else -> ApiClient.getProfileById(targetUserId!!) + while (isActive) { + if (!WebSocketManager.isConnected) { + delay(1000) + continue } - if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) { - cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) } + Logger.d( + "ProfileScreen", + "load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId" + ) + + try { + val profile = when { + targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile() + targetUsername != null -> ApiClient.getProfileByUsername(targetUsername) + else -> ApiClient.getProfileById(targetUserId!!) + } + + if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) { + cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) } + + Logger.d( + "ProfileScreen", + "load success but blank identity for id=${profile.id}, dropping " + + "as unusable preview" + ) + + state = latestUi.copy( + profile = null, + isLoading = false, + error = ProfileLoadError.Generic + ) + } else { + Logger.d( + "ProfileScreen", + "load success: mode=$lookupMode identifier=$lookupIdentifier -> " + + "id=${profile.id}, username='${profile.username}', " + + "display='${profile.displayName}', deleted=${profile.deleted}, " + + "suspended=${profile.suspended}" + ) + + ProfileCache.put(profile) + state = latestUi.copy(profile = profile, isLoading = false, error = null) + } + } catch (err: Exception) { + val fallback = resolveCachedProfile(targetUserId, targetUsername, ownUserId) + ?: latestUi.profile + val resolvedProfile = latestUi.profile ?: fallback + + if (!hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId)) { + val fallbackId = if (targetUsername != null) null else targetUserId ?: ownUserId + fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) } + } Logger.d( "ProfileScreen", - "load success but blank identity for id=${profile.id}, dropping " + - "as unusable preview" + "load failure fallback lookup: fallbackFound=${fallback != null}" ) + val resolvedErrorMessage = when { + err is ClientRequestException && err.response.status.value == 404 -> profileNotFound + else -> profileLoadFailed + } + + if (err is ClientRequestException) { + Logger.d( + "ProfileScreen", + "load failed: mode=$lookupMode identifier=$lookupIdentifier " + + "status=${err.response.status.value} error=${err.message}" + ) + } else { + Logger.d( + "ProfileScreen", + "load failed: mode=$lookupMode identifier=$lookupIdentifier " + + "errorType=${err::class.simpleName} message=${err.message}" + ) + } + + if ( + showErrorAsToast && + (targetUserId != null || targetUsername != null) && + !hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId) + ) { + showProfileLoadErrorMessage(resolvedErrorMessage) + } + state = latestUi.copy( - profile = null, - isLoading = false, - error = ProfileLoadError.Generic - ) - - return@LaunchedEffect - } - - Logger.d( - "ProfileScreen", - "load success: mode=$lookupMode identifier=$lookupIdentifier -> " + - "id=${profile.id}, username='${profile.username}', " + - "display='${profile.displayName}', deleted=${profile.deleted}, " + - "suspended=${profile.suspended}" - ) - - ProfileCache.put(profile) - state = latestUi.copy(profile = profile, isLoading = false, error = null) - } catch (err: Exception) { - val fallbackId = ( - if (targetUsername != null) null else targetUserId ?: ownUserId - )?.also { - ProfileCache.evictUnusableClientPreview(it) - } - - val fallback = fallbackId?.let { ProfileCache.get(it) } - ?: resolveCachedProfile(targetUserId, targetUsername, ownUserId) - - Logger.d( - "ProfileScreen", - "load failure fallback lookup: fallbackId=$fallbackId " + - "fallbackFound=${fallback != null}" - ) - - val resolvedErrorMessage = when { - err is ClientRequestException && err.response.status.value == 404 -> profileNotFound - else -> profileLoadFailed - } - - if (err is ClientRequestException) { - Logger.d( - "ProfileScreen", - "load failed: mode=$lookupMode identifier=$lookupIdentifier " + - "status=${err.response.status.value} fallbackId=$fallbackId error=${err.message}" - ) - } else { - Logger.d( - "ProfileScreen", - "load failed: mode=$lookupMode identifier=$lookupIdentifier " + - "errorType=${err::class.simpleName} message=${err.message}" - ) - } - - val resolvedProfile = latestUi.profile ?: fallback - - if ( - showErrorAsToast && - (targetUserId != null || targetUsername != null) && - !hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId) - ) { - showProfileLoadErrorMessage(resolvedErrorMessage) - } - - state = latestUi.copy( - error = if (!hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId)) { - if (resolvedErrorMessage == profileLoadFailed) { - ProfileLoadError.Generic + error = if (!hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId)) { + if (resolvedErrorMessage == profileLoadFailed) { + ProfileLoadError.Generic + } else { + ProfileLoadError.Message(resolvedErrorMessage) + } } else { - ProfileLoadError.Message(resolvedErrorMessage) - } - } else { - null - }, - profile = resolvedProfile, - isLoading = false - ) + null + }, + profile = resolvedProfile, + isLoading = false + ) + } + + delay(1000) } } @@ -427,7 +433,6 @@ fun ProfileScreen( val headlineVerification = stringResource(Res.string.profile_headline_verification) val verifiedSupport = stringResource(Res.string.profile_verified_support) val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support) - val labelRetry = stringResource(Res.string.action_retry_send) val profile = state.profile ?: resolveCachedProfile(targetUserId, targetUsername, ownUserId) if (hasDisplayableProfile(profile, initialDisplayName, ownUserId)) { @@ -435,17 +440,17 @@ fun ProfileScreen( } val statusMap by UserStatusStore.status.collectAsState() val lastSeenFormatStrings = rememberLastSeenFormatStrings() - val loadError = state.error val hasDisplayable = hasDisplayableProfile(profile, initialDisplayName, ownUserId) - val showSkeleton = !hasDisplayable && (state.isLoading || loadError != null) + val resolvedProfile = profile?.takeIf { hasDisplayableProfile(it, null, ownUserId) } + val showBodySkeleton = resolvedProfile == null + val showAvatarSkeleton = showBodySkeleton && !hasDisplayable val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id val displayName = profile?.visibleDisplayName(currentProfileUserId) ?: initialDisplayName?.takeIf { it.isNotBlank() } - ?: "?" + ?: profile?.username?.takeIf { it.isNotBlank() } + ?: "" val usernameForLinks = profile?.visibleUsername(currentProfileUserId) - - val resolvedProfile = profile?.takeIf { !showSkeleton } val profileLink = resolvedProfile?.let { usernameForLinks?.let { name -> "https://fromchat.ru/@$name" } ?: "https://fromchat.ru/?u=${it.id}" @@ -558,7 +563,7 @@ fun ProfileScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { when { - showSkeleton && !hideAvatar -> { + showAvatarSkeleton && !hideAvatar -> { item { ShimmerBox( modifier = Modifier @@ -591,9 +596,9 @@ fun ProfileScreen( !hideAvatar -> { item { - Avatar( - profilePictureUrl = profile?.profilePicture, - displayName = displayName, + Avatar( + profilePictureUrl = profile?.profilePicture, + displayName = displayName, modifier = Modifier .padding(top = profileAvatarTop) .size(104.dp) @@ -630,24 +635,18 @@ fun ProfileScreen( item { AnimatedContent( - targetState = showSkeleton, + modifier = Modifier.fillMaxWidth(), + targetState = showBodySkeleton, transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "profile_body", ) { skeleton -> if (skeleton) { - ProfileSkeletonBody( - onRetry = if (loadError != null) { - { reloadAttempt++ } - } else { - null - }, - retryLabel = labelRetry, - ) - } else if (resolvedProfile != null) { + ProfileSkeletonBody() + } else { ProfileLoadedBody( - resolvedProfile = resolvedProfile, + resolvedProfile = resolvedProfile!!, displayName = displayName, isOwnProfile = isOwnProfile, typingUsers = typingUsers, @@ -740,31 +739,34 @@ fun PublicChatProfileScreen( profile = PublicChatProfileCache.profile, isLoading = PublicChatProfileCache.profile == null, error = null, - ) + ), ) } - val hasShownContent = remember { - mutableStateOf(PublicChatProfileCache.profile != null) - } - val latestUi by rememberUpdatedState(state) LaunchedEffect(Unit) { + runCatching { PublicChatProfileCache.hydrateFromDisk() } + val diskProfile = PublicChatProfileCache.profile + if (diskProfile != null) { + state = latestUi.copy(profile = diskProfile, isLoading = false, error = null) + } else if (latestUi.profile == null) { + state = latestUi.copy(isLoading = true, error = null) + } + try { - val profile = ApiClient.getPublicChatProfile() - PublicChatProfileCache.put(profile) + val profile = PublicChatProfileSync.refreshFromNetwork() state = latestUi.copy(profile = profile, isLoading = false, error = null) } catch (err: Exception) { - val cached = PublicChatProfileCache.profile + val cached = PublicChatProfileCache.profile ?: latestUi.profile val resolvedErrorMessage = when { err is ClientRequestException && err.response.status.value == 404 -> profileNotFound else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed } state = latestUi.copy( - profile = latestUi.profile ?: cached, + profile = cached, isLoading = false, - error = if (latestUi.profile == null && cached == null) { + error = if (cached?.title.isNullOrBlank() && initialDisplayName.isNullOrBlank()) { if (resolvedErrorMessage == profileLoadFailed) { PublicChatProfileLoadError.Generic } else { @@ -780,15 +782,10 @@ fun PublicChatProfileScreen( val hazeState = rememberHazeState() val profile = state.profile - if (profile != null) { - hasShownContent.value = true - } - val loadError = state.error - val showLoadingSpinner = state.isLoading && !hasShownContent.value - val displayName = profile?.title?.takeIf { it.isNotBlank() } + val resolvedProfile = profile?.takeIf { !it.title.isNullOrBlank() } + val displayName = resolvedProfile?.title?.takeIf { it.isNotBlank() } ?: initialDisplayName?.takeIf { it.isNotBlank() } ?: "" - val resolvedProfile = profile?.takeIf { loadError == null && !showLoadingSpinner } val profileLink = resolvedProfile?.let { "https://fromchat.ru/chats/${it.id}" } val profileActions = resolvedProfile?.let { listOf( @@ -843,127 +840,57 @@ fun PublicChatProfileScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { when { - showLoadingSpinner -> { - item { Spacer(Modifier.height(profileAvatarTop + 104.dp + 12.dp)) } + useSharedAvatar && displayName.isNotBlank() -> { item { - CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) - } - } - - loadError != null -> { - item { Spacer(Modifier.height(profileAvatarTop + 104.dp + 12.dp)) } - item { - Text( - text = when (loadError) { - PublicChatProfileLoadError.Generic -> profileLoadFailed - is PublicChatProfileLoadError.Message -> loadError.text - }, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 24.dp), - ) - } - } - - resolvedProfile != null || initialDisplayName != null -> { - item { - if (useSharedAvatar) { - with(sharedTransitionScope!!) { - Avatar( - profilePictureUrl = null, - displayName = displayName, - modifier = Modifier - .padding(top = profileAvatarTop) - .sharedElement( - rememberSharedContentState(key = sharedAvatarKey!!), - animatedVisibilityScope = animatedVisibilityScope!!, - ) - .size(104.dp), - ) - } - } else { + with(sharedTransitionScope!!) { Avatar( profilePictureUrl = null, displayName = displayName, modifier = Modifier .padding(top = profileAvatarTop) + .sharedElement( + rememberSharedContentState(key = sharedAvatarKey!!), + animatedVisibilityScope = animatedVisibilityScope!!, + ) .size(104.dp), ) } } - item { Spacer(Modifier.height(12.dp)) } + } - if (resolvedProfile != null) { - item { - Text( - text = resolvedProfile.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - item { Spacer(Modifier.height(4.dp)) } - - if (showMemberCount) { - item { - Text( - text = membersCountText.orEmpty(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - item { Spacer(Modifier.height(24.dp)) } - - item { - ProfileActionButtonRow( - actions = profileActions.orEmpty(), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) - } - - if (showBio) { - Category( - margin = PaddingValues( - start = 16.dp, - end = 16.dp, - top = 28.dp, - bottom = 20.dp, - ), - roundedCorners = false, - ) { - item { - ListItem( - headline = headlineBio, - supportingSlot = { - ProfileBioMarkdown( - content = resolvedProfile.bio.orEmpty(), - ) - }, - position = ListItemPosition.START, - groupItemCount = 1, - leadingContent = { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = null, - tint = listItemIconTint, - ) - }, - contextMenu = { - item(Icons.Rounded.ContentCopy, labelCopy) { - scope.launch { - clipboard.setText(resolvedProfile.bio.orEmpty()) - } - } - }, - ) - } - } - } + resolvedProfile != null -> { + item { + Avatar( + profilePictureUrl = null, + displayName = displayName, + modifier = Modifier + .padding(top = profileAvatarTop) + .size(104.dp), + ) } + item { Spacer(Modifier.height(12.dp)) } + } + + else -> { + item { Spacer(Modifier.height(profileAvatarTop)) } + } + } + + if (resolvedProfile != null) { + item { + PublicChatProfileLoadedBody( + resolvedProfile = resolvedProfile, + profileActions = profileActions.orEmpty(), + showMemberCount = showMemberCount, + membersCountText = membersCountText.orEmpty(), + showBio = showBio, + headlineBio = headlineBio, + listItemIconTint = listItemIconTint, + labelCopy = labelCopy, + clipboard = clipboard, + scope = scope, + ) } } } @@ -988,6 +915,88 @@ fun PublicChatProfileScreen( } } +@Composable +private fun PublicChatProfileLoadedBody( + resolvedProfile: PublicChatProfile, + profileActions: List, + showMemberCount: Boolean, + membersCountText: String, + showBio: Boolean, + headlineBio: String, + listItemIconTint: Color, + labelCopy: String, + clipboard: SupportClipboardManager, + scope: CoroutineScope, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = resolvedProfile.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(4.dp)) + + if (showMemberCount) { + Text( + text = membersCountText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(24.dp)) + + ProfileActionButtonRow( + actions = profileActions, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + if (showBio) { + Category( + margin = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 28.dp, + bottom = 20.dp, + ), + roundedCorners = false, + ) { + ListItem( + headline = headlineBio, + supportingSlot = { + ProfileBioMarkdown( + content = resolvedProfile.bio.orEmpty(), + ) + }, + position = ListItemPosition.START, + groupItemCount = 1, + leadingContent = { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = listItemIconTint, + ) + }, + contextMenu = { + item(Icons.Rounded.ContentCopy, labelCopy) { + scope.launch { + clipboard.setText(resolvedProfile.bio.orEmpty()) + } + } + }, + ) + } + } + } +} + expect fun showProfileLoadErrorMessage(message: String) private sealed interface PublicChatProfileLoadError { @@ -1032,13 +1041,12 @@ private fun targetWeightForPress( @Composable private fun ProfileSkeletonBody( - onRetry: (() -> Unit)?, - retryLabel: String, modifier: Modifier = Modifier, ) { val barShape = RoundedCornerShape(8.dp) val pillShape = MaterialTheme.shapes.extraLarge val listBarShape = RoundedCornerShape(4.dp) + val skeletonRowCount = 3 Column( modifier = modifier.fillMaxWidth(), @@ -1050,7 +1058,7 @@ private fun ProfileSkeletonBody( .height(28.dp), shape = barShape, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(4.dp)) ShimmerBox( modifier = Modifier .width(112.dp) @@ -1082,68 +1090,40 @@ private fun ProfileSkeletonBody( ), roundedCorners = false, ) { - ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = true) - ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = true) - ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = false) - } - if (onRetry != null) { - Spacer(Modifier.height(8.dp)) - TextButton(onClick = onRetry) { - Text( - text = retryLabel, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + repeat(skeletonRowCount) { index -> + ListItem( + headline = "", + headlineSlot = { + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.38f) + .height(14.dp), + shape = listBarShape, + ) + }, + supportingSlot = { + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.62f) + .height(12.dp), + shape = listBarShape, + ) + }, + leadingContent = { + ShimmerBox( + modifier = Modifier.size(24.dp), + shape = CircleShape, + ) + }, + divider = index < skeletonRowCount - 1, + position = listItemPositionInGroup(index, skeletonRowCount), + groupItemCount = skeletonRowCount, ) } } } } -@Composable -private fun ProfileSkeletonListRow( - listBarShape: RoundedCornerShape, - showDivider: Boolean, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - ShimmerBox( - modifier = Modifier.size(24.dp), - shape = CircleShape, - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - ShimmerBox( - modifier = Modifier - .fillMaxWidth(0.38f) - .height(14.dp), - shape = listBarShape, - ) - ShimmerBox( - modifier = Modifier - .fillMaxWidth(0.62f) - .height(12.dp), - shape = listBarShape, - ) - } - } - if (showDivider) { - Spacer( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .height(1.dp) - .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - ) - } -} - @Composable private fun ProfileLoadedBody( resolvedProfile: UserProfile, @@ -1209,8 +1189,7 @@ private fun ProfileLoadedBody( ) } StatusBadge( - verified = resolvedProfile.verified, - userId = resolvedProfile.id, + verificationStatus = resolvedProfile.effectiveVerificationStatus(), ) } @@ -1400,9 +1379,19 @@ private fun ProfileLoadedBody( ApiClient.verifyUser(resolvedProfile.id) }.getOrNull() } - result?.verified?.let { newVerified -> + result?.let { response -> onProfileUpdated( - resolvedProfile.copy(verified = newVerified), + resolvedProfile.copy( + verified = response.verified, + verificationStatus = response.verificationStatus + ?: response.verified.let { verified -> + if (verified) { + ru.fromchat.api.schema.user.profile.VerificationStatus.Verified + } else { + ru.fromchat.api.schema.user.profile.VerificationStatus.None + } + }, + ), ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt index aaf25b9..7d1b357 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/StatusBadge.kt @@ -7,59 +7,37 @@ import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.api.ApiClient +import ru.fromchat.api.schema.user.profile.VerificationStatus import ru.fromchat.cd_similar_verified import ru.fromchat.cd_verified_account @Composable fun StatusBadge( - verified: Boolean?, - userId: Int?, + verificationStatus: VerificationStatus?, modifier: Modifier = Modifier, - size: Dp = 20.dp + size: Dp = 20.dp, ) { - var isSimilarToVerified by remember(userId, verified) { - mutableStateOf(false) - } - - LaunchedEffect(verified, userId, ApiClient.token) { - isSimilarToVerified = !( - verified == true || - userId == null || - ApiClient.token == null - ) && withContext(Dispatchers.Default) { - runCatching { - ApiClient.checkSimilarity(userId) - }.getOrNull() - }?.isSimilar == true - } - - when { - verified == true -> Icon( + when (verificationStatus) { + VerificationStatus.Verified -> Icon( imageVector = Icons.Filled.Verified, contentDescription = stringResource(Res.string.cd_verified_account), modifier = modifier.size(size), - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) - isSimilarToVerified -> Icon( + VerificationStatus.Warning -> Icon( imageVector = Icons.Filled.Warning, contentDescription = stringResource(Res.string.cd_similar_verified), modifier = modifier.size(size), - tint = Color(0xFFFFA000) + tint = Color(0xFFFFA000), ) + + VerificationStatus.None, null -> Unit } } 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 new file mode 100644 index 0000000..6c73289 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/VerificationStatusResolver.kt @@ -0,0 +1,33 @@ +package ru.fromchat.ui.profile + +import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.schema.messages.Message +import ru.fromchat.api.schema.user.User +import ru.fromchat.api.schema.user.profile.UserProfile +import ru.fromchat.api.schema.user.profile.VerificationStatus +import ru.fromchat.api.schema.user.profile.orFromLegacyVerified + +fun UserProfile.effectiveVerificationStatus(): VerificationStatus = + verificationStatus.orFromLegacyVerified(verified) + +fun User.effectiveVerificationStatus(): VerificationStatus = + verificationStatus.orFromLegacyVerified(verified) + +fun resolveVerificationStatus( + userId: Int, + message: Message? = null, + user: User? = null, +): VerificationStatus? { + ProfileCache.get(userId)?.effectiveVerificationStatus()?.let { cached -> + if (cached != VerificationStatus.None || ProfileCache.get(userId)?.verificationStatus != null) { + return cached + } + } + + user?.effectiveVerificationStatus()?.let { return it } + + message?.verificationStatus?.let { return it } + message?.verified?.let { return if (it) VerificationStatus.Verified else null } + + return null +} diff --git a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq index 41e3c89..49c570e 100644 --- a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq +++ b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq @@ -77,6 +77,11 @@ CREATE TABLE profile_cache ( PRIMARY KEY (instanceId, userId) ); +CREATE TABLE public_chat_profile ( + instanceId TEXT NOT NULL PRIMARY KEY, + json TEXT NOT NULL +); + -- server_binding selectActiveInstanceIdForConfig: SELECT activeInstanceId FROM server_binding WHERE configKey = ?; @@ -231,12 +236,16 @@ DELETE FROM attachment WHERE instanceId = ?; deleteAllProfilesForInstance: DELETE FROM profile_cache WHERE instanceId = ?; +deletePublicChatProfileForInstance: +DELETE FROM public_chat_profile WHERE instanceId = ?; + purgeAllCache: DELETE FROM message; DELETE FROM conversation; DELETE FROM outbox; DELETE FROM attachment; DELETE FROM profile_cache; +DELETE FROM public_chat_profile; DELETE FROM server_binding; DELETE FROM instance_registry; @@ -360,3 +369,11 @@ DELETE FROM profile_cache WHERE instanceId = ? AND userId = ?; deleteAllProfileCache: DELETE FROM profile_cache; + +-- public chat profile +upsertPublicChatProfile: +INSERT OR REPLACE INTO public_chat_profile(instanceId, json) +VALUES (?, ?); + +selectPublicChatProfile: +SELECT json FROM public_chat_profile WHERE instanceId = ?; diff --git a/gradle.properties b/gradle.properties index 8058c6f..2e4dd2e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -29,5 +29,9 @@ android.uniquePackageNames=false android.dependency.useConstraints=true android.r8.strictFullModeForKeepRules=false android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false +# Smaller release APK: aggressive R8 shrinking + compressed native libs in the APK. +android.enableR8.fullMode=true +android.r8.optimizedResourceShrinking=true +android.bundle.enableNativeLibraryCompressionInApk=true # Configuration cache can surface noisy IDE/sync issues on some setups; turn on when stable. org.gradle.configuration-cache=false \ No newline at end of file diff --git a/utils/shared/build.gradle.kts b/utils/shared/build.gradle.kts index 3101e87..aff24d4 100644 --- a/utils/shared/build.gradle.kts +++ b/utils/shared/build.gradle.kts @@ -40,7 +40,6 @@ kotlin { implementation(libs.navigation.compose) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.core) - implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.websockets) diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt index 8dcdfde..8404afd 100644 --- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt @@ -542,6 +542,7 @@ fun ListItem( modifier: Modifier = Modifier, bodyModifier: Modifier = Modifier, headline: String, + headlineSlot: (@Composable () -> Unit)? = null, supportingText: String? = null, supportingSlot: (@Composable () -> Unit)? = null, leadingContent: (@Composable () -> Unit)? = null, @@ -678,7 +679,7 @@ fun ListItem( } ListItem( - headlineContent = { Text(headline) }, + headlineContent = headlineSlot ?: { Text(headline) }, supportingContent = { when { supportingSlot != null -> supportingSlot()