diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index c74339b..6a9c546 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -600,12 +600,13 @@ object ApiClient { } ProfileCache.get(currentUser?.id ?: 0)?.let { cached -> - ProfileCache.put( + ProfileCache.applyServerProfile( cached.copy( username = response.username, displayName = response.displayName, bio = response.bio, - ) + ), + force = true, ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt index 671f439..1301c18 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/DeferredStartupNetwork.kt @@ -22,7 +22,7 @@ object DeferredStartupNetwork { runCatching { val profile = ApiClient.getOwnProfile() ApiClient.applyOwnProfile(profile) - ProfileCache.put(profile) + ProfileCache.applyServerProfile(profile, force = false) } runCatching { syncPushTokenAfterStartup() } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileUpdateSync.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileUpdateSync.kt new file mode 100644 index 0000000..a1a28dc --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileUpdateSync.kt @@ -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('"') + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/StatusSubscriptionCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/StatusSubscriptionCoordinator.kt new file mode 100644 index 0000000..0e54312 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/StatusSubscriptionCoordinator.kt @@ -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() + 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 + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt index 0acad13..3098c1c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt @@ -8,7 +8,9 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import ru.fromchat.api.ApiClient import ru.fromchat.api.ChatListSync +import ru.fromchat.api.ProfileUpdateSync 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.MessageRepository import ru.fromchat.api.local.db.store.PublicChatProfileCache @@ -39,6 +41,8 @@ private suspend fun activateInstance(instanceId: String) { CacheContext.setActiveInstance(instanceId, ApiClient.user?.id) runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) } PublicChatProfileSync.ensureStarted() + ProfileUpdateSync.ensureStarted() + StatusSubscriptionCoordinator.ensureStarted() ChatListSync.ensureStarted() scheduleOutboxProcessing(instanceId) scheduleAttachmentResumeAfterSession() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt index 5b0bf60..00ad2dd 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import ru.fromchat.api.ApiClient import ru.fromchat.api.ChatListSync +import ru.fromchat.api.ProfileUpdateSync +import ru.fromchat.api.StatusSubscriptionCoordinator import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.DecryptedFileCache @@ -49,6 +51,8 @@ suspend fun wipeLocalCacheOnDisk() { suspend fun clearAccountCacheOnLogout(instanceId: String) { val id = instanceId.trim() PublicChatProfileSync.resetOnLogout() + ProfileUpdateSync.resetOnLogout() + StatusSubscriptionCoordinator.resetOnLogout() ChatListSync.resetOnLogout() if (id.isNotEmpty()) { cancelOutboxProcessing(id) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt index 29a7f56..8fe7928 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt @@ -543,6 +543,33 @@ object MessageCacheStore { ?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: 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) { val iid = instanceId() val convId = conversationIdForDm(otherUserId) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt index 8c7c19f..93f0aa7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt @@ -108,6 +108,9 @@ object MessageRepository { suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) = MessageCacheStore.ensureDmConversationRow(otherUserId, displayName) + suspend fun patchDmConversationPeerProfile(otherUserId: Int) = + MessageCacheStore.patchDmConversationPeerProfile(otherUserId) + suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) { runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) } MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt index 66a0f4f..c2c8817 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt @@ -6,6 +6,9 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -61,6 +64,10 @@ object ProfileCache { fun get(userId: Int): UserProfile? = profiles[userId] + /** Emits whenever this user's cached profile changes (including bio). */ + fun observeUser(userId: Int): Flow = + revision.map { get(userId) }.distinctUntilChanged() + fun findByUsername(username: String): UserProfile? = username.trim().takeIf { it.isNotEmpty() }?.let { needle -> profiles.values.firstOrNull { profile -> @@ -135,6 +142,18 @@ object ProfileCache { } } 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) bumpRevision() 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) { val cur = profiles if (userId !in cur) return @@ -333,11 +381,26 @@ object ProfileCache { val instanceId = CacheContext.activeInstanceId.value.trim() persistMutex.withLock { loadedInstanceId = instanceId - profiles = if (instanceId.isNotEmpty()) { + val diskProfiles = if (instanceId.isNotEmpty()) { runCatching { ProfileCacheStore.loadAllForInstance(instanceId) }.getOrDefault(emptyMap()) } else { 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() bumpRevision() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/UserStatusStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/UserStatusStore.kt index 7132d97..b853ea6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/UserStatusStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/UserStatusStore.kt @@ -16,6 +16,7 @@ object UserStatusStore { val status: StateFlow> = _status.asStateFlow() fun update(userId: Int, online: Boolean, lastSeen: String?) { + if (userId <= 0) return _status.update { current -> val existing = current[userId] ?: UserStatus(online = false) current + (userId to existing.copy(online = online, lastSeen = lastSeen ?: existing.lastSeen)) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 949d9d1..851b33a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -56,7 +56,9 @@ import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.AppForeground import ru.fromchat.Logger import ru.fromchat.api.DeferredStartupNetwork +import ru.fromchat.api.ProfileUpdateSync import ru.fromchat.api.PublicChatProfileSync +import ru.fromchat.api.StatusSubscriptionCoordinator import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.calls.CallStore import ru.fromchat.api.instance.bootstrapSessionInstance @@ -240,6 +242,8 @@ fun App( ) } PublicChatProfileSync.ensureStarted() + ProfileUpdateSync.ensureStarted() + StatusSubscriptionCoordinator.ensureStarted() } startDestination = when { @@ -442,6 +446,8 @@ fun App( ) } PublicChatProfileSync.ensureStarted() + ProfileUpdateSync.ensureStarted() + StatusSubscriptionCoordinator.ensureStarted() scheduleSessionInstanceNetworkRefresh() } WebSocketManager.connect(forceRestart = true) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 2ad41b2..008dec0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -81,6 +81,7 @@ import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.compose.resources.stringResource import ru.fromchat.Logger import ru.fromchat.Res +import ru.fromchat.presence_recently import ru.fromchat.api.ApiClient import ru.fromchat.api.calls.CallStore 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.MessageRepository 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.download.SavableMessageImage import ru.fromchat.api.local.download.ensureFileDownloadedForSave @@ -408,7 +411,8 @@ fun ChatScreen( peerDeleted = peerIsDeleted(userId = userId, currentUserId = currentUserId) if (!peerDeleted) { runCatching { ApiClient.getProfileById(userId) }.onSuccess { profile -> - ProfileCache.put(profile) + ProfileCache.applyServerProfile(profile, force = false) + UserStatusStore.update(profile.id, profile.online, profile.lastSeen) peerDeleted = peerIsDeleted( userId = userId, currentUserId = currentUserId, @@ -422,6 +426,7 @@ fun ChatScreen( var showSuspendedSupportSheet by remember { mutableStateOf(false) } val statusConnecting = stringResource(Res.string.status_connecting) val statusUpdating = stringResource(Res.string.status_updating) + val presenceRecently = stringResource(Res.string.presence_recently) val chatGroupLabel = stringResource(Res.string.chat_group_label) val cdCall = stringResource(Res.string.cd_call) LaunchedEffect(currentTypingUsers) { @@ -440,9 +445,14 @@ fun ChatScreen( } } panelState.profileUserId != null -> { - val userStatus = statusMap[panelState.profileUserId] - val statusText = userStatus?.let { - formatLastSeen(it.online, it.lastSeen, lastSeenFormat) + val peerId = panelState.profileUserId!! + val userStatus = statusMap[peerId] + ?: 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() if (statusText.isNotEmpty()) { "presence:$statusText" @@ -461,16 +471,14 @@ fun ChatScreen( // Subscribe to other user's status when DM is visible; re-subscribe after reconnect LaunchedEffect(panelState.profileUserId, connectionStatus) { - val userId = panelState.profileUserId - if (userId != null) { - if (connectionStatus == ConnectionStatus.CONNECTED) { - runCatching { ApiClient.sendSubscribeStatus(userId) } - } - try { - kotlinx.coroutines.awaitCancellation() - } finally { - runCatching { ApiClient.sendUnsubscribeStatus(userId) } - } + val userId = panelState.profileUserId ?: return@LaunchedEffect + if (connectionStatus == ConnectionStatus.CONNECTED) { + StatusSubscriptionCoordinator.acquire(userId) + } + try { + kotlinx.coroutines.awaitCancellation() + } finally { + StatusSubscriptionCoordinator.release(userId) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt index 0a5a932..80a28da 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt @@ -182,7 +182,7 @@ fun ChatTopBarInner( Column( modifier = Modifier - .wrapContentWidth() + .fillMaxWidth() .padding(horizontal = 4.dp, vertical = 2.dp), horizontalAlignment = Alignment.Start, ) { @@ -208,6 +208,7 @@ fun ChatTopBarInner( } AnimatedContent( + modifier = Modifier.fillMaxWidth(), targetState = subtitleKey, transitionSpec = { (slideInVertically { it / 2 } + fadeIn()) togetherWith diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt index b9b6251..ed23961 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -79,11 +80,13 @@ fun DmProfileRoute( ?: stateSnapshot.title.takeIf { it.isNotBlank() } val initialProfilePictureUrl = stateSnapshot.titleAvatar?.profilePictureUrl - ProfileCache.mergePreview( - id = otherUserId, - displayName = initialDisplayName, - profilePicture = initialProfilePictureUrl, - ) + LaunchedEffect(otherUserId, initialDisplayName, initialProfilePictureUrl) { + ProfileCache.mergePreview( + id = otherUserId, + displayName = initialDisplayName, + profilePicture = initialProfilePictureUrl, + ) + } ProfileScreen( userId = otherUserId, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt index b1d0163..3e4e13c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt @@ -15,6 +15,7 @@ import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.api.ApiClient import ru.fromchat.api.local.cache.CacheContext 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.messages.ActiveDmChatTracker import ru.fromchat.api.local.db.store.MessageRepository @@ -91,6 +92,11 @@ class DmPanel( updateState { it.copy(typingUsers = users) } } } + coroutineScope.launch { + ProfileCache.revision.collect { + applyCachedPeerProfileOrReset() + } + } coroutineScope.launch(Dispatchers.Default) { AttachmentDownloadNotifier.progressFlow.collect { event -> if (event !is AttachmentDownloadProgress.Success || event.messageId <= 0) return@collect @@ -128,7 +134,8 @@ class DmPanel( } 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) if (displayName.isNotBlank()) { withContext(Dispatchers.Main) { @@ -152,6 +159,7 @@ class DmPanel( scope.launch(Dispatchers.Default) { val cached = ProfileCache.get(otherUserId) val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty() + cached?.let { UserStatusStore.update(it.id, it.online, it.lastSeen) } withContext(Dispatchers.Main) { if (displayName.isNotBlank()) { applyPeerTitle(displayName, cached?.profilePicture) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt index 20bf686..5b7fd67 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt @@ -59,6 +59,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res 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.MessageCacheStore import ru.fromchat.api.local.db.store.MessageRepository @@ -208,10 +209,14 @@ fun ChatsSearchScreen( val toUnsubscribe = subscribedDmUserIds - visibleIds toSubscribe.forEach { userId -> - runCatching { ApiClient.sendSubscribeStatus(userId) } + statusSubscriptionScope.launch { + StatusSubscriptionCoordinator.acquire(userId) + } } toUnsubscribe.forEach { userId -> - runCatching { ApiClient.sendUnsubscribeStatus(userId) } + statusSubscriptionScope.launch { + StatusSubscriptionCoordinator.release(userId) + } } subscribedDmUserIds = visibleIds } @@ -222,7 +227,7 @@ fun ChatsSearchScreen( hideIme() if (subscribedDmUserIds.isNotEmpty()) { statusSubscriptionScope.launch { - subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) } + subscribedDmUserIds.forEach { StatusSubscriptionCoordinator.release(it) } } } subscribedDmUserIds = emptySet() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt index 7cc0a1c..eeee630 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt @@ -109,6 +109,7 @@ import ru.fromchat.action_delete import ru.fromchat.action_mark_read import ru.fromchat.api.ApiClient import ru.fromchat.api.ChatListSync +import ru.fromchat.api.StatusSubscriptionCoordinator import ru.fromchat.api.calls.CallStore import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.cache.CacheContext @@ -715,12 +716,16 @@ fun ChatsTab( .collect { visibleIds -> if (connectionStatus == ConnectionStatus.CONNECTED) { visibleIds.forEach { userId -> - runCatching { ApiClient.sendSubscribeStatus(userId) } + statusSubscriptionScope.launch { + StatusSubscriptionCoordinator.acquire(userId) + } } } (subscribedDmUserIds - visibleIds).forEach { userId -> - runCatching { ApiClient.sendUnsubscribeStatus(userId) } + statusSubscriptionScope.launch { + StatusSubscriptionCoordinator.release(userId) + } } subscribedDmUserIds = visibleIds @@ -731,7 +736,7 @@ fun ChatsTab( onDispose { if (subscribedDmUserIds.isNotEmpty()) { statusSubscriptionScope.launch { - subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) } + subscribedDmUserIds.forEach { StatusSubscriptionCoordinator.release(it) } } } subscribedDmUserIds = emptySet() diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt index 5c2f89a..f4dac89 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt @@ -298,7 +298,8 @@ fun EditProfileScreen( lastSeen = ApiClient.user?.last_seen, createdAt = existing?.createdAt, ) - ProfileCache.put(updatedProfile) + ProfileCache.applyServerProfile(updatedProfile, force = true) + ApiClient.applyOwnProfile(updatedProfile) } showSnack(savedMessage) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 7f6248d..1fd8ebf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -88,6 +88,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -124,8 +125,11 @@ import ru.fromchat.Res import ru.fromchat.action_copy import ru.fromchat.action_edit import ru.fromchat.api.ApiClient +import ru.fromchat.api.StatusSubscriptionCoordinator import ru.fromchat.api.PublicChatProfileSync 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.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.UserStatus @@ -207,6 +211,17 @@ private fun hasDisplayableProfile( 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) @Composable fun ProfileScreen( @@ -253,12 +268,14 @@ fun ProfileScreen( val targetUsername = username?.trim()?.takeIf { it.isNotBlank() } val ownUserId = ApiClient.user?.id?.takeIf { it > 0 } - targetUserId?.let { id -> - ProfileCache.mergePreview( - id = id, - displayName = initialDisplayName?.takeIf { it.isNotBlank() }, - profilePicture = initialProfilePictureUrl?.takeIf { it.isNotBlank() }, - ) + LaunchedEffect(targetUserId, initialDisplayName, initialProfilePictureUrl) { + targetUserId?.let { id -> + ProfileCache.mergePreview( + id = id, + displayName = initialDisplayName?.takeIf { it.isNotBlank() }, + profilePicture = initialProfilePictureUrl?.takeIf { it.isNotBlank() }, + ) + } } val cacheLookupId = when { @@ -297,17 +314,27 @@ fun ProfileScreen( } 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 && ( (targetUserId == null && targetUsername == null) || targetUserId == ownUserId ) - LaunchedEffect(profileCacheRevision) { - if (!isViewingOwnProfile) return@LaunchedEffect - ownUserId?.let { ProfileCache.get(it) }?.let { cached -> - if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) { - state = latestUi.copy(profile = cached, isLoading = false, error = null) - } + LaunchedEffect(subscribedUserId, connectionStatus) { + val userId = subscribedUserId ?: return@LaunchedEffect + if (userId <= 0) return@LaunchedEffect + if (connectionStatus == ConnectionStatus.CONNECTED) { + 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 try { val refreshed = ApiClient.getOwnProfile() - ProfileCache.put(refreshed) + ProfileCache.applyServerProfile(refreshed, force = true) ApiClient.applyOwnProfile(refreshed) state = latestUi.copy(profile = refreshed, error = null) } catch (_: Exception) { @@ -332,7 +359,6 @@ fun ProfileScreen( } LaunchedEffect(lookupMode, lookupIdentifier) { - ProfileCache.hydrateFromDisk() resolveCachedProfile(targetUserId, targetUsername, ownUserId)?.let { cached -> if (hasDisplayableProfile(cached, initialDisplayName, ownUserId)) { state = latestUi.copy(profile = cached, isLoading = false, error = null) @@ -377,15 +403,18 @@ fun ProfileScreen( "ProfileScreen", "load success: mode=$lookupMode identifier=$lookupIdentifier -> " + "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}" ) - 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) { - ApiClient.applyOwnProfile(profile) + ApiClient.applyOwnProfile(displayProfile) } - state = latestUi.copy(profile = profile, isLoading = false, error = null) loadedSuccessfully = true true } @@ -474,7 +503,18 @@ fun ProfileScreen( val verifiedSupport = stringResource(Res.string.profile_verified_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)) { hasShownContent.value = true } @@ -1405,12 +1445,13 @@ private fun ProfileLoadedBody( if (showDetailsBio) { val position = listItemPositionInGroup(detailIndex, detailCount) detailIndex++ + val bioContent = resolvedProfile.bio.orEmpty() ListItem( headline = headlineBio, supportingSlot = { - ProfileBioMarkdown( - content = resolvedProfile.bio.orEmpty(), - ) + key(resolvedProfile.id, bioContent) { + ProfileBioMarkdown(content = bioContent) + } }, divider = true, position = position,