mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
@@ -600,12 +600,13 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ProfileCache.get(currentUser?.id ?: 0)?.let { cached ->
|
ProfileCache.get(currentUser?.id ?: 0)?.let { cached ->
|
||||||
ProfileCache.put(
|
ProfileCache.applyServerProfile(
|
||||||
cached.copy(
|
cached.copy(
|
||||||
username = response.username,
|
username = response.username,
|
||||||
displayName = response.displayName,
|
displayName = response.displayName,
|
||||||
bio = response.bio,
|
bio = response.bio,
|
||||||
)
|
),
|
||||||
|
force = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ object DeferredStartupNetwork {
|
|||||||
runCatching {
|
runCatching {
|
||||||
val profile = ApiClient.getOwnProfile()
|
val profile = ApiClient.getOwnProfile()
|
||||||
ApiClient.applyOwnProfile(profile)
|
ApiClient.applyOwnProfile(profile)
|
||||||
ProfileCache.put(profile)
|
ProfileCache.applyServerProfile(profile, force = false)
|
||||||
}
|
}
|
||||||
runCatching { syncPushTokenAfterStartup() }
|
runCatching { syncPushTokenAfterStartup() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package ru.fromchat.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.db.store.UserStatusStore
|
||||||
|
import ru.fromchat.api.schema.user.profile.UserProfile
|
||||||
|
import ru.fromchat.api.schema.user.profile.VerificationStatus
|
||||||
|
import ru.fromchat.api.schema.user.profile.orFromLegacyVerified
|
||||||
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
|
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies [profileUpdate] WebSocket payloads to [ProfileCache], the current-user session,
|
||||||
|
* and DM conversation list labels so profile changes reflect everywhere without a refetch.
|
||||||
|
*/
|
||||||
|
object ProfileUpdateSync {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
fun ensureStarted() {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
WebSocketManager.addGlobalMessageHandler(::handleWebSocketMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetOnLogout() {
|
||||||
|
started = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onProfileUpdatePayload(data: JsonElement) {
|
||||||
|
scope.launch { applyProfileUpdate(data) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleWebSocketMessage(message: WebSocketMessage) {
|
||||||
|
when (message.type) {
|
||||||
|
"updates" -> {
|
||||||
|
val data = message.data ?: return
|
||||||
|
val updates = runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
|
||||||
|
}.getOrNull() ?: return
|
||||||
|
updates.updates.forEach { update ->
|
||||||
|
handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"profileUpdate" -> {
|
||||||
|
val payload = message.data ?: return
|
||||||
|
onProfileUpdatePayload(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyProfileUpdate(data: JsonElement) {
|
||||||
|
val profile = parseProfileUpdate(data) ?: run {
|
||||||
|
Logger.w("ProfileUpdateSync", "profileUpdate parse failed: ${data.toString().take(200)}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (profile.id <= 0) return
|
||||||
|
|
||||||
|
Logger.d(
|
||||||
|
"ProfileUpdateSync",
|
||||||
|
"profileUpdate id=${profile.id} username='${profile.username}' " +
|
||||||
|
"bio='${profile.bio?.take(48)}'",
|
||||||
|
)
|
||||||
|
ProfileCache.applyServerProfile(profile, force = true)
|
||||||
|
UserStatusStore.update(profile.id, profile.online, profile.lastSeen)
|
||||||
|
|
||||||
|
if (ApiClient.user?.id == profile.id) {
|
||||||
|
ApiClient.applyOwnProfile(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching { MessageRepository.patchDmConversationPeerProfile(profile.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseProfileUpdate(data: JsonElement): UserProfile? {
|
||||||
|
val normalized = normalizeProfilePayload(data)
|
||||||
|
val decoded = runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(UserProfile.serializer(), normalized)
|
||||||
|
}.getOrElse { error ->
|
||||||
|
Logger.w("ProfileUpdateSync", "UserProfile decode failed: ${error.message}", error)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (decoded != null) {
|
||||||
|
return decoded.copy(isClientPreviewOnly = false)
|
||||||
|
}
|
||||||
|
return parseProfileUpdateFallback(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeProfilePayload(data: JsonElement): JsonElement {
|
||||||
|
val obj = data.jsonObject
|
||||||
|
val fixed = obj.toMutableMap()
|
||||||
|
for (key in listOf("created_at", "last_seen")) {
|
||||||
|
val value = obj[key] ?: continue
|
||||||
|
if (value !is JsonPrimitive) {
|
||||||
|
fixed[key] = JsonPrimitive(value.toString().trim('"'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JsonObject(fixed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseProfileUpdateFallback(data: JsonElement): UserProfile? {
|
||||||
|
val obj = data.jsonObject
|
||||||
|
val id = obj["id"]?.jsonPrimitive?.intOrNull ?: return null
|
||||||
|
val verified = obj["verified"]?.jsonPrimitive?.booleanOrNull
|
||||||
|
val verificationRaw = obj["verification_status"]?.jsonPrimitive?.contentOrNull
|
||||||
|
val verificationStatus = verificationRaw?.let { raw ->
|
||||||
|
VerificationStatus.entries.firstOrNull { it.name.equals(raw, ignoreCase = true) }
|
||||||
|
?: when (raw.lowercase()) {
|
||||||
|
"verified" -> VerificationStatus.Verified
|
||||||
|
"warning" -> VerificationStatus.Warning
|
||||||
|
"blocked" -> VerificationStatus.Blocked
|
||||||
|
else -> VerificationStatus.None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UserProfile(
|
||||||
|
id = id,
|
||||||
|
username = obj["username"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||||
|
displayName = obj["display_name"]?.jsonPrimitive?.contentOrNull,
|
||||||
|
profilePicture = obj["profile_picture"]?.jsonPrimitive?.contentOrNull,
|
||||||
|
bio = obj["bio"]?.jsonPrimitive?.contentOrNull,
|
||||||
|
online = obj["online"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||||
|
lastSeen = jsonScalarAsString(obj["last_seen"]),
|
||||||
|
createdAt = jsonScalarAsString(obj["created_at"]),
|
||||||
|
verified = verified,
|
||||||
|
verificationStatus = verificationStatus.orFromLegacyVerified(verified),
|
||||||
|
suspended = obj["suspended"]?.jsonPrimitive?.booleanOrNull,
|
||||||
|
suspensionReason = obj["suspension_reason"]?.jsonPrimitive?.contentOrNull,
|
||||||
|
deleted = obj["deleted"]?.jsonPrimitive?.booleanOrNull,
|
||||||
|
isClientPreviewOnly = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonScalarAsString(element: JsonElement?): String? {
|
||||||
|
element ?: return null
|
||||||
|
return element.jsonPrimitive.contentOrNull ?: element.toString().trim('"')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package ru.fromchat.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
|
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
||||||
|
import ru.fromchat.api.local.db.store.ConnectionStatus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference-counted [subscribeStatus] / [unsubscribeStatus] so multiple screens
|
||||||
|
* (chat top bar, profile, chats list) can share one backend subscription per user.
|
||||||
|
*/
|
||||||
|
object StatusSubscriptionCoordinator {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val refCounts = mutableMapOf<Int, Int>()
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
fun ensureStarted() {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
WebSocketManager.addSessionReadyHandler { resubscribeAll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun acquire(userId: Int) {
|
||||||
|
if (userId <= 0) return
|
||||||
|
val shouldSubscribe = mutex.withLock {
|
||||||
|
val next = (refCounts[userId] ?: 0) + 1
|
||||||
|
refCounts[userId] = next
|
||||||
|
next == 1
|
||||||
|
}
|
||||||
|
if (shouldSubscribe && ConnectionStateStore.status.value == ConnectionStatus.CONNECTED) {
|
||||||
|
runCatching { ApiClient.sendSubscribeStatus(userId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun release(userId: Int) {
|
||||||
|
if (userId <= 0) return
|
||||||
|
val shouldUnsubscribe = mutex.withLock {
|
||||||
|
val current = refCounts[userId] ?: return
|
||||||
|
if (current <= 1) {
|
||||||
|
refCounts.remove(userId)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
refCounts[userId] = current - 1
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldUnsubscribe) {
|
||||||
|
runCatching { ApiClient.sendUnsubscribeStatus(userId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resubscribeAll() {
|
||||||
|
val userIds = mutex.withLock { refCounts.keys.toList() }
|
||||||
|
if (ConnectionStateStore.status.value != ConnectionStatus.CONNECTED) return
|
||||||
|
userIds.forEach { userId ->
|
||||||
|
runCatching { ApiClient.sendSubscribeStatus(userId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetOnLogout() {
|
||||||
|
refCounts.clear()
|
||||||
|
started = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ 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.ChatListSync
|
import ru.fromchat.api.ChatListSync
|
||||||
|
import ru.fromchat.api.ProfileUpdateSync
|
||||||
import ru.fromchat.api.PublicChatProfileSync
|
import ru.fromchat.api.PublicChatProfileSync
|
||||||
|
import ru.fromchat.api.StatusSubscriptionCoordinator
|
||||||
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.db.store.PublicChatProfileCache
|
||||||
@@ -39,6 +41,8 @@ private suspend fun activateInstance(instanceId: String) {
|
|||||||
CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
|
CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
|
||||||
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) }
|
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) }
|
||||||
PublicChatProfileSync.ensureStarted()
|
PublicChatProfileSync.ensureStarted()
|
||||||
|
ProfileUpdateSync.ensureStarted()
|
||||||
|
StatusSubscriptionCoordinator.ensureStarted()
|
||||||
ChatListSync.ensureStarted()
|
ChatListSync.ensureStarted()
|
||||||
scheduleOutboxProcessing(instanceId)
|
scheduleOutboxProcessing(instanceId)
|
||||||
scheduleAttachmentResumeAfterSession()
|
scheduleAttachmentResumeAfterSession()
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.ChatListSync
|
import ru.fromchat.api.ChatListSync
|
||||||
|
import ru.fromchat.api.ProfileUpdateSync
|
||||||
|
import ru.fromchat.api.StatusSubscriptionCoordinator
|
||||||
import ru.fromchat.api.PublicChatProfileSync
|
import ru.fromchat.api.PublicChatProfileSync
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import ru.fromchat.api.local.cache.DecryptedFileCache
|
import ru.fromchat.api.local.cache.DecryptedFileCache
|
||||||
@@ -49,6 +51,8 @@ suspend fun wipeLocalCacheOnDisk() {
|
|||||||
suspend fun clearAccountCacheOnLogout(instanceId: String) {
|
suspend fun clearAccountCacheOnLogout(instanceId: String) {
|
||||||
val id = instanceId.trim()
|
val id = instanceId.trim()
|
||||||
PublicChatProfileSync.resetOnLogout()
|
PublicChatProfileSync.resetOnLogout()
|
||||||
|
ProfileUpdateSync.resetOnLogout()
|
||||||
|
StatusSubscriptionCoordinator.resetOnLogout()
|
||||||
ChatListSync.resetOnLogout()
|
ChatListSync.resetOnLogout()
|
||||||
if (id.isNotEmpty()) {
|
if (id.isNotEmpty()) {
|
||||||
cancelOutboxProcessing(id)
|
cancelOutboxProcessing(id)
|
||||||
|
|||||||
@@ -543,6 +543,33 @@ object MessageCacheStore {
|
|||||||
?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
?: ProfileCache.get(otherUserId)?.visibleUsername(ApiClient.user?.id).orEmpty()
|
?: ProfileCache.get(otherUserId)?.visibleUsername(ApiClient.user?.id).orEmpty()
|
||||||
|
|
||||||
|
suspend fun patchDmConversationPeerProfile(otherUserId: Int) {
|
||||||
|
if (otherUserId <= 0) return
|
||||||
|
val iid = instanceId()
|
||||||
|
val convId = conversationIdForDm(otherUserId)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val existing = db.messageDatabaseQueries
|
||||||
|
.selectConversationById(iid, convId)
|
||||||
|
.executeAsOneOrNull() ?: return@withContext
|
||||||
|
val label = resolveDmConversationDisplayLabel(otherUserId, null)
|
||||||
|
if (label.isEmpty()) return@withContext
|
||||||
|
if (label == existing.displayName) return@withContext
|
||||||
|
db.messageDatabaseQueries.upsertConversation(
|
||||||
|
instanceId = iid,
|
||||||
|
id = existing.id,
|
||||||
|
type = existing.type,
|
||||||
|
otherUserId = existing.otherUserId,
|
||||||
|
displayName = label,
|
||||||
|
lastMessageId = existing.lastMessageId,
|
||||||
|
lastMessagePreview = existing.lastMessagePreview,
|
||||||
|
unreadCount = existing.unreadCount,
|
||||||
|
updatedAt = existing.updatedAt,
|
||||||
|
archived = existing.archived,
|
||||||
|
)
|
||||||
|
DmConversationListNotifier.notifyChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun markDmConversationReadLocally(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
suspend fun markDmConversationReadLocally(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
val convId = conversationIdForDm(otherUserId)
|
val convId = conversationIdForDm(otherUserId)
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ object MessageRepository {
|
|||||||
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) =
|
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) =
|
||||||
MessageCacheStore.ensureDmConversationRow(otherUserId, displayName)
|
MessageCacheStore.ensureDmConversationRow(otherUserId, displayName)
|
||||||
|
|
||||||
|
suspend fun patchDmConversationPeerProfile(otherUserId: Int) =
|
||||||
|
MessageCacheStore.patchDmConversationPeerProfile(otherUserId)
|
||||||
|
|
||||||
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
||||||
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
|
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
|
||||||
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
|
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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 kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -61,6 +64,10 @@ object ProfileCache {
|
|||||||
|
|
||||||
fun get(userId: Int): UserProfile? = profiles[userId]
|
fun get(userId: Int): UserProfile? = profiles[userId]
|
||||||
|
|
||||||
|
/** Emits whenever this user's cached profile changes (including bio). */
|
||||||
|
fun observeUser(userId: Int): Flow<UserProfile?> =
|
||||||
|
revision.map { get(userId) }.distinctUntilChanged()
|
||||||
|
|
||||||
fun findByUsername(username: String): UserProfile? =
|
fun findByUsername(username: String): UserProfile? =
|
||||||
username.trim().takeIf { it.isNotEmpty() }?.let { needle ->
|
username.trim().takeIf { it.isNotEmpty() }?.let { needle ->
|
||||||
profiles.values.firstOrNull { profile ->
|
profiles.values.firstOrNull { profile ->
|
||||||
@@ -135,6 +142,18 @@ object ProfileCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val cur = profiles
|
val cur = profiles
|
||||||
|
val existing = cur[profile.id]
|
||||||
|
if (
|
||||||
|
existing != null &&
|
||||||
|
!existing.isClientPreviewOnly &&
|
||||||
|
existing.bio != profile.bio
|
||||||
|
) {
|
||||||
|
ru.fromchat.Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"put overwrite id=${profile.id} bio '${existing.bio?.take(48)}' -> " +
|
||||||
|
"'${profile.bio?.take(48)}' preview=${profile.isClientPreviewOnly}",
|
||||||
|
)
|
||||||
|
}
|
||||||
profiles = cur + (profile.id to profile)
|
profiles = cur + (profile.id to profile)
|
||||||
bumpRevision()
|
bumpRevision()
|
||||||
val instanceId = loadedInstanceId
|
val instanceId = loadedInstanceId
|
||||||
@@ -145,6 +164,35 @@ object ProfileCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a full server profile payload (HTTP or WebSocket).
|
||||||
|
* When [force] is false, an existing full (non-preview) cache row is kept so a slow HTTP
|
||||||
|
* response cannot overwrite a fresher WebSocket update.
|
||||||
|
*/
|
||||||
|
fun applyServerProfile(profile: UserProfile, force: Boolean = false) {
|
||||||
|
if (profile.id <= 0) return
|
||||||
|
val normalized = profile.copy(isClientPreviewOnly = false)
|
||||||
|
if (!force) {
|
||||||
|
val existing = get(profile.id)
|
||||||
|
if (existing != null && !existing.isClientPreviewOnly) {
|
||||||
|
if (existing.bio != normalized.bio) {
|
||||||
|
ru.fromchat.Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"applyServerProfile skipped stale HTTP id=${profile.id} " +
|
||||||
|
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ru.fromchat.Logger.d(
|
||||||
|
"ProfileCache",
|
||||||
|
"applyServerProfile applied force=$force id=${profile.id} " +
|
||||||
|
"bio='${normalized.bio?.take(48)}'",
|
||||||
|
)
|
||||||
|
put(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
fun remove(userId: Int) {
|
fun remove(userId: Int) {
|
||||||
val cur = profiles
|
val cur = profiles
|
||||||
if (userId !in cur) return
|
if (userId !in cur) return
|
||||||
@@ -333,11 +381,26 @@ object ProfileCache {
|
|||||||
val instanceId = CacheContext.activeInstanceId.value.trim()
|
val instanceId = CacheContext.activeInstanceId.value.trim()
|
||||||
persistMutex.withLock {
|
persistMutex.withLock {
|
||||||
loadedInstanceId = instanceId
|
loadedInstanceId = instanceId
|
||||||
profiles = if (instanceId.isNotEmpty()) {
|
val diskProfiles = if (instanceId.isNotEmpty()) {
|
||||||
runCatching { ProfileCacheStore.loadAllForInstance(instanceId) }.getOrDefault(emptyMap())
|
runCatching { ProfileCacheStore.loadAllForInstance(instanceId) }.getOrDefault(emptyMap())
|
||||||
} else {
|
} else {
|
||||||
emptyMap()
|
emptyMap()
|
||||||
}
|
}
|
||||||
|
if (profiles.isEmpty()) {
|
||||||
|
profiles = diskProfiles
|
||||||
|
} else {
|
||||||
|
val merged = profiles.toMutableMap()
|
||||||
|
for ((userId, diskProfile) in diskProfiles) {
|
||||||
|
val inMemory = merged[userId]
|
||||||
|
when {
|
||||||
|
inMemory == null -> merged[userId] = diskProfile
|
||||||
|
inMemory.isClientPreviewOnly && !diskProfile.isClientPreviewOnly ->
|
||||||
|
merged[userId] = diskProfile
|
||||||
|
// Keep in-memory full profiles over disk — disk may lag behind WS.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profiles = merged
|
||||||
|
}
|
||||||
pruneUnusableClientPreviewsLocked()
|
pruneUnusableClientPreviewsLocked()
|
||||||
bumpRevision()
|
bumpRevision()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ object UserStatusStore {
|
|||||||
val status: StateFlow<Map<Int, UserStatus>> = _status.asStateFlow()
|
val status: StateFlow<Map<Int, UserStatus>> = _status.asStateFlow()
|
||||||
|
|
||||||
fun update(userId: Int, online: Boolean, lastSeen: String?) {
|
fun update(userId: Int, online: Boolean, lastSeen: String?) {
|
||||||
|
if (userId <= 0) return
|
||||||
_status.update { current ->
|
_status.update { current ->
|
||||||
val existing = current[userId] ?: UserStatus(online = false)
|
val existing = current[userId] ?: UserStatus(online = false)
|
||||||
current + (userId to existing.copy(online = online, lastSeen = lastSeen ?: existing.lastSeen))
|
current + (userId to existing.copy(online = online, lastSeen = lastSeen ?: existing.lastSeen))
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ 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.ProfileUpdateSync
|
||||||
import ru.fromchat.api.PublicChatProfileSync
|
import ru.fromchat.api.PublicChatProfileSync
|
||||||
|
import ru.fromchat.api.StatusSubscriptionCoordinator
|
||||||
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.bootstrapSessionInstance
|
import ru.fromchat.api.instance.bootstrapSessionInstance
|
||||||
@@ -240,6 +242,8 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
PublicChatProfileSync.ensureStarted()
|
PublicChatProfileSync.ensureStarted()
|
||||||
|
ProfileUpdateSync.ensureStarted()
|
||||||
|
StatusSubscriptionCoordinator.ensureStarted()
|
||||||
}
|
}
|
||||||
|
|
||||||
startDestination = when {
|
startDestination = when {
|
||||||
@@ -442,6 +446,8 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
PublicChatProfileSync.ensureStarted()
|
PublicChatProfileSync.ensureStarted()
|
||||||
|
ProfileUpdateSync.ensureStarted()
|
||||||
|
StatusSubscriptionCoordinator.ensureStarted()
|
||||||
scheduleSessionInstanceNetworkRefresh()
|
scheduleSessionInstanceNetworkRefresh()
|
||||||
}
|
}
|
||||||
WebSocketManager.connect(forceRestart = true)
|
WebSocketManager.connect(forceRestart = true)
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.presence_recently
|
||||||
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.AttachmentMediaLog
|
import ru.fromchat.api.local.AttachmentMediaLog
|
||||||
@@ -89,6 +90,8 @@ import ru.fromchat.api.local.db.store.ConnectionStateStore
|
|||||||
import ru.fromchat.api.local.db.store.ConnectionStatus
|
import ru.fromchat.api.local.db.store.ConnectionStatus
|
||||||
import ru.fromchat.api.local.db.store.MessageRepository
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.StatusSubscriptionCoordinator
|
||||||
|
import ru.fromchat.api.local.db.store.UserStatus
|
||||||
import ru.fromchat.api.local.db.store.UserStatusStore
|
import ru.fromchat.api.local.db.store.UserStatusStore
|
||||||
import ru.fromchat.api.local.download.SavableMessageImage
|
import ru.fromchat.api.local.download.SavableMessageImage
|
||||||
import ru.fromchat.api.local.download.ensureFileDownloadedForSave
|
import ru.fromchat.api.local.download.ensureFileDownloadedForSave
|
||||||
@@ -408,7 +411,8 @@ fun ChatScreen(
|
|||||||
peerDeleted = peerIsDeleted(userId = userId, currentUserId = currentUserId)
|
peerDeleted = peerIsDeleted(userId = userId, currentUserId = currentUserId)
|
||||||
if (!peerDeleted) {
|
if (!peerDeleted) {
|
||||||
runCatching { ApiClient.getProfileById(userId) }.onSuccess { profile ->
|
runCatching { ApiClient.getProfileById(userId) }.onSuccess { profile ->
|
||||||
ProfileCache.put(profile)
|
ProfileCache.applyServerProfile(profile, force = false)
|
||||||
|
UserStatusStore.update(profile.id, profile.online, profile.lastSeen)
|
||||||
peerDeleted = peerIsDeleted(
|
peerDeleted = peerIsDeleted(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
@@ -422,6 +426,7 @@ fun ChatScreen(
|
|||||||
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
|
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
|
||||||
val statusConnecting = stringResource(Res.string.status_connecting)
|
val statusConnecting = stringResource(Res.string.status_connecting)
|
||||||
val statusUpdating = stringResource(Res.string.status_updating)
|
val statusUpdating = stringResource(Res.string.status_updating)
|
||||||
|
val presenceRecently = stringResource(Res.string.presence_recently)
|
||||||
val chatGroupLabel = stringResource(Res.string.chat_group_label)
|
val chatGroupLabel = stringResource(Res.string.chat_group_label)
|
||||||
val cdCall = stringResource(Res.string.cd_call)
|
val cdCall = stringResource(Res.string.cd_call)
|
||||||
LaunchedEffect(currentTypingUsers) {
|
LaunchedEffect(currentTypingUsers) {
|
||||||
@@ -440,9 +445,14 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
panelState.profileUserId != null -> {
|
panelState.profileUserId != null -> {
|
||||||
val userStatus = statusMap[panelState.profileUserId]
|
val peerId = panelState.profileUserId!!
|
||||||
val statusText = userStatus?.let {
|
val userStatus = statusMap[peerId]
|
||||||
formatLastSeen(it.online, it.lastSeen, lastSeenFormat)
|
?: ProfileCache.get(peerId)?.let { profile ->
|
||||||
|
UserStatus(online = profile.online, lastSeen = profile.lastSeen)
|
||||||
|
}
|
||||||
|
val statusText = userStatus?.let { status ->
|
||||||
|
formatLastSeen(status.online, status.lastSeen, lastSeenFormat)
|
||||||
|
.ifEmpty { if (!status.online) presenceRecently else "" }
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
if (statusText.isNotEmpty()) {
|
if (statusText.isNotEmpty()) {
|
||||||
"presence:$statusText"
|
"presence:$statusText"
|
||||||
@@ -461,16 +471,14 @@ fun ChatScreen(
|
|||||||
|
|
||||||
// Subscribe to other user's status when DM is visible; re-subscribe after reconnect
|
// Subscribe to other user's status when DM is visible; re-subscribe after reconnect
|
||||||
LaunchedEffect(panelState.profileUserId, connectionStatus) {
|
LaunchedEffect(panelState.profileUserId, connectionStatus) {
|
||||||
val userId = panelState.profileUserId
|
val userId = panelState.profileUserId ?: return@LaunchedEffect
|
||||||
if (userId != null) {
|
|
||||||
if (connectionStatus == ConnectionStatus.CONNECTED) {
|
if (connectionStatus == ConnectionStatus.CONNECTED) {
|
||||||
runCatching { ApiClient.sendSubscribeStatus(userId) }
|
StatusSubscriptionCoordinator.acquire(userId)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
kotlinx.coroutines.awaitCancellation()
|
kotlinx.coroutines.awaitCancellation()
|
||||||
} finally {
|
} finally {
|
||||||
runCatching { ApiClient.sendUnsubscribeStatus(userId) }
|
StatusSubscriptionCoordinator.release(userId)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ fun ChatTopBarInner(
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.wrapContentWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
horizontalAlignment = Alignment.Start,
|
||||||
) {
|
) {
|
||||||
@@ -208,6 +208,7 @@ fun ChatTopBarInner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
targetState = subtitleKey,
|
targetState = subtitleKey,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibilityScope
|
|||||||
import androidx.compose.animation.SharedTransitionScope
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -79,11 +80,13 @@ fun DmProfileRoute(
|
|||||||
?: stateSnapshot.title.takeIf { it.isNotBlank() }
|
?: stateSnapshot.title.takeIf { it.isNotBlank() }
|
||||||
val initialProfilePictureUrl = stateSnapshot.titleAvatar?.profilePictureUrl
|
val initialProfilePictureUrl = stateSnapshot.titleAvatar?.profilePictureUrl
|
||||||
|
|
||||||
|
LaunchedEffect(otherUserId, initialDisplayName, initialProfilePictureUrl) {
|
||||||
ProfileCache.mergePreview(
|
ProfileCache.mergePreview(
|
||||||
id = otherUserId,
|
id = otherUserId,
|
||||||
displayName = initialDisplayName,
|
displayName = initialDisplayName,
|
||||||
profilePicture = initialProfilePictureUrl,
|
profilePicture = initialProfilePictureUrl,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ProfileScreen(
|
ProfileScreen(
|
||||||
userId = otherUserId,
|
userId = otherUserId,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.db.store.UserStatusStore
|
||||||
import ru.fromchat.api.local.db.store.MessageCacheStore
|
import ru.fromchat.api.local.db.store.MessageCacheStore
|
||||||
import ru.fromchat.api.local.messages.ActiveDmChatTracker
|
import ru.fromchat.api.local.messages.ActiveDmChatTracker
|
||||||
import ru.fromchat.api.local.db.store.MessageRepository
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
@@ -91,6 +92,11 @@ class DmPanel(
|
|||||||
updateState { it.copy(typingUsers = users) }
|
updateState { it.copy(typingUsers = users) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
coroutineScope.launch {
|
||||||
|
ProfileCache.revision.collect {
|
||||||
|
applyCachedPeerProfileOrReset()
|
||||||
|
}
|
||||||
|
}
|
||||||
coroutineScope.launch(Dispatchers.Default) {
|
coroutineScope.launch(Dispatchers.Default) {
|
||||||
AttachmentDownloadNotifier.progressFlow.collect { event ->
|
AttachmentDownloadNotifier.progressFlow.collect { event ->
|
||||||
if (event !is AttachmentDownloadProgress.Success || event.messageId <= 0) return@collect
|
if (event !is AttachmentDownloadProgress.Success || event.messageId <= 0) return@collect
|
||||||
@@ -128,7 +134,8 @@ class DmPanel(
|
|||||||
}
|
}
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
ProfileCache.put(profile)
|
ProfileCache.applyServerProfile(profile, force = false)
|
||||||
|
UserStatusStore.update(profile.id, profile.online, profile.lastSeen)
|
||||||
val displayName = profile.displayNameText(ApiClient.user?.id)
|
val displayName = profile.displayNameText(ApiClient.user?.id)
|
||||||
if (displayName.isNotBlank()) {
|
if (displayName.isNotBlank()) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
@@ -152,6 +159,7 @@ class DmPanel(
|
|||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
val cached = ProfileCache.get(otherUserId)
|
val cached = ProfileCache.get(otherUserId)
|
||||||
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
|
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
|
||||||
|
cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) }
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (displayName.isNotBlank()) {
|
if (displayName.isNotBlank()) {
|
||||||
applyPeerTitle(displayName, cached?.profilePicture)
|
applyPeerTitle(displayName, cached?.profilePicture)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ 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.StatusSubscriptionCoordinator
|
||||||
import ru.fromchat.api.local.db.store.CachedConversation
|
import ru.fromchat.api.local.db.store.CachedConversation
|
||||||
import ru.fromchat.api.local.db.store.MessageCacheStore
|
import ru.fromchat.api.local.db.store.MessageCacheStore
|
||||||
import ru.fromchat.api.local.db.store.MessageRepository
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
@@ -208,10 +209,14 @@ fun ChatsSearchScreen(
|
|||||||
val toUnsubscribe = subscribedDmUserIds - visibleIds
|
val toUnsubscribe = subscribedDmUserIds - visibleIds
|
||||||
|
|
||||||
toSubscribe.forEach { userId ->
|
toSubscribe.forEach { userId ->
|
||||||
runCatching { ApiClient.sendSubscribeStatus(userId) }
|
statusSubscriptionScope.launch {
|
||||||
|
StatusSubscriptionCoordinator.acquire(userId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
toUnsubscribe.forEach { userId ->
|
toUnsubscribe.forEach { userId ->
|
||||||
runCatching { ApiClient.sendUnsubscribeStatus(userId) }
|
statusSubscriptionScope.launch {
|
||||||
|
StatusSubscriptionCoordinator.release(userId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
subscribedDmUserIds = visibleIds
|
subscribedDmUserIds = visibleIds
|
||||||
}
|
}
|
||||||
@@ -222,7 +227,7 @@ fun ChatsSearchScreen(
|
|||||||
hideIme()
|
hideIme()
|
||||||
if (subscribedDmUserIds.isNotEmpty()) {
|
if (subscribedDmUserIds.isNotEmpty()) {
|
||||||
statusSubscriptionScope.launch {
|
statusSubscriptionScope.launch {
|
||||||
subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) }
|
subscribedDmUserIds.forEach { StatusSubscriptionCoordinator.release(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
subscribedDmUserIds = emptySet()
|
subscribedDmUserIds = emptySet()
|
||||||
|
|||||||
@@ -109,6 +109,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.ChatListSync
|
import ru.fromchat.api.ChatListSync
|
||||||
|
import ru.fromchat.api.StatusSubscriptionCoordinator
|
||||||
import ru.fromchat.api.calls.CallStore
|
import ru.fromchat.api.calls.CallStore
|
||||||
import ru.fromchat.api.local.WebSocketManager
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
@@ -715,12 +716,16 @@ fun ChatsTab(
|
|||||||
.collect { visibleIds ->
|
.collect { visibleIds ->
|
||||||
if (connectionStatus == ConnectionStatus.CONNECTED) {
|
if (connectionStatus == ConnectionStatus.CONNECTED) {
|
||||||
visibleIds.forEach { userId ->
|
visibleIds.forEach { userId ->
|
||||||
runCatching { ApiClient.sendSubscribeStatus(userId) }
|
statusSubscriptionScope.launch {
|
||||||
|
StatusSubscriptionCoordinator.acquire(userId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(subscribedDmUserIds - visibleIds).forEach { userId ->
|
(subscribedDmUserIds - visibleIds).forEach { userId ->
|
||||||
runCatching { ApiClient.sendUnsubscribeStatus(userId) }
|
statusSubscriptionScope.launch {
|
||||||
|
StatusSubscriptionCoordinator.release(userId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribedDmUserIds = visibleIds
|
subscribedDmUserIds = visibleIds
|
||||||
@@ -731,7 +736,7 @@ fun ChatsTab(
|
|||||||
onDispose {
|
onDispose {
|
||||||
if (subscribedDmUserIds.isNotEmpty()) {
|
if (subscribedDmUserIds.isNotEmpty()) {
|
||||||
statusSubscriptionScope.launch {
|
statusSubscriptionScope.launch {
|
||||||
subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) }
|
subscribedDmUserIds.forEach { StatusSubscriptionCoordinator.release(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
subscribedDmUserIds = emptySet()
|
subscribedDmUserIds = emptySet()
|
||||||
|
|||||||
@@ -298,7 +298,8 @@ fun EditProfileScreen(
|
|||||||
lastSeen = ApiClient.user?.last_seen,
|
lastSeen = ApiClient.user?.last_seen,
|
||||||
createdAt = existing?.createdAt,
|
createdAt = existing?.createdAt,
|
||||||
)
|
)
|
||||||
ProfileCache.put(updatedProfile)
|
ProfileCache.applyServerProfile(updatedProfile, force = true)
|
||||||
|
ApiClient.applyOwnProfile(updatedProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
showSnack(savedMessage)
|
showSnack(savedMessage)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -124,8 +125,11 @@ 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.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.StatusSubscriptionCoordinator
|
||||||
import ru.fromchat.api.PublicChatProfileSync
|
import ru.fromchat.api.PublicChatProfileSync
|
||||||
import ru.fromchat.api.local.WebSocketManager
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
|
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
||||||
|
import ru.fromchat.api.local.db.store.ConnectionStatus
|
||||||
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
|
||||||
@@ -207,6 +211,17 @@ private fun hasDisplayableProfile(
|
|||||||
profile.username.isNotBlank()
|
profile.username.isNotBlank()
|
||||||
))
|
))
|
||||||
|
|
||||||
|
/** Live profile row for [userId]; recomposes when [ProfileCache] updates (e.g. WS bio change). */
|
||||||
|
@Composable
|
||||||
|
private fun rememberLiveProfile(userId: Int?): UserProfile? {
|
||||||
|
if (userId == null || userId <= 0) return null
|
||||||
|
val flow = remember(userId) { ProfileCache.observeUser(userId) }
|
||||||
|
return flow
|
||||||
|
.collectAsState(initial = ProfileCache.get(userId))
|
||||||
|
.value
|
||||||
|
?.takeUnless { it.isClientPreviewOnly }
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(
|
fun ProfileScreen(
|
||||||
@@ -253,6 +268,7 @@ fun ProfileScreen(
|
|||||||
val targetUsername = username?.trim()?.takeIf { it.isNotBlank() }
|
val targetUsername = username?.trim()?.takeIf { it.isNotBlank() }
|
||||||
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 }
|
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 }
|
||||||
|
|
||||||
|
LaunchedEffect(targetUserId, initialDisplayName, initialProfilePictureUrl) {
|
||||||
targetUserId?.let { id ->
|
targetUserId?.let { id ->
|
||||||
ProfileCache.mergePreview(
|
ProfileCache.mergePreview(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -260,6 +276,7 @@ fun ProfileScreen(
|
|||||||
profilePicture = initialProfilePictureUrl?.takeIf { it.isNotBlank() },
|
profilePicture = initialProfilePictureUrl?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val cacheLookupId = when {
|
val cacheLookupId = when {
|
||||||
targetUserId != null -> targetUserId
|
targetUserId != null -> targetUserId
|
||||||
@@ -297,17 +314,27 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val latestUi by rememberUpdatedState(state)
|
val latestUi by rememberUpdatedState(state)
|
||||||
val profileCacheRevision by ProfileCache.revision.collectAsState()
|
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
||||||
|
val displayUserId = targetUserId
|
||||||
|
?: state.profile?.id
|
||||||
|
?: ownUserId?.takeIf { targetUsername == null }
|
||||||
|
val liveProfile = rememberLiveProfile(displayUserId)
|
||||||
|
val subscribedUserId = displayUserId
|
||||||
val isViewingOwnProfile = ownUserId != null && (
|
val isViewingOwnProfile = ownUserId != null && (
|
||||||
(targetUserId == null && targetUsername == null) || targetUserId == ownUserId
|
(targetUserId == null && targetUsername == null) || targetUserId == ownUserId
|
||||||
)
|
)
|
||||||
|
|
||||||
LaunchedEffect(profileCacheRevision) {
|
LaunchedEffect(subscribedUserId, connectionStatus) {
|
||||||
if (!isViewingOwnProfile) return@LaunchedEffect
|
val userId = subscribedUserId ?: return@LaunchedEffect
|
||||||
ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
|
if (userId <= 0) return@LaunchedEffect
|
||||||
if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) {
|
if (connectionStatus == ConnectionStatus.CONNECTED) {
|
||||||
state = latestUi.copy(profile = cached, isLoading = false, error = null)
|
Logger.d("ProfileScreen", "subscribeStatus userId=$userId")
|
||||||
|
StatusSubscriptionCoordinator.acquire(userId)
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
awaitCancellation()
|
||||||
|
} finally {
|
||||||
|
StatusSubscriptionCoordinator.release(userId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +347,7 @@ fun ProfileScreen(
|
|||||||
if (!isViewingOwnProfile) return@collect
|
if (!isViewingOwnProfile) return@collect
|
||||||
try {
|
try {
|
||||||
val refreshed = ApiClient.getOwnProfile()
|
val refreshed = ApiClient.getOwnProfile()
|
||||||
ProfileCache.put(refreshed)
|
ProfileCache.applyServerProfile(refreshed, force = true)
|
||||||
ApiClient.applyOwnProfile(refreshed)
|
ApiClient.applyOwnProfile(refreshed)
|
||||||
state = latestUi.copy(profile = refreshed, error = null)
|
state = latestUi.copy(profile = refreshed, error = null)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -332,7 +359,6 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(lookupMode, lookupIdentifier) {
|
LaunchedEffect(lookupMode, lookupIdentifier) {
|
||||||
ProfileCache.hydrateFromDisk()
|
|
||||||
resolveCachedProfile(targetUserId, targetUsername, ownUserId)?.let { cached ->
|
resolveCachedProfile(targetUserId, targetUsername, ownUserId)?.let { cached ->
|
||||||
if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) {
|
if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) {
|
||||||
state = latestUi.copy(profile = cached, isLoading = false, error = null)
|
state = latestUi.copy(profile = cached, isLoading = false, error = null)
|
||||||
@@ -377,15 +403,18 @@ fun ProfileScreen(
|
|||||||
"ProfileScreen",
|
"ProfileScreen",
|
||||||
"load success: mode=$lookupMode identifier=$lookupIdentifier -> " +
|
"load success: mode=$lookupMode identifier=$lookupIdentifier -> " +
|
||||||
"id=${profile.id}, username='${profile.username}', " +
|
"id=${profile.id}, username='${profile.username}', " +
|
||||||
"display='${profile.displayName}', deleted=${profile.deleted}, " +
|
"display='${profile.displayName}', bio='${profile.bio?.take(32)}', " +
|
||||||
|
"deleted=${profile.deleted}, " +
|
||||||
"suspended=${profile.suspended}"
|
"suspended=${profile.suspended}"
|
||||||
)
|
)
|
||||||
|
|
||||||
ProfileCache.put(profile)
|
val normalized = profile.copy(isClientPreviewOnly = false)
|
||||||
|
ProfileCache.applyServerProfile(normalized, force = false)
|
||||||
|
val displayProfile = ProfileCache.get(profile.id) ?: normalized
|
||||||
|
state = latestUi.copy(profile = displayProfile, isLoading = false, error = null)
|
||||||
if (isViewingOwnProfile) {
|
if (isViewingOwnProfile) {
|
||||||
ApiClient.applyOwnProfile(profile)
|
ApiClient.applyOwnProfile(displayProfile)
|
||||||
}
|
}
|
||||||
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
|
||||||
loadedSuccessfully = true
|
loadedSuccessfully = true
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -474,7 +503,18 @@ fun ProfileScreen(
|
|||||||
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 profile = state.profile ?: resolveCachedProfile(targetUserId, targetUsername, ownUserId)
|
val profile = liveProfile
|
||||||
|
?: state.profile
|
||||||
|
?: resolveCachedProfile(targetUserId, targetUsername, ownUserId)
|
||||||
|
|
||||||
|
LaunchedEffect(displayUserId, liveProfile?.bio, state.profile?.bio) {
|
||||||
|
Logger.d(
|
||||||
|
"ProfileScreen",
|
||||||
|
"profile bio displayUserId=$displayUserId live='${liveProfile?.bio?.take(48)}' " +
|
||||||
|
"state='${state.profile?.bio?.take(48)}' shown='${profile?.bio?.take(48)}'",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (hasDisplayableProfile(profile, initialDisplayName, ownUserId)) {
|
if (hasDisplayableProfile(profile, initialDisplayName, ownUserId)) {
|
||||||
hasShownContent.value = true
|
hasShownContent.value = true
|
||||||
}
|
}
|
||||||
@@ -1405,12 +1445,13 @@ private fun ProfileLoadedBody(
|
|||||||
if (showDetailsBio) {
|
if (showDetailsBio) {
|
||||||
val position = listItemPositionInGroup(detailIndex, detailCount)
|
val position = listItemPositionInGroup(detailIndex, detailCount)
|
||||||
detailIndex++
|
detailIndex++
|
||||||
|
val bioContent = resolvedProfile.bio.orEmpty()
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = headlineBio,
|
headline = headlineBio,
|
||||||
supportingSlot = {
|
supportingSlot = {
|
||||||
ProfileBioMarkdown(
|
key(resolvedProfile.id, bioContent) {
|
||||||
content = resolvedProfile.bio.orEmpty(),
|
ProfileBioMarkdown(content = bioContent)
|
||||||
)
|
}
|
||||||
},
|
},
|
||||||
divider = true,
|
divider = true,
|
||||||
position = position,
|
position = position,
|
||||||
|
|||||||
Reference in New Issue
Block a user