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