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