Fix bugs from test #2

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-05 21:14:03 +03:00
Unverified
parent 0752ed980a
commit 5a28f0c756
44 changed files with 1233 additions and 589 deletions
+18 -2
View File
@@ -64,6 +64,10 @@ extensions.configure<ApplicationExtension> {
targetSdk = 37 targetSdk = 37
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
} }
signingConfigs { signingConfigs {
@@ -109,7 +113,19 @@ extensions.configure<ApplicationExtension> {
packaging { packaging {
resources { 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(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.play.services.base) implementation(libs.play.services.base)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.adaptive.android) implementation(libs.androidx.adaptive.android)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
+36 -18
View File
@@ -1,21 +1,39 @@
# Add project specific ProGuard rules here. # Release shrinking: obfuscate/rename classes and members as aggressively as R8 allows.
# You can control the set of applied configuration files using the # proguard-android-optimize.txt (from build.gradle) supplies repackageclasses, overloadaggressively, etc.
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following # Crash reports: keep real .kt file names and line numbers; class names stay obfuscated.
# 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.
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to # Kotlin serialization keep structure required at runtime; names may still be shortened.
# hide the original source file name. -keepattributes *Annotation*, InnerClasses, Signature
#-renamesourcefileattribute SourceFile -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.**
-2
View File
@@ -43,7 +43,6 @@ kotlin {
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.components.resources) implementation(libs.compose.components.resources)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.constraintlayout) implementation(libs.constraintlayout)
implementation(libs.navigation.compose) implementation(libs.navigation.compose)
implementation(libs.compose.materialIconsExtended) implementation(libs.compose.materialIconsExtended)
@@ -59,7 +58,6 @@ kotlin {
// Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3 // Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.websockets)
@@ -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.BackupBlobRequest
import ru.fromchat.api.schema.user.keys.BackupBlobResponse import ru.fromchat.api.schema.user.keys.BackupBlobResponse
import ru.fromchat.api.schema.user.keys.PublicKeyResponse 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.UpdateProfileRequest
import ru.fromchat.api.schema.user.profile.UpdateProfileResponse import ru.fromchat.api.schema.user.profile.UpdateProfileResponse
import ru.fromchat.api.schema.user.profile.UserProfile import ru.fromchat.api.schema.user.profile.UserProfile
@@ -251,6 +250,10 @@ object ApiClient {
handleForbiddenAsPotentialSuspension(response) handleForbiddenAsPotentialSuspension(response)
} }
if (response.status.value in 200..299 && token != null) {
WebSocketManager.onServerLikelyReachable()
}
if (response.status.value !in (200..299) + 101) { if (response.status.value !in (200..299) + 101) {
throw ClientRequestException( throw ClientRequestException(
response, response,
@@ -601,15 +604,6 @@ object ApiClient {
.body<RegisteredUserCountResponse>() .body<RegisteredUserCountResponse>()
.count .count
suspend fun checkSimilarity(userId: Int): SimilarityResult? =
runCatching {
http
.get("${ServerConfig.apiBaseUrl}/user/check-similarity/$userId") {
contentType(ContentType.Application.Json)
}
.body<SimilarityResult>()
}.getOrNull()
suspend fun verifyUser(userId: Int): VerifyResponse? = suspend fun verifyUser(userId: Int): VerifyResponse? =
runCatching { runCatching {
http http
@@ -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() }
}
}
@@ -7,8 +7,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import ru.fromchat.api.ApiClient 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.InstanceRegistryStore
import ru.fromchat.api.local.db.store.MessageRepository 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.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.config.Settings 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) CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) }
PublicChatProfileSync.ensureStarted()
scheduleOutboxProcessing(instanceId) scheduleOutboxProcessing(instanceId)
scheduleAttachmentResumeAfterSession() scheduleAttachmentResumeAfterSession()
ApiClient.user?.id?.let { userId -> ApiClient.user?.id?.let { userId ->
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
@@ -47,14 +48,17 @@ import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
object WebSocketManager { object WebSocketManager {
private const val TAG = "WebSocketManager" private const val TAG = "WebSocketManager"
private const val MIN_RECONNECT_DELAY_MS = 1_000L private const val RECONNECT_DELAY_MS = 1_000L
private const val MAX_RECONNECT_DELAY_MS = 60_000L
private const val NETWORK_AVAILABLE_DEBOUNCE_MS = 2_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 private const val FOREGROUND_DELAY_CHUNK_MS = 200L
@Volatile @Volatile
private var lastOnNetworkAvailableWallMs: Long = 0L private var lastOnNetworkAvailableWallMs: Long = 0L
@Volatile
private var lastServerReachableNotifyWallMs: Long = 0L
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@@ -145,10 +149,16 @@ object WebSocketManager {
if (forceRestart) { if (forceRestart) {
connectionJob?.cancel() connectionJob?.cancel()
connectionJob = null connectionJob = null
session?.cancel()
session = null
connecting = false
} else { } else {
val existingJob = connectionJob val existingJob = connectionJob
if (existingJob != null && existingJob.isActive) { if (existingJob != null && existingJob.isActive) {
logD("connect() ignored: connectionJob already running") logD("connect() ignored: connectionJob already running")
if (session != null) {
ConnectionStateStore.onConnected()
}
return return
} }
} }
@@ -156,7 +166,6 @@ object WebSocketManager {
ConnectionStateStore.onConnecting() ConnectionStateStore.onConnecting()
connectionJob = scope.launch { connectionJob = scope.launch {
var reconnectDelayMs = MIN_RECONNECT_DELAY_MS
while (isActive) { while (isActive) {
awaitForeground() awaitForeground()
@@ -166,7 +175,7 @@ object WebSocketManager {
if (token.isNullOrEmpty()) { if (token.isNullOrEmpty()) {
logD("No auth token available; staying in CONNECTING and retrying later") logD("No auth token available; staying in CONNECTING and retrying later")
ConnectionStateStore.onConnecting() ConnectionStateStore.onConnecting()
delayWhileForeground(MIN_RECONNECT_DELAY_MS) delayWhileForeground(RECONNECT_DELAY_MS)
continue continue
} }
@@ -186,7 +195,6 @@ object WebSocketManager {
} }
} }
) { ) {
reconnectDelayMs = MIN_RECONNECT_DELAY_MS
session = this session = this
connecting = false connecting = false
logD("WebSocket connected. connecting set to false") logD("WebSocket connected. connecting set to false")
@@ -256,17 +264,20 @@ object WebSocketManager {
} catch (e: Throwable) { } catch (e: Throwable) {
logW("An error occurred during WebSocket connection: ${e.message}", e) logW("An error occurred during WebSocket connection: ${e.message}", e)
} finally { } finally {
logW("WebSocket disconnected. session set to null, connecting set to false") val isCurrentJob = connectionJob === currentCoroutineContext()[Job]
session = null if (!isCurrentJob) {
connecting = false logD("Stale connection job finished; skipping session cleanup")
ConnectionStateStore.onConnecting() } else {
logW("WebSocket disconnected. session set to null, connecting set to false")
session = null
connecting = false
ConnectionStateStore.onConnecting()
}
if (isActive) { if (isActive) {
awaitForeground() awaitForeground()
logD("Reconnecting in ${reconnectDelayMs}ms...") logD("Reconnecting in ${RECONNECT_DELAY_MS}ms...")
delayWhileForeground(reconnectDelayMs) delayWhileForeground(RECONNECT_DELAY_MS)
reconnectDelayMs =
(reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_DELAY_MS)
} }
} }
} }
@@ -365,8 +376,38 @@ object WebSocketManager {
return return
} }
lastOnNetworkAvailableWallMs = now lastOnNetworkAvailableWallMs = now
if (session != null) {
logD("onNetworkAvailable: already connected, flushing outbox")
OutgoingMessageCoordinator.onTransportReady()
return
}
logD("onNetworkAvailable: reconnect and flush outbox") logD("onNetworkAvailable: reconnect and flush outbox")
connect(forceRestart = true) connect(forceRestart = true)
OutgoingMessageCoordinator.onTransportReady() 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()
}
}
} }
@@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import ru.fromchat.api.local.db.store.ProfileCache 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.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.cache.CacheContext.activeInstanceId import ru.fromchat.api.local.cache.CacheContext.activeInstanceId
import ru.fromchat.ui.chat.utils.PublicChatPanelCache import ru.fromchat.ui.chat.utils.PublicChatPanelCache
@@ -27,6 +28,7 @@ object CacheContext {
_activeUserId.value = userId _activeUserId.value = userId
if (changed) { if (changed) {
ProfileCache.onActiveInstanceChanged(trimmed) ProfileCache.onActiveInstanceChanged(trimmed)
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(trimmed) }
PublicChatPanelCache.onActiveInstanceChanged(trimmed) PublicChatPanelCache.onActiveInstanceChanged(trimmed)
DmPanelCache.onActiveInstanceChanged(trimmed) DmPanelCache.onActiveInstanceChanged(trimmed)
if (trimmed.isNotEmpty()) { if (trimmed.isNotEmpty()) {
@@ -12,6 +12,7 @@ internal object MessageDatabaseExpectedSchema {
"attachment", "attachment",
"outbox", "outbox",
"profile_cache", "profile_cache",
"public_chat_profile",
) )
val tableCreateSql: Map<String, String> = mapOf( val tableCreateSql: Map<String, String> = mapOf(
@@ -99,6 +100,12 @@ internal object MessageDatabaseExpectedSchema {
PRIMARY KEY (instanceId, userId) PRIMARY KEY (instanceId, userId)
) )
""".trimIndent(), """.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. */ /** 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", "userId" to "INTEGER NOT NULL",
"json" to "TEXT 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. */ /** Tables that require [instanceId]; missing it triggers rebuild-with-copy, not wipe. */
@@ -416,6 +416,7 @@ private fun recreateMessageDatabaseSchema(driver: SqlDriver) {
"DROP TABLE IF EXISTS attachment__migrate", "DROP TABLE IF EXISTS attachment__migrate",
"DROP TABLE IF EXISTS outbox", "DROP TABLE IF EXISTS outbox",
"DROP TABLE IF EXISTS profile_cache", "DROP TABLE IF EXISTS profile_cache",
"DROP TABLE IF EXISTS public_chat_profile",
"DROP TABLE IF EXISTS conversation", "DROP TABLE IF EXISTS conversation",
"DROP TABLE IF EXISTS conversation__migrate", "DROP TABLE IF EXISTS conversation__migrate",
"DROP TABLE IF EXISTS server_binding", "DROP TABLE IF EXISTS server_binding",
@@ -99,6 +99,7 @@ object InstanceRegistryStore {
db.messageDatabaseQueries.deleteAllOutboxForInstance(id) db.messageDatabaseQueries.deleteAllOutboxForInstance(id)
db.messageDatabaseQueries.deleteAllAttachmentsForInstance(id) db.messageDatabaseQueries.deleteAllAttachmentsForInstance(id)
db.messageDatabaseQueries.deleteAllProfilesForInstance(id) db.messageDatabaseQueries.deleteAllProfilesForInstance(id)
db.messageDatabaseQueries.deletePublicChatProfileForInstance(id)
} }
} }
} }
@@ -96,6 +96,42 @@ object MessageCacheStore {
suspend fun loadRecentPublicMessages(limit: Long): List<Message> = suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
loadRecentMessages(conversationIdForPublic(), limit) loadRecentMessages(conversationIdForPublic(), limit)
fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> {
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( suspend fun loadRecentPublicChatPreviewState(
strings: ChatListPreviewStrings, strings: ChatListPreviewStrings,
limit: Long = 1, limit: Long = 1,
@@ -556,31 +592,39 @@ object MessageCacheStore {
} }
} }
fun loadCachedDmConversationsImmediate(instanceId: String): List<CachedConversation> {
if (instanceId.isBlank()) return emptyList()
return loadCachedDmConversationsRows(instanceId)
}
suspend fun loadCachedDmConversations(): List<CachedConversation> = suspend fun loadCachedDmConversations(): List<CachedConversation> =
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val iid = instanceId() loadCachedDmConversationsRows(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(),
)
}
} }
private fun loadCachedDmConversationsRows(instanceId: String): List<CachedConversation> {
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) @OptIn(ExperimentalCoroutinesApi::class)
fun observeActiveDmConversations(instanceId: String): Flow<List<CachedConversation>> { fun observeActiveDmConversations(instanceId: String): Flow<List<CachedConversation>> {
val conversationsFlow = db.messageDatabaseQueries val conversationsFlow = db.messageDatabaseQueries
@@ -959,6 +1003,7 @@ object MessageCacheStore {
username = usernameResolved, username = usernameResolved,
profile_picture = pictureResolved, profile_picture = pictureResolved,
verified = profile?.verified, verified = profile?.verified,
verificationStatus = profile?.verificationStatus,
reply_to = null, reply_to = null,
client_message_id = clientMessageId, client_message_id = clientMessageId,
reactions = null, reactions = null,
@@ -31,11 +31,25 @@ object MessageRepository {
suspend fun loadRecentPublicMessages(limit: Long): List<Message> = suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
MessageCacheStore.loadRecentPublicMessages(limit) MessageCacheStore.loadRecentPublicMessages(limit)
fun loadRecentPublicMessagesImmediate(limit: Long = 128): List<Message> {
val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isBlank()) return emptyList()
return MessageCacheStore.loadRecentPublicMessagesImmediate(instanceId, limit)
}
suspend fun loadRecentPublicChatPreviewState( suspend fun loadRecentPublicChatPreviewState(
strings: ChatListPreviewStrings, strings: ChatListPreviewStrings,
limit: Long = 1, limit: Long = 1,
): ChatListPreviewState? = MessageCacheStore.loadRecentPublicChatPreviewState(strings, limit) ): 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<ChatListPreviewState?> = fun observePublicChatPreviewState(strings: ChatListPreviewStrings): Flow<ChatListPreviewState?> =
MessageCacheStore.observePublicChatPreviewState(activeInstance(), strings) MessageCacheStore.observePublicChatPreviewState(activeInstance(), strings)
@@ -85,6 +99,12 @@ object MessageRepository {
suspend fun loadCachedDmConversations(): List<CachedConversation> = suspend fun loadCachedDmConversations(): List<CachedConversation> =
MessageCacheStore.loadCachedDmConversations() MessageCacheStore.loadCachedDmConversations()
fun loadCachedDmConversationsImmediate(): List<CachedConversation> {
val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isBlank()) return emptyList()
return MessageCacheStore.loadCachedDmConversationsImmediate(instanceId)
}
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) = suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) =
MessageCacheStore.ensureDmConversationRow(otherUserId, displayName) MessageCacheStore.ensureDmConversationRow(otherUserId, displayName)
@@ -9,6 +9,7 @@ import kotlinx.coroutines.sync.withLock
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.user.User import ru.fromchat.api.schema.user.User
import ru.fromchat.api.schema.user.profile.UserProfile import ru.fromchat.api.schema.user.profile.UserProfile
import ru.fromchat.api.schema.user.profile.VerificationStatus
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
@@ -61,6 +62,7 @@ object ProfileCache {
username: String? = null, username: String? = null,
displayName: String? = null, displayName: String? = null,
profilePicture: String? = null, profilePicture: String? = null,
verificationStatus: VerificationStatus? = null,
) { ) {
if (id <= 0) return if (id <= 0) return
val existing = get(id) val existing = get(id)
@@ -86,6 +88,7 @@ object ProfileCache {
lastSeen = existing?.lastSeen, lastSeen = existing?.lastSeen,
createdAt = existing?.createdAt, createdAt = existing?.createdAt,
verified = existing?.verified, verified = existing?.verified,
verificationStatus = verificationStatus ?: existing?.verificationStatus,
suspended = existing?.suspended, suspended = existing?.suspended,
suspensionReason = existing?.suspensionReason, suspensionReason = existing?.suspensionReason,
deleted = existing?.deleted, deleted = existing?.deleted,
@@ -163,7 +166,8 @@ object ProfileCache {
online = user.online, online = user.online,
lastSeen = user.last_seen.takeIf { it.isNotBlank() } ?: existing?.lastSeen, lastSeen = user.last_seen.takeIf { it.isNotBlank() } ?: existing?.lastSeen,
createdAt = user.created_at.takeIf { it.isNotBlank() } ?: existing?.createdAt, 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, suspended = existing?.suspended,
suspensionReason = existing?.suspensionReason, suspensionReason = existing?.suspensionReason,
deleted = existing?.deleted, deleted = existing?.deleted,
@@ -192,7 +196,8 @@ object ProfileCache {
online = existing?.online ?: false, online = existing?.online ?: false,
lastSeen = existing?.lastSeen, lastSeen = existing?.lastSeen,
createdAt = existing?.createdAt, createdAt = existing?.createdAt,
verified = existing?.verified, verified = message.verified ?: existing?.verified,
verificationStatus = message.verificationStatus ?: existing?.verificationStatus,
suspended = existing?.suspended, suspended = existing?.suspended,
suspensionReason = existing?.suspensionReason, suspensionReason = existing?.suspensionReason,
deleted = existing?.deleted, deleted = existing?.deleted,
@@ -1,19 +1,87 @@
package ru.fromchat.api.local.db.store 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 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 { object PublicChatProfileCache {
var profile: PublicChatProfile? = null private val _profile = MutableStateFlow<PublicChatProfile?>(null)
private set val profileState: StateFlow<PublicChatProfile?> = _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) { 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() { suspend fun onActiveInstanceChanged(instanceId: String) {
profile = null 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()
} }
@@ -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)
}
}
}
@@ -13,6 +13,7 @@ import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.repairInterruptedUploadArtifacts import ru.fromchat.api.local.cache.repairInterruptedUploadArtifacts
import ru.fromchat.api.PublicChatProfileSync
import ru.fromchat.api.instance.applyCachedSessionInstanceIfAvailable import ru.fromchat.api.instance.applyCachedSessionInstanceIfAvailable
import ru.fromchat.api.instance.scheduleSessionInstanceNetworkRefresh import ru.fromchat.api.instance.scheduleSessionInstanceNetworkRefresh
@@ -34,6 +35,7 @@ object AttachmentTransferBootstrap {
AttachmentDownloadNotifier.hydrateFromDisk() AttachmentDownloadNotifier.hydrateFromDisk()
if (ApiClient.token.isNullOrEmpty()) return if (ApiClient.token.isNullOrEmpty()) return
applyCachedSessionInstanceIfAvailable() applyCachedSessionInstanceIfAvailable()
PublicChatProfileSync.ensureStarted()
resumeAttachmentsForActiveInstance() resumeAttachmentsForActiveInstance()
scheduleSessionInstanceNetworkRefresh() scheduleSessionInstanceNetworkRefresh()
} }
@@ -1,7 +1,9 @@
package ru.fromchat.api.schema.messages package ru.fromchat.api.schema.messages
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient 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.DmEnvelope
import ru.fromchat.api.schema.messages.dm.DmFile import ru.fromchat.api.schema.messages.dm.DmFile
import ru.fromchat.api.schema.websocket.types.ReactionData import ru.fromchat.api.schema.websocket.types.ReactionData
@@ -17,6 +19,7 @@ data class Message(
val username: String, val username: String,
val profile_picture: String? = null, val profile_picture: String? = null,
val verified: Boolean? = null, val verified: Boolean? = null,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
val reply_to: Message? = null, val reply_to: Message? = null,
val client_message_id: String? = null, val client_message_id: String? = null,
val reactions: List<ReactionData>? = null, val reactions: List<ReactionData>? = null,
@@ -2,6 +2,7 @@ package ru.fromchat.api.schema.user
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import ru.fromchat.api.schema.user.profile.VerificationStatus
@Serializable @Serializable
data class User( data class User(
@@ -14,7 +15,9 @@ data class User(
val admin: Boolean? = null, val admin: Boolean? = null,
val bio: String? = null, val bio: String? = null,
val profile_picture: String? = null, val profile_picture: String? = null,
val verified: Boolean? = null,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
val suspended: Boolean? = null, val suspended: Boolean? = null,
@SerialName("suspension_reason") val suspensionReason: String? = null @SerialName("suspension_reason") val suspensionReason: String? = null,
) )
@@ -14,6 +14,7 @@ data class ProfileDialogData(
val online: Boolean? = null, val online: Boolean? = null,
@SerialName("is_own_profile") val isOwnProfile: Boolean = false, @SerialName("is_own_profile") val isOwnProfile: Boolean = false,
val verified: Boolean? = null, val verified: Boolean? = null,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
val suspended: Boolean? = null, val suspended: Boolean? = null,
@SerialName("suspension_reason") val suspensionReason: String? = null, @SerialName("suspension_reason") val suspensionReason: String? = null,
val deleted: Boolean? = null val deleted: Boolean? = 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
)
@@ -14,6 +14,7 @@ data class UserProfile(
@SerialName("last_seen") val lastSeen: String? = null, @SerialName("last_seen") val lastSeen: String? = null,
@SerialName("created_at") val createdAt: String? = null, @SerialName("created_at") val createdAt: String? = null,
val verified: Boolean? = null, val verified: Boolean? = null,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
val suspended: Boolean? = null, val suspended: Boolean? = null,
@SerialName("suspension_reason") val suspensionReason: String? = null, @SerialName("suspension_reason") val suspensionReason: String? = null,
val deleted: Boolean? = null, val deleted: Boolean? = null,
@@ -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
}
@@ -1,8 +1,10 @@
package ru.fromchat.api.schema.user.profile package ru.fromchat.api.schema.user.profile
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class VerifyResponse( data class VerifyResponse(
val verified: Boolean val verified: Boolean,
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
) )
@@ -56,6 +56,7 @@ import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground import ru.fromchat.AppForeground
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.api.DeferredStartupNetwork import ru.fromchat.api.DeferredStartupNetwork
import ru.fromchat.api.PublicChatProfileSync
import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.instance.bootstrapSessionOnStartup import ru.fromchat.api.instance.bootstrapSessionOnStartup
@@ -224,6 +225,17 @@ fun App(
} }
val hasToken = ApiClient.token?.isNotEmpty() == true val hasToken = ApiClient.token?.isNotEmpty() == true
if (hasToken) {
runCatching {
bootstrapSessionOnStartup(
hasToken = true,
onLogoutRequired = { sessionLogoutRequired = true },
)
}
PublicChatProfileSync.ensureStarted()
}
startDestination = when { startDestination = when {
hasToken && startAtDmConversationUserId != null -> "chat" hasToken && startAtDmConversationUserId != null -> "chat"
hasToken && startAtPublicChat -> "chats/publicChat" hasToken && startAtPublicChat -> "chats/publicChat"
@@ -239,15 +251,6 @@ fun App(
if (!hasToken) return@LaunchedEffect if (!hasToken) return@LaunchedEffect
launch(Dispatchers.Default) {
runCatching {
bootstrapSessionOnStartup(
hasToken = true,
onLogoutRequired = { sessionLogoutRequired = true },
)
}
}
launch(Dispatchers.Default) { launch(Dispatchers.Default) {
runCatching { ProfileCache.hydrateFromDisk() } runCatching { ProfileCache.hydrateFromDisk() }
} }
@@ -33,6 +33,9 @@ import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.calls.CallUiState import ru.fromchat.api.calls.CallUiState
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName 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_accept
import ru.fromchat.call_decline import ru.fromchat.call_decline
import ru.fromchat.call_dismiss import ru.fromchat.call_dismiss
@@ -104,10 +107,11 @@ fun CallOverlay(modifier: Modifier = Modifier) {
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( DisplayName(
text = title, displayName = title,
style = MaterialTheme.typography.headlineSmall, verificationStatus = cached?.effectiveVerificationStatus()
color = MaterialTheme.colorScheme.onSurface, ?: resolveVerificationStatus(s.fromUserId),
textStyle = MaterialTheme.typography.headlineSmall,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
@@ -313,18 +313,25 @@ private fun ChatImageTileContent(
val initialFull = remember(decryptCacheKey) { LocalDecodedImageCache.peekFull(decryptCacheKey) } val initialFull = remember(decryptCacheKey) { LocalDecodedImageCache.peekFull(decryptCacheKey) }
val hadInstantFull = initialFull != null 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 hasServerThumb = thumbnailBase64?.isNotBlank() == true
val useBlurredThumbPlaceholder = hasServerThumb && !hasDiskCache
var fullBitmap by remember(decryptCacheKey) { var fullBitmap by remember(decryptCacheKey) {
mutableStateOf(initialFull) mutableStateOf(initialFull)
} }
var didRevealFull by remember(decryptCacheKey) { mutableStateOf(hadInstantFull) } var didRevealFull by remember(decryptCacheKey) { mutableStateOf(hadInstantFull || hasDiskCache) }
val thumbBlurAlpha = remember(decryptCacheKey, hasServerThumb, hasLocalSource) { val thumbBlurAlpha = remember(decryptCacheKey, useBlurredThumbPlaceholder, hasLocalSource) {
Animatable( Animatable(
when { when {
hadInstantFull -> 0f hadInstantFull || hasDiskCache -> 0f
hasServerThumb || hasLocalSource -> 1f useBlurredThumbPlaceholder || hasLocalSource -> 1f
else -> 0f else -> 0f
}, },
) )
@@ -374,19 +381,17 @@ private fun ChatImageTileContent(
} }
} }
var cachedPath by remember(decryptCacheKey) { val thumbnailBytes = remember(thumbnailBase64, hasDiskCache) {
mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, cacheClientId)) if (hasDiskCache) null else thumbnailBase64?.let { decodeAttachmentThumbnailBase64(it) }
}
val thumbnailBytes = remember(thumbnailBase64) {
thumbnailBase64?.let { decodeAttachmentThumbnailBase64(it) }
} }
val thumbBitmap by produceState( val thumbBitmap by produceState(
initialValue = LocalDecodedImageCache.peekThumb(decryptCacheKey), initialValue = if (hasDiskCache) null else LocalDecodedImageCache.peekThumb(decryptCacheKey),
decryptCacheKey, decryptCacheKey,
thumbnailBytes, thumbnailBytes,
decodeSize, decodeSize,
hasDiskCache,
) { ) {
if (thumbnailBytes == null) { if (hasDiskCache || thumbnailBytes == null) {
value = null value = null
return@produceState return@produceState
} }
@@ -401,14 +406,22 @@ private fun ChatImageTileContent(
val imageContentScale = ContentScale.Fit val imageContentScale = ContentScale.Fit
val tilePlaceholderColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.42f) 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( LaunchedEffect(
decryptCacheKey, decryptCacheKey,
localUri, localUri,
diskCacheUri,
serverFile?.path, serverFile?.path,
messageId, messageId,
isOutboundPending, isOutboundPending,
cacheClientId, cacheClientId,
loadAttempt, loadAttempt,
decodeSize,
) { ) {
AttachmentMediaLog.tileLoad( AttachmentMediaLog.tileLoad(
"load_start", "load_start",
@@ -416,9 +429,11 @@ private fun ChatImageTileContent(
"msgId" to messageId, "msgId" to messageId,
"pending" to isOutboundPending, "pending" to isOutboundPending,
"localUri" to (localUri?.take(48) ?: "null"), "localUri" to (localUri?.take(48) ?: "null"),
"diskCache" to (diskCacheUri?.take(48) ?: "null"),
"target" to "${decodeSize.widthPx}x${decodeSize.heightPx}", "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 { val localPaths = buildList {
localUri?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) } localUri?.trim()?.takeIf { it.isNotEmpty() }?.let { add(it) }
diskUri?.let { cached -> if (none { it == cached }) add(cached) } diskUri?.let { cached -> if (none { it == cached }) add(cached) }
@@ -444,12 +459,10 @@ private fun ChatImageTileContent(
fullBitmap = loaded fullBitmap = loaded
cachedPath = diskUri ?: localPaths.firstOrNull() cachedPath = diskUri ?: localPaths.firstOrNull()
decryptFinished = true decryptFinished = true
if (thumbBitmap == null) { didRevealFull = true
didRevealFull = true fullRevealAlpha.snapTo(1f)
fullRevealAlpha.snapTo(1f) thumbBlurAlpha.snapTo(0f)
thumbBlurAlpha.snapTo(0f) onFullyLoaded(true)
}
onFullyLoaded(true)
return@LaunchedEffect return@LaunchedEffect
} }
AttachmentMediaLog.tileLoad( AttachmentMediaLog.tileLoad(
@@ -484,11 +497,9 @@ private fun ChatImageTileContent(
) )
fullBitmap = loaded fullBitmap = loaded
decryptFinished = true decryptFinished = true
if (thumbBitmap == null) { didRevealFull = true
didRevealFull = true fullRevealAlpha.snapTo(1f)
fullRevealAlpha.snapTo(1f) thumbBlurAlpha.snapTo(0f)
thumbBlurAlpha.snapTo(0f)
}
onFullyLoaded(true) onFullyLoaded(true)
return@LaunchedEffect return@LaunchedEffect
} }
@@ -542,11 +553,9 @@ private fun ChatImageTileContent(
) )
if (fullBitmap != null) { if (fullBitmap != null) {
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId) AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
if (thumbBitmap == null) { didRevealFull = true
didRevealFull = true fullRevealAlpha.snapTo(1f)
fullRevealAlpha.snapTo(1f) thumbBlurAlpha.snapTo(0f)
thumbBlurAlpha.snapTo(0f)
}
onFullyLoaded(true) onFullyLoaded(true)
} }
} }
@@ -574,15 +583,19 @@ private fun ChatImageTileContent(
} }
} }
LaunchedEffect(fullBitmap, thumbBitmap, hadInstantFull) { LaunchedEffect(fullBitmap, thumbBitmap, hadInstantFull, hasDiskCache) {
when { when {
fullBitmap == null -> { fullBitmap == null -> {
if (hasDiskCache) {
thumbBlurAlpha.snapTo(0f)
return@LaunchedEffect
}
didRevealFull = false didRevealFull = false
fullRevealAlpha.snapTo(0f) fullRevealAlpha.snapTo(0f)
if (thumbBitmap != null) thumbBlurAlpha.snapTo(1f) if (thumbBitmap != null) thumbBlurAlpha.snapTo(1f)
} }
didRevealFull -> return@LaunchedEffect didRevealFull -> return@LaunchedEffect
hadInstantFull -> { hadInstantFull || hasDiskCache -> {
didRevealFull = true didRevealFull = true
fullRevealAlpha.snapTo(1f) fullRevealAlpha.snapTo(1f)
thumbBlurAlpha.snapTo(0f) thumbBlurAlpha.snapTo(0f)
@@ -641,10 +654,10 @@ private fun ChatImageTileContent(
) )
if (fullBitmap == null && hasLocalSource && !showOutboundBlurOverlay) { if (fullBitmap == null && hasLocalSource && !showOutboundBlurOverlay) {
AsyncImage( AsyncImage(
model = localUri, model = displayLocalUri,
contentDescription = null, contentDescription = null,
contentScale = imageContentScale, contentScale = imageContentScale,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
thumbBitmap?.let { thumb -> thumbBitmap?.let { thumb ->
@@ -759,7 +772,7 @@ private fun ChatImageTileContent(
} }
if (showOutboundBlurOverlay && outboundOverlayAlpha.value > 0.01f) { if (showOutboundBlurOverlay && outboundOverlayAlpha.value > 0.01f) {
UploadingImageOverlay( UploadingImageOverlay(
model = localUri, model = localUri ?: displayLocalUri!!,
uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null, uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null,
clipShape = clipShape, clipShape = clipShape,
contentScale = imageContentScale, contentScale = imageContentScale,
@@ -41,8 +41,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity 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.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.resetFocus
import com.pr0gramm3r101.utils.supportClipboardManagerImpl import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
@@ -151,6 +154,8 @@ fun ChatScreen(
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ } val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
val navController = LocalNavController.current val navController = LocalNavController.current
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val profileUserId = panelState.profileUserId val profileUserId = panelState.profileUserId
val hazeState = rememberHazeState( val hazeState = rememberHazeState(
blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty()) blurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
@@ -173,9 +178,8 @@ fun ChatScreen(
} }
val subtitleKey = when { val subtitleKey = when {
!online -> "connecting"
connectionStatus == ConnectionStatus.UPDATING -> "updating" connectionStatus == ConnectionStatus.UPDATING -> "updating"
connectionStatus != ConnectionStatus.CONNECTED -> "connecting" !online || (connectionStatus == ConnectionStatus.CONNECTING && !WebSocketManager.isConnected) -> "connecting"
currentTypingUsers.isNotEmpty() -> "typing" currentTypingUsers.isNotEmpty() -> "typing"
panel.usesPublicGroupSubtitle -> { panel.usesPublicGroupSubtitle -> {
if (panelState.publicGroupMetaLoading || panelState.publicGroupMemberCount == null) { if (panelState.publicGroupMetaLoading || panelState.publicGroupMemberCount == null) {
@@ -716,7 +720,10 @@ fun ChatScreen(
} else { } else {
null null
}, },
onImageClick = { msg, idx -> expandedImage = msg to idx }, onImageClick = { msg, idx ->
resetFocus(keyboardController, focusManager)
expandedImage = msg to idx
},
onImageBounds = { key, rect -> onImageBounds = { key, rect ->
imageThumbBounds[key] = rect imageThumbBounds[key] = rect
}, },
@@ -64,6 +64,8 @@ import ru.fromchat.chat_members_count
import ru.fromchat.ui.chat.utils.TypingUser import ru.fromchat.ui.chat.utils.TypingUser
import ru.fromchat.ui.components.ConnectingEllipsis import ru.fromchat.ui.components.ConnectingEllipsis
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.profile.StatusBadge
import ru.fromchat.ui.profile.resolveVerificationStatus
import com.pr0gramm3r101.utils.scaleOnPress import com.pr0gramm3r101.utils.scaleOnPress
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.tan import kotlin.math.tan
@@ -169,12 +171,26 @@ fun ChatTopBarInner(
.padding(horizontal = 4.dp, vertical = 2.dp), .padding(horizontal = 4.dp, vertical = 2.dp),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
) { ) {
Text( Row(
text = title, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.titleMedium, ) {
maxLines = 1, Text(
overflow = TextOverflow.Ellipsis, 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( AnimatedContent(
targetState = subtitleKey, targetState = subtitleKey,
@@ -67,6 +67,8 @@ import ru.fromchat.ui.chat.components.getReplyMessageGradient
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
import ru.fromchat.ui.chat.utils.imageAttachmentKey import ru.fromchat.ui.chat.utils.imageAttachmentKey
import ru.fromchat.ui.components.Text 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). */ /** True when [Message.content] is only a filename placeholder (no real caption). */
internal fun isFilenameOnlyMessageCaption(message: Message): Boolean { internal fun isFilenameOnlyMessageCaption(message: Message): Boolean {
@@ -121,6 +123,7 @@ fun MessageItem(
val editedSuffix = stringResource(Res.string.message_edited_suffix) val editedSuffix = stringResource(Res.string.message_edited_suffix)
val sendFailedLabel = stringResource(Res.string.message_send_failed) val sendFailedLabel = stringResource(Res.string.message_send_failed)
val displayUsername = messageDisplayUsername(message, currentUserId) val displayUsername = messageDisplayUsername(message, currentUserId)
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
val replyRef = message.reply_to val replyRef = message.reply_to
// No AnimatedVisibility here: visible=true still ran enter transitions for every item on first // No AnimatedVisibility here: visible=true still ran enter transitions for every item on first
@@ -342,23 +345,41 @@ fun MessageItem(
onClick = onUsernameClick onClick = onUsernameClick
) )
) { ) {
Text( Row(
text = displayUsername, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.labelMedium, horizontalArrangement = Arrangement.spacedBy(4.dp),
fontWeight = FontWeight.SemiBold, modifier = usernameInset,
color = MaterialTheme.colorScheme.primary, ) {
modifier = usernameInset Text(
) text = displayUsername,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
StatusBadge(
verificationStatus = senderVerificationStatus,
size = 14.dp,
)
}
} }
} else { } else {
Box(modifier = usernameOutset) { Box(modifier = usernameOutset) {
Text( Row(
text = displayUsername, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.labelMedium, horizontalArrangement = Arrangement.spacedBy(4.dp),
fontWeight = FontWeight.SemiBold, modifier = usernameInset,
color = MaterialTheme.colorScheme.primary, ) {
modifier = usernameInset Text(
) text = displayUsername,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
StatusBadge(
verificationStatus = senderVerificationStatus,
size = 14.dp,
)
}
} }
} }
} }
@@ -9,12 +9,14 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.store.MessageCacheStore import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.messages.conversationIdForGroup 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.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
@@ -40,7 +42,7 @@ class PublicChatPanel(
scope = scope scope = scope
) { ) {
private val typingHandler = PublicChatTypingHandler(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). * Whether replacing the list would change **structure or message body** (content / edited).
@@ -68,11 +70,12 @@ class PublicChatPanel(
get() = true get() = true
init { init {
val cachedProfile = PublicChatProfileCache.profile val instanceId = CacheContext.activeInstanceId.value.trim()
if (cachedProfile != null) { if (instanceId.isNotEmpty()) {
applyPublicChatProfile(cachedProfile) PublicChatProfileCache.hydrateFromDiskImmediate(instanceId)
} else { }
updateState { PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) }
?: updateState {
it.copy( it.copy(
title = "", title = "",
titleAvatar = null, titleAvatar = null,
@@ -80,6 +83,18 @@ class PublicChatPanel(
publicGroupMemberCount = null, 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 { scope.launch {
typingHandler.typingUsers.collect { users -> typingHandler.typingUsers.collect { users ->
@@ -87,17 +102,52 @@ class PublicChatPanel(
updateState { it.copy(typingUsers = users.filter { it.userId != currentUserId }) } updateState { it.copy(typingUsers = users.filter { it.userId != currentUserId }) }
} }
} }
scope.launch(Dispatchers.Default) { scope.launch {
runCatching { ApiClient.getPublicChatProfile() } PublicChatProfileCache.profileState.collect { profile ->
.onSuccess { profile -> if (profile != null) {
PublicChatProfileCache.put(profile)
applyPublicChatProfile(profile) 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() { override suspend fun loadMessages() {
if (messagesLoaded) return hydrateMessagesFromLocalCache()
if (networkHistoryLoaded) return
networkHistoryLoaded = true
messagesLoaded = true val cached = _state.messages
if (cached.isEmpty()) {
// 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 {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
setLoading(true) 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) { val responseResult = withContext(Dispatchers.Default) {
runCatching { ApiClient.getMessages(limit = 50) } runCatching { ApiClient.getMessages(limit = 50) }
} }
@@ -44,9 +44,10 @@ fun PublicChatScreen(
LaunchedEffect(panel, activeInstanceId) { LaunchedEffect(panel, activeInstanceId) {
if (activeInstanceId.isBlank()) return@LaunchedEffect if (activeInstanceId.isBlank()) return@LaunchedEffect
if (panel.getState().messages.isEmpty()) { withContext(Dispatchers.Default) {
panel.loadMessages() panel.hydrateFromLocalCache()
} }
panel.loadMessages()
} }
LaunchedEffect(activeInstanceId, online, connectionStatus) { LaunchedEffect(activeInstanceId, online, connectionStatus) {
@@ -80,6 +80,8 @@ import ru.fromchat.cd_chat_preview_sending
import ru.fromchat.cd_chat_preview_uploading import ru.fromchat.cd_chat_preview_uploading
import ru.fromchat.cd_chat_selected import ru.fromchat.cd_chat_selected
import ru.fromchat.ui.chat.Avatar 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.ExpressiveUploadIndicator
import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.chat.TypingIndicator
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
@@ -113,6 +115,18 @@ private val ChatListCategoryMargin = PaddingValues(
bottom = 20.dp, bottom = 20.dp,
) )
@Composable
internal fun ChatListHeadlineWithBadge(
title: String,
userId: Int,
) {
DisplayName(
displayName = title,
verificationStatus = resolveVerificationStatus(userId),
textStyle = MaterialTheme.typography.bodyLarge,
)
}
@Composable @Composable
internal fun ChatListItemSpacer() { internal fun ChatListItemSpacer() {
Spacer( Spacer(
@@ -165,7 +179,7 @@ internal fun ChatConversationsList(
onRowPositioned: (lazyIndex: Int, offset: Offset, size: IntSize) -> Unit, onRowPositioned: (lazyIndex: Int, offset: Offset, size: IntSize) -> Unit,
) { ) {
val scrollBlocked = contextMenuState.isOverlayActive 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 val groupCount = (if (showPublicChat) 1 else 0) + conversations.size
LazyColumn( LazyColumn(
@@ -394,6 +408,7 @@ internal fun SearchConversationsList(
) { ) {
ListItem( ListItem(
headline = peerTitle, headline = peerTitle,
headlineSlot = { ChatListHeadlineWithBadge(peerTitle, user.id) },
supportingText = username, supportingText = username,
containerColor = Color.Transparent, containerColor = Color.Transparent,
position = position, position = position,
@@ -703,11 +718,7 @@ internal fun PublicChatRowContent(
) )
} }
}, },
trailingContent = { trailingContent = {},
if (publicChatTitle == null) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
},
bodyModifier = Modifier bodyModifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(), .fillMaxHeight(),
@@ -834,6 +845,7 @@ internal fun DmConversationRowContent(
val listSurfaceColor = MaterialTheme.colorScheme.surfaceContainerLow val listSurfaceColor = MaterialTheme.colorScheme.surfaceContainerLow
ListItem( ListItem(
headline = peerTitle, headline = peerTitle,
headlineSlot = { ChatListHeadlineWithBadge(peerTitle, conversation.otherUserId) },
supportingSlot = { supportingSlot = {
if (isTyping) { if (isTyping) {
TypingIndicator(typingUsers = typingUsers) TypingIndicator(typingUsers = typingUsers)
@@ -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.Clear
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -100,6 +101,8 @@ fun ChatsSearchScreen(
val searchBarHint = stringResource(Res.string.search_title) val searchBarHint = stringResource(Res.string.search_title)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) } var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
var remoteUsers by remember { mutableStateOf<List<User>>(emptyList()) } var remoteUsers by remember { mutableStateOf<List<User>>(emptyList()) }
var isSearchInFlight by remember { mutableStateOf(false) }
var lastCompletedSearchQuery by remember { mutableStateOf<String?>(null) }
val statusSubscriptionScope = rememberCoroutineScope() val statusSubscriptionScope = rememberCoroutineScope()
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) } var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@@ -131,27 +134,39 @@ fun ChatsSearchScreen(
} }
val searchUiState = when { val searchUiState = when {
searchText.isBlank() -> SearchScreenState.EmptyQuery searchText.isBlank() || searchQuery.length < 2 -> SearchScreenState.EmptyQuery
searchResults.isEmpty() -> SearchScreenState.NotFound searchResults.isNotEmpty() -> SearchScreenState.Results
else -> SearchScreenState.Results isSearchInFlight || lastCompletedSearchQuery != searchQuery -> SearchScreenState.Loading
else -> SearchScreenState.NotFound
} }
LaunchedEffect(searchQuery) { LaunchedEffect(searchQuery) {
if (searchQuery.length < 2) { if (searchQuery.length < 2) {
remoteUsers = emptyList() remoteUsers = emptyList()
isSearchInFlight = false
lastCompletedSearchQuery = null
return@LaunchedEffect return@LaunchedEffect
} }
isSearchInFlight = true
delay(300) delay(300)
val querySnapshot = searchQuery val querySnapshot = searchQuery
runCatching { if (querySnapshot != searchText.trim().lowercase().trimStart('@')) {
ApiClient.searchUsers(querySnapshot) return@LaunchedEffect
}.onSuccess { users -> }
try {
val users = ApiClient.searchUsers(querySnapshot)
if (querySnapshot == searchText.trim().lowercase().trimStart('@')) { if (querySnapshot == searchText.trim().lowercase().trimStart('@')) {
users.forEach { ProfileCache.mergeFromDmUser(it) } users.forEach { ProfileCache.mergeFromDmUser(it) }
remoteUsers = users remoteUsers = users
lastCompletedSearchQuery = querySnapshot
} }
}.onFailure { } catch (_: Throwable) {
if (querySnapshot == searchText.trim().lowercase().trimStart('@')) { if (querySnapshot == searchText.trim().lowercase().trimStart('@')) {
remoteUsers = emptyList() 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 -> { SearchScreenState.NotFound -> {
SearchScreenInfo( SearchScreenInfo(
title = stringResource(Res.string.search_not_found), title = stringResource(Res.string.search_not_found),
subtitle = stringResource(Res.string.search_not_found_message), subtitle = stringResource(Res.string.search_not_found_message),
showSearchIcon = false showSearchIcon = false
) )
} }
@@ -328,8 +352,9 @@ private sealed interface SearchResult {
private enum class SearchScreenState { private enum class SearchScreenState {
EmptyQuery, EmptyQuery,
Loading,
NotFound, NotFound,
Results Results,
} }
@Composable @Composable
@@ -84,6 +84,7 @@ import ru.fromchat.action_delete
import ru.fromchat.action_mark_read import ru.fromchat.action_mark_read
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.CachedConversation import ru.fromchat.api.local.db.store.CachedConversation
import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStateStore
@@ -285,21 +286,49 @@ fun ChatsTab(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true) val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) } val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
var publicChatPreviewState by remember { mutableStateOf<ChatListPreviewState?>(null) } var dmConversations by remember(activeInstanceId) {
var publicChatProfile by remember { mutableStateOf(PublicChatProfileCache.profile) } val cached = if (activeInstanceId.isBlank()) {
val searchBarHint = stringResource(Res.string.search_title) emptyList()
val tabListState = rememberLazyListState() } else {
val statusMap by UserStatusStore.status.collectAsState() runCatching { MessageRepository.loadCachedDmConversationsImmediate() }
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) } .getOrDefault(emptyList())
val statusSubscriptionScope = rememberCoroutineScope() }
val suspensionState by ApiClient.suspensionState.collectAsState() cached.forEach { ProfileCache.mergeFromCachedConversation(it) }
mutableStateOf(ChatListReorderController.applyOrdered(cached))
}
val imageEmoji = stringResource(Res.string.chat_preview_image_emoji) val imageEmoji = stringResource(Res.string.chat_preview_image_emoji)
val previewStrings = ChatListPreviewStrings( val previewStrings = ChatListPreviewStrings(
imageEmoji = imageEmoji, imageEmoji = imageEmoji,
imageOnly = stringResource(Res.string.chat_preview_image, imageEmoji), imageOnly = stringResource(Res.string.chat_preview_image, imageEmoji),
attachmentOnly = stringResource(Res.string.chat_preview_attachment), attachmentOnly = stringResource(Res.string.chat_preview_attachment),
) )
var publicChatPreviewState by remember(activeInstanceId, previewStrings.imageOnly, previewStrings.attachmentOnly) {
if (activeInstanceId.isBlank()) {
mutableStateOf<ChatListPreviewState?>(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<Set<Int>>(emptySet()) }
val statusSubscriptionScope = rememberCoroutineScope()
val suspensionState by ApiClient.suspensionState.collectAsState()
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) { LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) {
@@ -360,7 +389,9 @@ fun ChatsTab(
fun refreshDmList() { fun refreshDmList() {
scope.launch { scope.launch {
runCatching { runCatching {
dmConversations = MessageRepository.loadCachedDmConversations() dmConversations = ChatListReorderController.applyOrdered(
MessageRepository.loadCachedDmConversations(),
)
} }
} }
} }
@@ -459,14 +490,13 @@ fun ChatsTab(
} }
val serverConfig by ServerConfig.serverConfig.collectAsState() val serverConfig by ServerConfig.serverConfig.collectAsState()
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
LaunchedEffect(activeInstanceId, previewStrings.imageOnly, previewStrings.attachmentOnly) { LaunchedEffect(activeInstanceId, previewStrings.imageOnly, previewStrings.attachmentOnly) {
if (activeInstanceId.isBlank()) { if (activeInstanceId.isBlank()) {
publicChatPreviewState = null publicChatPreviewState = null
return@LaunchedEffect return@LaunchedEffect
} }
publicChatProfile = PublicChatProfileCache.profile runCatching { PublicChatProfileCache.hydrateFromDisk() }
runCatching { runCatching {
publicChatPreviewState = MessageRepository.loadRecentPublicChatPreviewState(previewStrings) publicChatPreviewState = MessageRepository.loadRecentPublicChatPreviewState(previewStrings)
} }
@@ -490,36 +520,24 @@ fun ChatsTab(
LaunchedEffect(serverConfig, activeInstanceId) { LaunchedEffect(serverConfig, activeInstanceId) {
if (activeInstanceId.isBlank()) return@LaunchedEffect if (activeInstanceId.isBlank()) return@LaunchedEffect
runCatching {
MessageRepository.loadCachedDmConversations()
}.onSuccess { conversations ->
conversations.forEach { ProfileCache.mergeFromCachedConversation(it) }
dmConversations = conversations
}
runCatching { runCatching {
ApiClient.getDmConversations() ApiClient.getDmConversations()
}.onSuccess { conversations -> }.onSuccess { conversations ->
runCatching { runCatching {
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
MessageRepository.replaceDmConversations(conversations, previewStrings) MessageRepository.replaceDmConversations(conversations, previewStrings)
dmConversations = MessageRepository.loadCachedDmConversations() dmConversations = ChatListReorderController.applyOrdered(
MessageRepository.loadCachedDmConversations(),
)
} }
} }
publicChatProfile = PublicChatProfileCache.profile runCatching { PublicChatProfileCache.hydrateFromDisk() }
runCatching { ApiClient.getPublicChatProfile() }
.onSuccess { profile ->
PublicChatProfileCache.put(profile)
publicChatProfile = profile
}
} }
val titleKey = when { val titleKey = when {
!online -> "connecting"
connectionStatus == ConnectionStatus.UPDATING -> "updating" connectionStatus == ConnectionStatus.UPDATING -> "updating"
connectionStatus == ConnectionStatus.CONNECTING -> "connecting" !online || (connectionStatus == ConnectionStatus.CONNECTING && !WebSocketManager.isConnected) -> "connecting"
else -> "fromchat" else -> "fromchat"
} }
@@ -529,7 +547,7 @@ fun ChatsTab(
val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount) val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount)
val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message) val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message)
val suspendDefaultReason = stringResource(Res.string.suspended_default_reason) 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 publicChatLink = publicChatProfile?.let { "https://fromchat.ru/chats/${it.id}" }
val deleteConfirmTitle = stringResource(Res.string.chat_delete_confirm_title) val deleteConfirmTitle = stringResource(Res.string.chat_delete_confirm_title)
val deleteConfirmBody = stringResource(Res.string.chat_delete_confirm_body) val deleteConfirmBody = stringResource(Res.string.chat_delete_confirm_body)
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding 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.LaptopMac
import androidx.compose.material.icons.rounded.PhoneAndroid import androidx.compose.material.icons.rounded.PhoneAndroid
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet 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.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState 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.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.schema.user.devices.DeviceSessionInfo import ru.fromchat.api.schema.user.devices.DeviceSessionInfo
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.cancel import ru.fromchat.cancel
@@ -102,6 +109,15 @@ import ru.fromchat.unknown
private const val DEVICE_SESSIONS_POLL_INTERVAL_MS = 15_000L 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<DeviceSessionInfo>): List<DeviceSessionInfo> { private fun enrichDevicesList(list: List<DeviceSessionInfo>): List<DeviceSessionInfo> {
val currentIndex = list.indexOfFirst { it.current } val currentIndex = list.indexOfFirst { it.current }
if (currentIndex < 0) return list if (currentIndex < 0) return list
@@ -368,25 +384,40 @@ fun DevicesScreen(onBack: () -> Unit) {
} }
suspend fun fetchDevices() { 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() } runCatching { ApiClient.listDevices() }
.onSuccess { list -> .onSuccess { list ->
Settings.writeDeviceSessionsCache(list) Settings.writeDeviceSessionsCache(list)
devices = enrichDevicesList(list) devices = enrichDevicesList(list)
refreshing = false
} }
.onFailure { .onFailure { error ->
snackbarHostState.showSnackbar(errUnexpected) if (isUnreachableDevicesFetchError(error)) {
refreshing = devices.isEmpty()
} else {
refreshing = false
snackbarHostState.showSnackbar(errUnexpected)
}
} }
refreshing = false
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
fetchDevices()
while (isActive) { while (isActive) {
delay(DEVICE_SESSIONS_POLL_INTERVAL_MS) if (!WebSocketManager.isConnected) {
if (devices.isEmpty()) refreshing = true
delay(1_000)
continue
}
fetchDevices() fetchDevices()
delay(DEVICE_SESSIONS_POLL_INTERVAL_MS)
} }
} }
@@ -434,6 +465,18 @@ fun DevicesScreen(onBack: () -> Unit) {
current to mutable 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( LazyColumn(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .hazeSource(hazeState)
@@ -538,7 +581,7 @@ fun DevicesScreen(onBack: () -> Unit) {
.padding(vertical = 16.dp), .padding(vertical = 16.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CircularProgressIndicator(modifier = Modifier.size(24.dp)) LoadingIndicator(modifier = Modifier.padding(24.dp))
} }
} }
} }
@@ -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,
)
}
}
}
@@ -43,7 +43,6 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
import ru.fromchat.back 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.ContentCopy
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.TextButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
@@ -115,6 +112,8 @@ import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -122,8 +121,9 @@ import ru.fromchat.Logger
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.action_copy import ru.fromchat.action_copy
import ru.fromchat.action_edit import ru.fromchat.action_edit
import ru.fromchat.action_retry_send
import ru.fromchat.api.ApiClient 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.calls.CallStore
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.UserStatus 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_not_found
import ru.fromchat.profile_verified_support import ru.fromchat.profile_verified_support
import ru.fromchat.profile_verify_prompt_support import ru.fromchat.profile_verify_prompt_support
import ru.fromchat.ui.profile.effectiveVerificationStatus
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.chat.TypingIndicator
@@ -286,8 +287,6 @@ fun ProfileScreen(
mutableStateOf(hasDisplayableProfile(state.profile, initialDisplayName, ownUserId)) mutableStateOf(hasDisplayableProfile(state.profile, initialDisplayName, ownUserId))
} }
var reloadAttempt by remember(lookupKey) { mutableIntStateOf(0) }
val latestUi by rememberUpdatedState(state) val latestUi by rememberUpdatedState(state)
val backStackEntry = navController.currentBackStackEntry val backStackEntry = navController.currentBackStackEntry
@@ -309,109 +308,116 @@ fun ProfileScreen(
} }
} }
LaunchedEffect(lookupMode, lookupIdentifier, reloadAttempt) { LaunchedEffect(lookupMode, lookupIdentifier) {
if (reloadAttempt > 0) { ProfileCache.hydrateFromDisk()
state = latestUi.copy(isLoading = true, error = null) resolveCachedProfile(targetUserId, targetUsername, ownUserId)?.let { cached ->
if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) {
state = latestUi.copy(profile = cached, isLoading = false, error = null)
}
} }
Logger.d( while (isActive) {
"ProfileScreen", if (!WebSocketManager.isConnected) {
"load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId" delay(1000)
) continue
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()) { Logger.d(
cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) } "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( Logger.d(
"ProfileScreen", "ProfileScreen",
"load success but blank identity for id=${profile.id}, dropping " + "load failure fallback lookup: fallbackFound=${fallback != null}"
"as unusable preview"
) )
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( state = latestUi.copy(
profile = null, error = if (!hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId)) {
isLoading = false, if (resolvedErrorMessage == profileLoadFailed) {
error = ProfileLoadError.Generic ProfileLoadError.Generic
) } else {
ProfileLoadError.Message(resolvedErrorMessage)
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
} else { } else {
ProfileLoadError.Message(resolvedErrorMessage) null
} },
} else { profile = resolvedProfile,
null isLoading = false
}, )
profile = resolvedProfile, }
isLoading = false
) delay(1000)
} }
} }
@@ -427,7 +433,6 @@ fun ProfileScreen(
val headlineVerification = stringResource(Res.string.profile_headline_verification) val headlineVerification = stringResource(Res.string.profile_headline_verification)
val verifiedSupport = stringResource(Res.string.profile_verified_support) val verifiedSupport = stringResource(Res.string.profile_verified_support)
val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_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) val profile = state.profile ?: resolveCachedProfile(targetUserId, targetUsername, ownUserId)
if (hasDisplayableProfile(profile, initialDisplayName, ownUserId)) { if (hasDisplayableProfile(profile, initialDisplayName, ownUserId)) {
@@ -435,17 +440,17 @@ fun ProfileScreen(
} }
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
val lastSeenFormatStrings = rememberLastSeenFormatStrings() val lastSeenFormatStrings = rememberLastSeenFormatStrings()
val loadError = state.error
val hasDisplayable = hasDisplayableProfile(profile, initialDisplayName, ownUserId) 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 currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id
val displayName = val displayName =
profile?.visibleDisplayName(currentProfileUserId) profile?.visibleDisplayName(currentProfileUserId)
?: initialDisplayName?.takeIf { it.isNotBlank() } ?: initialDisplayName?.takeIf { it.isNotBlank() }
?: "?" ?: profile?.username?.takeIf { it.isNotBlank() }
?: ""
val usernameForLinks = profile?.visibleUsername(currentProfileUserId) val usernameForLinks = profile?.visibleUsername(currentProfileUserId)
val resolvedProfile = profile?.takeIf { !showSkeleton }
val profileLink = resolvedProfile?.let { val profileLink = resolvedProfile?.let {
usernameForLinks?.let { name -> "https://fromchat.ru/@$name" } usernameForLinks?.let { name -> "https://fromchat.ru/@$name" }
?: "https://fromchat.ru/?u=${it.id}" ?: "https://fromchat.ru/?u=${it.id}"
@@ -558,7 +563,7 @@ fun ProfileScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
when { when {
showSkeleton && !hideAvatar -> { showAvatarSkeleton && !hideAvatar -> {
item { item {
ShimmerBox( ShimmerBox(
modifier = Modifier modifier = Modifier
@@ -591,9 +596,9 @@ fun ProfileScreen(
!hideAvatar -> { !hideAvatar -> {
item { item {
Avatar( Avatar(
profilePictureUrl = profile?.profilePicture, profilePictureUrl = profile?.profilePicture,
displayName = displayName, displayName = displayName,
modifier = Modifier modifier = Modifier
.padding(top = profileAvatarTop) .padding(top = profileAvatarTop)
.size(104.dp) .size(104.dp)
@@ -630,24 +635,18 @@ fun ProfileScreen(
item { item {
AnimatedContent( AnimatedContent(
targetState = showSkeleton, modifier = Modifier.fillMaxWidth(),
targetState = showBodySkeleton,
transitionSpec = { transitionSpec = {
fadeIn() togetherWith fadeOut() fadeIn() togetherWith fadeOut()
}, },
label = "profile_body", label = "profile_body",
) { skeleton -> ) { skeleton ->
if (skeleton) { if (skeleton) {
ProfileSkeletonBody( ProfileSkeletonBody()
onRetry = if (loadError != null) { } else {
{ reloadAttempt++ }
} else {
null
},
retryLabel = labelRetry,
)
} else if (resolvedProfile != null) {
ProfileLoadedBody( ProfileLoadedBody(
resolvedProfile = resolvedProfile, resolvedProfile = resolvedProfile!!,
displayName = displayName, displayName = displayName,
isOwnProfile = isOwnProfile, isOwnProfile = isOwnProfile,
typingUsers = typingUsers, typingUsers = typingUsers,
@@ -740,31 +739,34 @@ fun PublicChatProfileScreen(
profile = PublicChatProfileCache.profile, profile = PublicChatProfileCache.profile,
isLoading = PublicChatProfileCache.profile == null, isLoading = PublicChatProfileCache.profile == null,
error = null, error = null,
) ),
) )
} }
val hasShownContent = remember {
mutableStateOf(PublicChatProfileCache.profile != null)
}
val latestUi by rememberUpdatedState(state) val latestUi by rememberUpdatedState(state)
LaunchedEffect(Unit) { 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 { try {
val profile = ApiClient.getPublicChatProfile() val profile = PublicChatProfileSync.refreshFromNetwork()
PublicChatProfileCache.put(profile)
state = latestUi.copy(profile = profile, isLoading = false, error = null) state = latestUi.copy(profile = profile, isLoading = false, error = null)
} catch (err: Exception) { } catch (err: Exception) {
val cached = PublicChatProfileCache.profile val cached = PublicChatProfileCache.profile ?: latestUi.profile
val resolvedErrorMessage = when { val resolvedErrorMessage = when {
err is ClientRequestException && err.response.status.value == 404 -> profileNotFound err is ClientRequestException && err.response.status.value == 404 -> profileNotFound
else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed
} }
state = latestUi.copy( state = latestUi.copy(
profile = latestUi.profile ?: cached, profile = cached,
isLoading = false, isLoading = false,
error = if (latestUi.profile == null && cached == null) { error = if (cached?.title.isNullOrBlank() && initialDisplayName.isNullOrBlank()) {
if (resolvedErrorMessage == profileLoadFailed) { if (resolvedErrorMessage == profileLoadFailed) {
PublicChatProfileLoadError.Generic PublicChatProfileLoadError.Generic
} else { } else {
@@ -780,15 +782,10 @@ fun PublicChatProfileScreen(
val hazeState = rememberHazeState() val hazeState = rememberHazeState()
val profile = state.profile val profile = state.profile
if (profile != null) { val resolvedProfile = profile?.takeIf { !it.title.isNullOrBlank() }
hasShownContent.value = true val displayName = resolvedProfile?.title?.takeIf { it.isNotBlank() }
}
val loadError = state.error
val showLoadingSpinner = state.isLoading && !hasShownContent.value
val displayName = profile?.title?.takeIf { it.isNotBlank() }
?: initialDisplayName?.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 profileLink = resolvedProfile?.let { "https://fromchat.ru/chats/${it.id}" }
val profileActions = resolvedProfile?.let { val profileActions = resolvedProfile?.let {
listOf( listOf(
@@ -843,127 +840,57 @@ fun PublicChatProfileScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
when { when {
showLoadingSpinner -> { useSharedAvatar && displayName.isNotBlank() -> {
item { Spacer(Modifier.height(profileAvatarTop + 104.dp + 12.dp)) }
item { item {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) with(sharedTransitionScope!!) {
}
}
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 {
Avatar( Avatar(
profilePictureUrl = null, profilePictureUrl = null,
displayName = displayName, displayName = displayName,
modifier = Modifier modifier = Modifier
.padding(top = profileAvatarTop) .padding(top = profileAvatarTop)
.sharedElement(
rememberSharedContentState(key = sharedAvatarKey!!),
animatedVisibilityScope = animatedVisibilityScope!!,
)
.size(104.dp), .size(104.dp),
) )
} }
} }
item { Spacer(Modifier.height(12.dp)) } item { Spacer(Modifier.height(12.dp)) }
}
if (resolvedProfile != null) { resolvedProfile != null -> {
item { item {
Text( Avatar(
text = resolvedProfile.title, profilePictureUrl = null,
style = MaterialTheme.typography.titleLarge, displayName = displayName,
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier
) .padding(top = profileAvatarTop)
} .size(104.dp),
)
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())
}
}
},
)
}
}
}
} }
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<ProfileAction>,
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) expect fun showProfileLoadErrorMessage(message: String)
private sealed interface PublicChatProfileLoadError { private sealed interface PublicChatProfileLoadError {
@@ -1032,13 +1041,12 @@ private fun targetWeightForPress(
@Composable @Composable
private fun ProfileSkeletonBody( private fun ProfileSkeletonBody(
onRetry: (() -> Unit)?,
retryLabel: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val barShape = RoundedCornerShape(8.dp) val barShape = RoundedCornerShape(8.dp)
val pillShape = MaterialTheme.shapes.extraLarge val pillShape = MaterialTheme.shapes.extraLarge
val listBarShape = RoundedCornerShape(4.dp) val listBarShape = RoundedCornerShape(4.dp)
val skeletonRowCount = 3
Column( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@@ -1050,7 +1058,7 @@ private fun ProfileSkeletonBody(
.height(28.dp), .height(28.dp),
shape = barShape, shape = barShape,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(4.dp))
ShimmerBox( ShimmerBox(
modifier = Modifier modifier = Modifier
.width(112.dp) .width(112.dp)
@@ -1082,68 +1090,40 @@ private fun ProfileSkeletonBody(
), ),
roundedCorners = false, roundedCorners = false,
) { ) {
ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = true) repeat(skeletonRowCount) { index ->
ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = true) ListItem(
ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = false) headline = "",
} headlineSlot = {
if (onRetry != null) { ShimmerBox(
Spacer(Modifier.height(8.dp)) modifier = Modifier
TextButton(onClick = onRetry) { .fillMaxWidth(0.38f)
Text( .height(14.dp),
text = retryLabel, shape = listBarShape,
style = MaterialTheme.typography.labelLarge, )
color = MaterialTheme.colorScheme.onSurfaceVariant, },
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 @Composable
private fun ProfileLoadedBody( private fun ProfileLoadedBody(
resolvedProfile: UserProfile, resolvedProfile: UserProfile,
@@ -1209,8 +1189,7 @@ private fun ProfileLoadedBody(
) )
} }
StatusBadge( StatusBadge(
verified = resolvedProfile.verified, verificationStatus = resolvedProfile.effectiveVerificationStatus(),
userId = resolvedProfile.id,
) )
} }
@@ -1400,9 +1379,19 @@ private fun ProfileLoadedBody(
ApiClient.verifyUser(resolvedProfile.id) ApiClient.verifyUser(resolvedProfile.id)
}.getOrNull() }.getOrNull()
} }
result?.verified?.let { newVerified -> result?.let { response ->
onProfileUpdated( 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
}
},
),
) )
} }
} }
@@ -7,59 +7,37 @@ import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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 org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res 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_similar_verified
import ru.fromchat.cd_verified_account import ru.fromchat.cd_verified_account
@Composable @Composable
fun StatusBadge( fun StatusBadge(
verified: Boolean?, verificationStatus: VerificationStatus?,
userId: Int?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Dp = 20.dp size: Dp = 20.dp,
) { ) {
var isSimilarToVerified by remember(userId, verified) { when (verificationStatus) {
mutableStateOf(false) VerificationStatus.Verified -> Icon(
}
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(
imageVector = Icons.Filled.Verified, imageVector = Icons.Filled.Verified,
contentDescription = stringResource(Res.string.cd_verified_account), contentDescription = stringResource(Res.string.cd_verified_account),
modifier = modifier.size(size), modifier = modifier.size(size),
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary,
) )
isSimilarToVerified -> Icon( VerificationStatus.Warning -> Icon(
imageVector = Icons.Filled.Warning, imageVector = Icons.Filled.Warning,
contentDescription = stringResource(Res.string.cd_similar_verified), contentDescription = stringResource(Res.string.cd_similar_verified),
modifier = modifier.size(size), modifier = modifier.size(size),
tint = Color(0xFFFFA000) tint = Color(0xFFFFA000),
) )
VerificationStatus.None, null -> Unit
} }
} }
@@ -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
}
@@ -77,6 +77,11 @@ CREATE TABLE profile_cache (
PRIMARY KEY (instanceId, userId) PRIMARY KEY (instanceId, userId)
); );
CREATE TABLE public_chat_profile (
instanceId TEXT NOT NULL PRIMARY KEY,
json TEXT NOT NULL
);
-- server_binding -- server_binding
selectActiveInstanceIdForConfig: selectActiveInstanceIdForConfig:
SELECT activeInstanceId FROM server_binding WHERE configKey = ?; SELECT activeInstanceId FROM server_binding WHERE configKey = ?;
@@ -231,12 +236,16 @@ DELETE FROM attachment WHERE instanceId = ?;
deleteAllProfilesForInstance: deleteAllProfilesForInstance:
DELETE FROM profile_cache WHERE instanceId = ?; DELETE FROM profile_cache WHERE instanceId = ?;
deletePublicChatProfileForInstance:
DELETE FROM public_chat_profile WHERE instanceId = ?;
purgeAllCache: purgeAllCache:
DELETE FROM message; DELETE FROM message;
DELETE FROM conversation; DELETE FROM conversation;
DELETE FROM outbox; DELETE FROM outbox;
DELETE FROM attachment; DELETE FROM attachment;
DELETE FROM profile_cache; DELETE FROM profile_cache;
DELETE FROM public_chat_profile;
DELETE FROM server_binding; DELETE FROM server_binding;
DELETE FROM instance_registry; DELETE FROM instance_registry;
@@ -360,3 +369,11 @@ DELETE FROM profile_cache WHERE instanceId = ? AND userId = ?;
deleteAllProfileCache: deleteAllProfileCache:
DELETE FROM profile_cache; 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 = ?;
+4
View File
@@ -29,5 +29,9 @@ android.uniquePackageNames=false
android.dependency.useConstraints=true android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false android.r8.strictFullModeForKeepRules=false
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=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. # Configuration cache can surface noisy IDE/sync issues on some setups; turn on when stable.
org.gradle.configuration-cache=false org.gradle.configuration-cache=false
-1
View File
@@ -40,7 +40,6 @@ kotlin {
implementation(libs.navigation.compose) implementation(libs.navigation.compose)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.websockets)
@@ -542,6 +542,7 @@ fun ListItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
bodyModifier: Modifier = Modifier, bodyModifier: Modifier = Modifier,
headline: String, headline: String,
headlineSlot: (@Composable () -> Unit)? = null,
supportingText: String? = null, supportingText: String? = null,
supportingSlot: (@Composable () -> Unit)? = null, supportingSlot: (@Composable () -> Unit)? = null,
leadingContent: (@Composable () -> Unit)? = null, leadingContent: (@Composable () -> Unit)? = null,
@@ -678,7 +679,7 @@ fun ListItem(
} }
ListItem( ListItem(
headlineContent = { Text(headline) }, headlineContent = headlineSlot ?: { Text(headline) },
supportingContent = { supportingContent = {
when { when {
supportingSlot != null -> supportingSlot() supportingSlot != null -> supportingSlot()