mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Fix bugs from test #2
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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)
|
||||
|
||||
Vendored
+36
-18
@@ -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.**
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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 {
|
||||
logW("WebSocket disconnected. session set to null, connecting set to false")
|
||||
session = null
|
||||
connecting = false
|
||||
ConnectionStateStore.onConnecting()
|
||||
val isCurrentJob = connectionJob === currentCoroutineContext()[Job]
|
||||
if (!isCurrentJob) {
|
||||
logD("Stale connection job finished; skipping session cleanup")
|
||||
} else {
|
||||
logW("WebSocket disconnected. session set to null, connecting set to false")
|
||||
session = null
|
||||
connecting = false
|
||||
ConnectionStateStore.onConnecting()
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
awaitForeground()
|
||||
logD("Reconnecting in ${reconnectDelayMs}ms...")
|
||||
delayWhileForeground(reconnectDelayMs)
|
||||
reconnectDelayMs =
|
||||
(reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_DELAY_MS)
|
||||
logD("Reconnecting in ${RECONNECT_DELAY_MS}ms...")
|
||||
delayWhileForeground(RECONNECT_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,8 +376,38 @@ object WebSocketManager {
|
||||
return
|
||||
}
|
||||
lastOnNetworkAvailableWallMs = now
|
||||
if (session != null) {
|
||||
logD("onNetworkAvailable: already connected, flushing outbox")
|
||||
OutgoingMessageCoordinator.onTransportReady()
|
||||
return
|
||||
}
|
||||
logD("onNetworkAvailable: reconnect and flush outbox")
|
||||
connect(forceRestart = true)
|
||||
OutgoingMessageCoordinator.onTransportReady()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an authenticated HTTP request succeeds while the WebSocket is down.
|
||||
* Restarts the reconnect loop immediately instead of waiting out exponential backoff.
|
||||
*/
|
||||
fun onServerLikelyReachable() {
|
||||
if (!AppForeground.isInForeground.value) return
|
||||
if (session != null) return
|
||||
if (ApiClient.token.isNullOrEmpty()) return
|
||||
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
if (now - lastServerReachableNotifyWallMs < SERVER_REACHABLE_DEBOUNCE_MS) {
|
||||
return
|
||||
}
|
||||
lastServerReachableNotifyWallMs = now
|
||||
|
||||
val job = connectionJob
|
||||
if (job != null && job.isActive) {
|
||||
logD("onServerLikelyReachable: HTTP ok while WS down; restarting reconnect")
|
||||
connect(forceRestart = true)
|
||||
} else {
|
||||
logD("onServerLikelyReachable: HTTP ok while WS down; starting reconnect")
|
||||
connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
+11
@@ -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. */
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
+1
@@ -99,6 +99,7 @@ object InstanceRegistryStore {
|
||||
db.messageDatabaseQueries.deleteAllOutboxForInstance(id)
|
||||
db.messageDatabaseQueries.deleteAllAttachmentsForInstance(id)
|
||||
db.messageDatabaseQueries.deleteAllProfilesForInstance(id)
|
||||
db.messageDatabaseQueries.deletePublicChatProfileForInstance(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+66
-21
@@ -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,31 +592,39 @@ 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()
|
||||
val previewStrings = listPreviewStrings
|
||||
val currentUserId = ApiClient.user?.id
|
||||
db.messageDatabaseQueries
|
||||
.selectActiveDmConversationsForInstance(iid)
|
||||
.executeAsList()
|
||||
.map { row: Conversation ->
|
||||
val previewState = previewStrings?.let { strings ->
|
||||
previewStateForRecentMessage(iid, row.id, strings, currentUserId)
|
||||
}
|
||||
CachedConversation(
|
||||
id = row.id,
|
||||
otherUserId = row.otherUserId?.toInt() ?: 0,
|
||||
displayName = row.displayName ?: "",
|
||||
lastMessagePreview = previewState?.text ?: row.lastMessagePreview,
|
||||
lastMessagePendingIndicator = previewState?.pendingIndicator
|
||||
?: ChatListPreviewPendingIndicator.None,
|
||||
lastMessageUploadProgress = previewState?.uploadProgress,
|
||||
unreadCount = row.unreadCount.toInt(),
|
||||
)
|
||||
}
|
||||
loadCachedDmConversationsRows(instanceId())
|
||||
}
|
||||
|
||||
private fun loadCachedDmConversationsRows(instanceId: String): List<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)
|
||||
fun observeActiveDmConversations(instanceId: String): Flow<List<CachedConversation>> {
|
||||
val conversationsFlow = db.messageDatabaseQueries
|
||||
@@ -959,6 +1003,7 @@ object MessageCacheStore {
|
||||
username = usernameResolved,
|
||||
profile_picture = pictureResolved,
|
||||
verified = profile?.verified,
|
||||
verificationStatus = profile?.verificationStatus,
|
||||
reply_to = null,
|
||||
client_message_id = clientMessageId,
|
||||
reactions = null,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+74
-6
@@ -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()
|
||||
}
|
||||
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
-9
@@ -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,
|
||||
|
||||
+23
@@ -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
|
||||
}
|
||||
+3
-1
@@ -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,12 +459,10 @@ private fun ChatImageTileContent(
|
||||
fullBitmap = loaded
|
||||
cachedPath = diskUri ?: localPaths.firstOrNull()
|
||||
decryptFinished = true
|
||||
if (thumbBitmap == null) {
|
||||
didRevealFull = true
|
||||
fullRevealAlpha.snapTo(1f)
|
||||
thumbBlurAlpha.snapTo(0f)
|
||||
}
|
||||
onFullyLoaded(true)
|
||||
didRevealFull = true
|
||||
fullRevealAlpha.snapTo(1f)
|
||||
thumbBlurAlpha.snapTo(0f)
|
||||
onFullyLoaded(true)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
AttachmentMediaLog.tileLoad(
|
||||
@@ -484,11 +497,9 @@ private fun ChatImageTileContent(
|
||||
)
|
||||
fullBitmap = loaded
|
||||
decryptFinished = true
|
||||
if (thumbBitmap == null) {
|
||||
didRevealFull = true
|
||||
fullRevealAlpha.snapTo(1f)
|
||||
thumbBlurAlpha.snapTo(0f)
|
||||
}
|
||||
didRevealFull = true
|
||||
fullRevealAlpha.snapTo(1f)
|
||||
thumbBlurAlpha.snapTo(0f)
|
||||
onFullyLoaded(true)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
@@ -542,11 +553,9 @@ private fun ChatImageTileContent(
|
||||
)
|
||||
if (fullBitmap != null) {
|
||||
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
|
||||
if (thumbBitmap == null) {
|
||||
didRevealFull = true
|
||||
fullRevealAlpha.snapTo(1f)
|
||||
thumbBlurAlpha.snapTo(0f)
|
||||
}
|
||||
didRevealFull = true
|
||||
fullRevealAlpha.snapTo(1f)
|
||||
thumbBlurAlpha.snapTo(0f)
|
||||
onFullyLoaded(true)
|
||||
}
|
||||
}
|
||||
@@ -574,15 +583,19 @@ private fun ChatImageTileContent(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(fullBitmap, thumbBitmap, hadInstantFull) {
|
||||
LaunchedEffect(fullBitmap, thumbBitmap, hadInstantFull, hasDiskCache) {
|
||||
when {
|
||||
fullBitmap == null -> {
|
||||
if (hasDiskCache) {
|
||||
thumbBlurAlpha.snapTo(0f)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
didRevealFull = false
|
||||
fullRevealAlpha.snapTo(0f)
|
||||
if (thumbBitmap != null) thumbBlurAlpha.snapTo(1f)
|
||||
}
|
||||
didRevealFull -> return@LaunchedEffect
|
||||
hadInstantFull -> {
|
||||
hadInstantFull || hasDiskCache -> {
|
||||
didRevealFull = true
|
||||
fullRevealAlpha.snapTo(1f)
|
||||
thumbBlurAlpha.snapTo(0f)
|
||||
@@ -641,10 +654,10 @@ private fun ChatImageTileContent(
|
||||
)
|
||||
if (fullBitmap == null && hasLocalSource && !showOutboundBlurOverlay) {
|
||||
AsyncImage(
|
||||
model = localUri,
|
||||
contentDescription = null,
|
||||
model = displayLocalUri,
|
||||
contentDescription = null,
|
||||
contentScale = imageContentScale,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
thumbBitmap?.let { thumb ->
|
||||
@@ -759,7 +772,7 @@ private fun ChatImageTileContent(
|
||||
}
|
||||
if (showOutboundBlurOverlay && outboundOverlayAlpha.value > 0.01f) {
|
||||
UploadingImageOverlay(
|
||||
model = localUri,
|
||||
model = localUri ?: displayLocalUri!!,
|
||||
uploadProgress = if (isUploading || awaitingServerAck) uploadProgress else null,
|
||||
clipShape = clipShape,
|
||||
contentScale = imageContentScale,
|
||||
|
||||
@@ -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
|
||||
@@ -169,12 +171,26 @@ fun ChatTopBarInner(
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
profileUserId?.let { userId ->
|
||||
val status = resolveVerificationStatus(userId)
|
||||
if (status != null) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
StatusBadge(
|
||||
verificationStatus = status,
|
||||
size = 18.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = subtitleKey,
|
||||
|
||||
@@ -67,6 +67,8 @@ import ru.fromchat.ui.chat.components.getReplyMessageGradient
|
||||
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
|
||||
import ru.fromchat.ui.chat.utils.imageAttachmentKey
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.profile.StatusBadge
|
||||
import ru.fromchat.ui.profile.resolveVerificationStatus
|
||||
|
||||
/** True when [Message.content] is only a filename placeholder (no real caption). */
|
||||
internal fun isFilenameOnlyMessageCaption(message: Message): Boolean {
|
||||
@@ -121,6 +123,7 @@ fun MessageItem(
|
||||
val editedSuffix = stringResource(Res.string.message_edited_suffix)
|
||||
val sendFailedLabel = stringResource(Res.string.message_send_failed)
|
||||
val displayUsername = messageDisplayUsername(message, currentUserId)
|
||||
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
|
||||
val replyRef = message.reply_to
|
||||
|
||||
// No AnimatedVisibility here: visible=true still ran enter transitions for every item on first
|
||||
@@ -342,23 +345,41 @@ fun MessageItem(
|
||||
onClick = onUsernameClick
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = displayUsername,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = usernameInset
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = usernameInset,
|
||||
) {
|
||||
Text(
|
||||
text = displayUsername,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
StatusBadge(
|
||||
verificationStatus = senderVerificationStatus,
|
||||
size = 14.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(modifier = usernameOutset) {
|
||||
Text(
|
||||
text = displayUsername,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = usernameInset
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = usernameInset,
|
||||
) {
|
||||
Text(
|
||||
text = displayUsername,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
StatusBadge(
|
||||
verificationStatus = senderVerificationStatus,
|
||||
size = 14.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+71
-34
@@ -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) }
|
||||
}
|
||||
|
||||
+3
-2
@@ -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,10 +287,19 @@ fun ChatsSearchScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SearchScreenState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
SearchScreenState.NotFound -> {
|
||||
SearchScreenInfo(
|
||||
title = stringResource(Res.string.search_not_found),
|
||||
subtitle = stringResource(Res.string.search_not_found_message),
|
||||
title = stringResource(Res.string.search_not_found),
|
||||
subtitle = stringResource(Res.string.search_not_found_message),
|
||||
showSearchIcon = false
|
||||
)
|
||||
}
|
||||
@@ -328,8 +352,9 @@ private sealed interface SearchResult {
|
||||
|
||||
private enum class SearchScreenState {
|
||||
EmptyQuery,
|
||||
Loading,
|
||||
NotFound,
|
||||
Results
|
||||
Results,
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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 {
|
||||
snackbarHostState.showSnackbar(errUnexpected)
|
||||
.onFailure { error ->
|
||||
if (isUnreachableDevicesFetchError(error)) {
|
||||
refreshing = devices.isEmpty()
|
||||
} else {
|
||||
refreshing = false
|
||||
snackbarHostState.showSnackbar(errUnexpected)
|
||||
}
|
||||
}
|
||||
|
||||
refreshing = false
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
fetchDevices()
|
||||
while (isActive) {
|
||||
delay(DEVICE_SESSIONS_POLL_INTERVAL_MS)
|
||||
if (!WebSocketManager.isConnected) {
|
||||
if (devices.isEmpty()) refreshing = true
|
||||
delay(1_000)
|
||||
continue
|
||||
}
|
||||
|
||||
fetchDevices()
|
||||
delay(DEVICE_SESSIONS_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,6 +465,18 @@ fun DevicesScreen(onBack: () -> Unit) {
|
||||
current to mutable
|
||||
}
|
||||
|
||||
if (devices.isEmpty() && refreshing) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
LoadingIndicator(modifier = Modifier.padding(24.dp))
|
||||
}
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
@@ -538,7 +581,7 @@ fun DevicesScreen(onBack: () -> Unit) {
|
||||
.padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
LoadingIndicator(modifier = Modifier.padding(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,109 +308,116 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(lookupMode, lookupIdentifier, reloadAttempt) {
|
||||
if (reloadAttempt > 0) {
|
||||
state = latestUi.copy(isLoading = true, error = null)
|
||||
LaunchedEffect(lookupMode, lookupIdentifier) {
|
||||
ProfileCache.hydrateFromDisk()
|
||||
resolveCachedProfile(targetUserId, targetUsername, ownUserId)?.let { cached ->
|
||||
if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) {
|
||||
state = latestUi.copy(profile = cached, isLoading = false, error = null)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId"
|
||||
)
|
||||
|
||||
try {
|
||||
val profile = when {
|
||||
targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile()
|
||||
targetUsername != null -> ApiClient.getProfileByUsername(targetUsername)
|
||||
else -> ApiClient.getProfileById(targetUserId!!)
|
||||
while (isActive) {
|
||||
if (!WebSocketManager.isConnected) {
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
|
||||
cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) }
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId"
|
||||
)
|
||||
|
||||
try {
|
||||
val profile = when {
|
||||
targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile()
|
||||
targetUsername != null -> ApiClient.getProfileByUsername(targetUsername)
|
||||
else -> ApiClient.getProfileById(targetUserId!!)
|
||||
}
|
||||
|
||||
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
|
||||
cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) }
|
||||
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load success but blank identity for id=${profile.id}, dropping " +
|
||||
"as unusable preview"
|
||||
)
|
||||
|
||||
state = latestUi.copy(
|
||||
profile = null,
|
||||
isLoading = false,
|
||||
error = ProfileLoadError.Generic
|
||||
)
|
||||
} else {
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load success: mode=$lookupMode identifier=$lookupIdentifier -> " +
|
||||
"id=${profile.id}, username='${profile.username}', " +
|
||||
"display='${profile.displayName}', deleted=${profile.deleted}, " +
|
||||
"suspended=${profile.suspended}"
|
||||
)
|
||||
|
||||
ProfileCache.put(profile)
|
||||
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
||||
}
|
||||
} catch (err: Exception) {
|
||||
val fallback = resolveCachedProfile(targetUserId, targetUsername, ownUserId)
|
||||
?: latestUi.profile
|
||||
val resolvedProfile = latestUi.profile ?: fallback
|
||||
|
||||
if (!hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId)) {
|
||||
val fallbackId = if (targetUsername != null) null else targetUserId ?: ownUserId
|
||||
fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) }
|
||||
}
|
||||
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load success but blank identity for id=${profile.id}, dropping " +
|
||||
"as unusable preview"
|
||||
"load failure fallback lookup: fallbackFound=${fallback != null}"
|
||||
)
|
||||
|
||||
val resolvedErrorMessage = when {
|
||||
err is ClientRequestException && err.response.status.value == 404 -> profileNotFound
|
||||
else -> profileLoadFailed
|
||||
}
|
||||
|
||||
if (err is ClientRequestException) {
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load failed: mode=$lookupMode identifier=$lookupIdentifier " +
|
||||
"status=${err.response.status.value} error=${err.message}"
|
||||
)
|
||||
} else {
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load failed: mode=$lookupMode identifier=$lookupIdentifier " +
|
||||
"errorType=${err::class.simpleName} message=${err.message}"
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
showErrorAsToast &&
|
||||
(targetUserId != null || targetUsername != null) &&
|
||||
!hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId)
|
||||
) {
|
||||
showProfileLoadErrorMessage(resolvedErrorMessage)
|
||||
}
|
||||
|
||||
state = latestUi.copy(
|
||||
profile = null,
|
||||
isLoading = false,
|
||||
error = ProfileLoadError.Generic
|
||||
)
|
||||
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load success: mode=$lookupMode identifier=$lookupIdentifier -> " +
|
||||
"id=${profile.id}, username='${profile.username}', " +
|
||||
"display='${profile.displayName}', deleted=${profile.deleted}, " +
|
||||
"suspended=${profile.suspended}"
|
||||
)
|
||||
|
||||
ProfileCache.put(profile)
|
||||
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
||||
} catch (err: Exception) {
|
||||
val fallbackId = (
|
||||
if (targetUsername != null) null else targetUserId ?: ownUserId
|
||||
)?.also {
|
||||
ProfileCache.evictUnusableClientPreview(it)
|
||||
}
|
||||
|
||||
val fallback = fallbackId?.let { ProfileCache.get(it) }
|
||||
?: resolveCachedProfile(targetUserId, targetUsername, ownUserId)
|
||||
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load failure fallback lookup: fallbackId=$fallbackId " +
|
||||
"fallbackFound=${fallback != null}"
|
||||
)
|
||||
|
||||
val resolvedErrorMessage = when {
|
||||
err is ClientRequestException && err.response.status.value == 404 -> profileNotFound
|
||||
else -> profileLoadFailed
|
||||
}
|
||||
|
||||
if (err is ClientRequestException) {
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load failed: mode=$lookupMode identifier=$lookupIdentifier " +
|
||||
"status=${err.response.status.value} fallbackId=$fallbackId error=${err.message}"
|
||||
)
|
||||
} else {
|
||||
Logger.d(
|
||||
"ProfileScreen",
|
||||
"load failed: mode=$lookupMode identifier=$lookupIdentifier " +
|
||||
"errorType=${err::class.simpleName} message=${err.message}"
|
||||
)
|
||||
}
|
||||
|
||||
val resolvedProfile = latestUi.profile ?: fallback
|
||||
|
||||
if (
|
||||
showErrorAsToast &&
|
||||
(targetUserId != null || targetUsername != null) &&
|
||||
!hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId)
|
||||
) {
|
||||
showProfileLoadErrorMessage(resolvedErrorMessage)
|
||||
}
|
||||
|
||||
state = latestUi.copy(
|
||||
error = if (!hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId)) {
|
||||
if (resolvedErrorMessage == profileLoadFailed) {
|
||||
ProfileLoadError.Generic
|
||||
error = if (!hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId)) {
|
||||
if (resolvedErrorMessage == profileLoadFailed) {
|
||||
ProfileLoadError.Generic
|
||||
} else {
|
||||
ProfileLoadError.Message(resolvedErrorMessage)
|
||||
}
|
||||
} else {
|
||||
ProfileLoadError.Message(resolvedErrorMessage)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
profile = resolvedProfile,
|
||||
isLoading = false
|
||||
)
|
||||
null
|
||||
},
|
||||
profile = resolvedProfile,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +433,6 @@ fun ProfileScreen(
|
||||
val headlineVerification = stringResource(Res.string.profile_headline_verification)
|
||||
val verifiedSupport = stringResource(Res.string.profile_verified_support)
|
||||
val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support)
|
||||
val labelRetry = stringResource(Res.string.action_retry_send)
|
||||
|
||||
val profile = state.profile ?: resolveCachedProfile(targetUserId, targetUsername, ownUserId)
|
||||
if (hasDisplayableProfile(profile, initialDisplayName, ownUserId)) {
|
||||
@@ -435,17 +440,17 @@ fun ProfileScreen(
|
||||
}
|
||||
val statusMap by UserStatusStore.status.collectAsState()
|
||||
val lastSeenFormatStrings = rememberLastSeenFormatStrings()
|
||||
val loadError = state.error
|
||||
val hasDisplayable = hasDisplayableProfile(profile, initialDisplayName, ownUserId)
|
||||
val showSkeleton = !hasDisplayable && (state.isLoading || loadError != null)
|
||||
val resolvedProfile = profile?.takeIf { hasDisplayableProfile(it, null, ownUserId) }
|
||||
val showBodySkeleton = resolvedProfile == null
|
||||
val showAvatarSkeleton = showBodySkeleton && !hasDisplayable
|
||||
val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id
|
||||
val displayName =
|
||||
profile?.visibleDisplayName(currentProfileUserId)
|
||||
?: initialDisplayName?.takeIf { it.isNotBlank() }
|
||||
?: "?"
|
||||
?: profile?.username?.takeIf { it.isNotBlank() }
|
||||
?: ""
|
||||
val usernameForLinks = profile?.visibleUsername(currentProfileUserId)
|
||||
|
||||
val resolvedProfile = profile?.takeIf { !showSkeleton }
|
||||
val profileLink = resolvedProfile?.let {
|
||||
usernameForLinks?.let { name -> "https://fromchat.ru/@$name" }
|
||||
?: "https://fromchat.ru/?u=${it.id}"
|
||||
@@ -558,7 +563,7 @@ fun ProfileScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
when {
|
||||
showSkeleton && !hideAvatar -> {
|
||||
showAvatarSkeleton && !hideAvatar -> {
|
||||
item {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
@@ -591,9 +596,9 @@ fun ProfileScreen(
|
||||
|
||||
!hideAvatar -> {
|
||||
item {
|
||||
Avatar(
|
||||
profilePictureUrl = profile?.profilePicture,
|
||||
displayName = displayName,
|
||||
Avatar(
|
||||
profilePictureUrl = profile?.profilePicture,
|
||||
displayName = displayName,
|
||||
modifier = Modifier
|
||||
.padding(top = profileAvatarTop)
|
||||
.size(104.dp)
|
||||
@@ -630,24 +635,18 @@ fun ProfileScreen(
|
||||
|
||||
item {
|
||||
AnimatedContent(
|
||||
targetState = showSkeleton,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
targetState = showBodySkeleton,
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
},
|
||||
label = "profile_body",
|
||||
) { skeleton ->
|
||||
if (skeleton) {
|
||||
ProfileSkeletonBody(
|
||||
onRetry = if (loadError != null) {
|
||||
{ reloadAttempt++ }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
retryLabel = labelRetry,
|
||||
)
|
||||
} else if (resolvedProfile != null) {
|
||||
ProfileSkeletonBody()
|
||||
} else {
|
||||
ProfileLoadedBody(
|
||||
resolvedProfile = resolvedProfile,
|
||||
resolvedProfile = resolvedProfile!!,
|
||||
displayName = displayName,
|
||||
isOwnProfile = isOwnProfile,
|
||||
typingUsers = typingUsers,
|
||||
@@ -740,31 +739,34 @@ fun PublicChatProfileScreen(
|
||||
profile = PublicChatProfileCache.profile,
|
||||
isLoading = PublicChatProfileCache.profile == null,
|
||||
error = null,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val hasShownContent = remember {
|
||||
mutableStateOf(PublicChatProfileCache.profile != null)
|
||||
}
|
||||
|
||||
val latestUi by rememberUpdatedState(state)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { PublicChatProfileCache.hydrateFromDisk() }
|
||||
val diskProfile = PublicChatProfileCache.profile
|
||||
if (diskProfile != null) {
|
||||
state = latestUi.copy(profile = diskProfile, isLoading = false, error = null)
|
||||
} else if (latestUi.profile == null) {
|
||||
state = latestUi.copy(isLoading = true, error = null)
|
||||
}
|
||||
|
||||
try {
|
||||
val profile = ApiClient.getPublicChatProfile()
|
||||
PublicChatProfileCache.put(profile)
|
||||
val profile = PublicChatProfileSync.refreshFromNetwork()
|
||||
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
||||
} catch (err: Exception) {
|
||||
val cached = PublicChatProfileCache.profile
|
||||
val cached = PublicChatProfileCache.profile ?: latestUi.profile
|
||||
val resolvedErrorMessage = when {
|
||||
err is ClientRequestException && err.response.status.value == 404 -> profileNotFound
|
||||
else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed
|
||||
}
|
||||
state = latestUi.copy(
|
||||
profile = latestUi.profile ?: cached,
|
||||
profile = cached,
|
||||
isLoading = false,
|
||||
error = if (latestUi.profile == null && cached == null) {
|
||||
error = if (cached?.title.isNullOrBlank() && initialDisplayName.isNullOrBlank()) {
|
||||
if (resolvedErrorMessage == profileLoadFailed) {
|
||||
PublicChatProfileLoadError.Generic
|
||||
} else {
|
||||
@@ -780,15 +782,10 @@ fun PublicChatProfileScreen(
|
||||
val hazeState = rememberHazeState()
|
||||
|
||||
val profile = state.profile
|
||||
if (profile != null) {
|
||||
hasShownContent.value = true
|
||||
}
|
||||
val loadError = state.error
|
||||
val showLoadingSpinner = state.isLoading && !hasShownContent.value
|
||||
val displayName = profile?.title?.takeIf { it.isNotBlank() }
|
||||
val resolvedProfile = profile?.takeIf { !it.title.isNullOrBlank() }
|
||||
val displayName = resolvedProfile?.title?.takeIf { it.isNotBlank() }
|
||||
?: initialDisplayName?.takeIf { it.isNotBlank() }
|
||||
?: ""
|
||||
val resolvedProfile = profile?.takeIf { loadError == null && !showLoadingSpinner }
|
||||
val profileLink = resolvedProfile?.let { "https://fromchat.ru/chats/${it.id}" }
|
||||
val profileActions = resolvedProfile?.let {
|
||||
listOf(
|
||||
@@ -843,127 +840,57 @@ fun PublicChatProfileScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
when {
|
||||
showLoadingSpinner -> {
|
||||
item { Spacer(Modifier.height(profileAvatarTop + 104.dp + 12.dp)) }
|
||||
useSharedAvatar && displayName.isNotBlank() -> {
|
||||
item {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
loadError != null -> {
|
||||
item { Spacer(Modifier.height(profileAvatarTop + 104.dp + 12.dp)) }
|
||||
item {
|
||||
Text(
|
||||
text = when (loadError) {
|
||||
PublicChatProfileLoadError.Generic -> profileLoadFailed
|
||||
is PublicChatProfileLoadError.Message -> loadError.text
|
||||
},
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
resolvedProfile != null || initialDisplayName != null -> {
|
||||
item {
|
||||
if (useSharedAvatar) {
|
||||
with(sharedTransitionScope!!) {
|
||||
Avatar(
|
||||
profilePictureUrl = null,
|
||||
displayName = displayName,
|
||||
modifier = Modifier
|
||||
.padding(top = profileAvatarTop)
|
||||
.sharedElement(
|
||||
rememberSharedContentState(key = sharedAvatarKey!!),
|
||||
animatedVisibilityScope = animatedVisibilityScope!!,
|
||||
)
|
||||
.size(104.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
with(sharedTransitionScope!!) {
|
||||
Avatar(
|
||||
profilePictureUrl = null,
|
||||
displayName = displayName,
|
||||
modifier = Modifier
|
||||
.padding(top = profileAvatarTop)
|
||||
.sharedElement(
|
||||
rememberSharedContentState(key = sharedAvatarKey!!),
|
||||
animatedVisibilityScope = animatedVisibilityScope!!,
|
||||
)
|
||||
.size(104.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(12.dp)) }
|
||||
}
|
||||
|
||||
if (resolvedProfile != null) {
|
||||
item {
|
||||
Text(
|
||||
text = resolvedProfile.title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(4.dp)) }
|
||||
|
||||
if (showMemberCount) {
|
||||
item {
|
||||
Text(
|
||||
text = membersCountText.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
|
||||
item {
|
||||
ProfileActionButtonRow(
|
||||
actions = profileActions.orEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (showBio) {
|
||||
Category(
|
||||
margin = PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 28.dp,
|
||||
bottom = 20.dp,
|
||||
),
|
||||
roundedCorners = false,
|
||||
) {
|
||||
item {
|
||||
ListItem(
|
||||
headline = headlineBio,
|
||||
supportingSlot = {
|
||||
ProfileBioMarkdown(
|
||||
content = resolvedProfile.bio.orEmpty(),
|
||||
)
|
||||
},
|
||||
position = ListItemPosition.START,
|
||||
groupItemCount = 1,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = null,
|
||||
tint = listItemIconTint,
|
||||
)
|
||||
},
|
||||
contextMenu = {
|
||||
item(Icons.Rounded.ContentCopy, labelCopy) {
|
||||
scope.launch {
|
||||
clipboard.setText(resolvedProfile.bio.orEmpty())
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
resolvedProfile != null -> {
|
||||
item {
|
||||
Avatar(
|
||||
profilePictureUrl = null,
|
||||
displayName = displayName,
|
||||
modifier = Modifier
|
||||
.padding(top = profileAvatarTop)
|
||||
.size(104.dp),
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(12.dp)) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
item { Spacer(Modifier.height(profileAvatarTop)) }
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedProfile != null) {
|
||||
item {
|
||||
PublicChatProfileLoadedBody(
|
||||
resolvedProfile = resolvedProfile,
|
||||
profileActions = profileActions.orEmpty(),
|
||||
showMemberCount = showMemberCount,
|
||||
membersCountText = membersCountText.orEmpty(),
|
||||
showBio = showBio,
|
||||
headlineBio = headlineBio,
|
||||
listItemIconTint = listItemIconTint,
|
||||
labelCopy = labelCopy,
|
||||
clipboard = clipboard,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -988,6 +915,88 @@ fun PublicChatProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PublicChatProfileLoadedBody(
|
||||
resolvedProfile: PublicChatProfile,
|
||||
profileActions: List<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)
|
||||
|
||||
private sealed interface PublicChatProfileLoadError {
|
||||
@@ -1032,13 +1041,12 @@ private fun targetWeightForPress(
|
||||
|
||||
@Composable
|
||||
private fun ProfileSkeletonBody(
|
||||
onRetry: (() -> Unit)?,
|
||||
retryLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val barShape = RoundedCornerShape(8.dp)
|
||||
val pillShape = MaterialTheme.shapes.extraLarge
|
||||
val listBarShape = RoundedCornerShape(4.dp)
|
||||
val skeletonRowCount = 3
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
@@ -1050,7 +1058,7 @@ private fun ProfileSkeletonBody(
|
||||
.height(28.dp),
|
||||
shape = barShape,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.width(112.dp)
|
||||
@@ -1082,68 +1090,40 @@ private fun ProfileSkeletonBody(
|
||||
),
|
||||
roundedCorners = false,
|
||||
) {
|
||||
ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = true)
|
||||
ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = true)
|
||||
ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = false)
|
||||
}
|
||||
if (onRetry != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TextButton(onClick = onRetry) {
|
||||
Text(
|
||||
text = retryLabel,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
repeat(skeletonRowCount) { index ->
|
||||
ListItem(
|
||||
headline = "",
|
||||
headlineSlot = {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.38f)
|
||||
.height(14.dp),
|
||||
shape = listBarShape,
|
||||
)
|
||||
},
|
||||
supportingSlot = {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.62f)
|
||||
.height(12.dp),
|
||||
shape = listBarShape,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
ShimmerBox(
|
||||
modifier = Modifier.size(24.dp),
|
||||
shape = CircleShape,
|
||||
)
|
||||
},
|
||||
divider = index < skeletonRowCount - 1,
|
||||
position = listItemPositionInGroup(index, skeletonRowCount),
|
||||
groupItemCount = skeletonRowCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileSkeletonListRow(
|
||||
listBarShape: RoundedCornerShape,
|
||||
showDivider: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ShimmerBox(
|
||||
modifier = Modifier.size(24.dp),
|
||||
shape = CircleShape,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.38f)
|
||||
.height(14.dp),
|
||||
shape = listBarShape,
|
||||
)
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.62f)
|
||||
.height(12.dp),
|
||||
shape = listBarShape,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showDivider) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(1.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileLoadedBody(
|
||||
resolvedProfile: UserProfile,
|
||||
@@ -1209,8 +1189,7 @@ private fun ProfileLoadedBody(
|
||||
)
|
||||
}
|
||||
StatusBadge(
|
||||
verified = resolvedProfile.verified,
|
||||
userId = resolvedProfile.id,
|
||||
verificationStatus = resolvedProfile.effectiveVerificationStatus(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1400,9 +1379,19 @@ private fun ProfileLoadedBody(
|
||||
ApiClient.verifyUser(resolvedProfile.id)
|
||||
}.getOrNull()
|
||||
}
|
||||
result?.verified?.let { newVerified ->
|
||||
result?.let { response ->
|
||||
onProfileUpdated(
|
||||
resolvedProfile.copy(verified = newVerified),
|
||||
resolvedProfile.copy(
|
||||
verified = response.verified,
|
||||
verificationStatus = response.verificationStatus
|
||||
?: response.verified.let { verified ->
|
||||
if (verified) {
|
||||
ru.fromchat.api.schema.user.profile.VerificationStatus.Verified
|
||||
} else {
|
||||
ru.fromchat.api.schema.user.profile.VerificationStatus.None
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = ?;
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user