Fix more bugs

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-12 16:20:50 +03:00
Unverified
parent eb846f910a
commit e0ba6db41e
19 changed files with 451 additions and 55 deletions
@@ -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,
)
}
@@ -22,7 +22,7 @@ object DeferredStartupNetwork {
runCatching {
val profile = ApiClient.getOwnProfile()
ApiClient.applyOwnProfile(profile)
ProfileCache.put(profile)
ProfileCache.applyServerProfile(profile, force = false)
}
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 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()
@@ -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)
@@ -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)
@@ -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)
@@ -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<UserProfile?> =
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()
}
@@ -16,6 +16,7 @@ object UserStatusStore {
val status: StateFlow<Map<Int, UserStatus>> = _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))
@@ -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)
@@ -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)
}
}
@@ -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
@@ -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,
@@ -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)
@@ -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()
@@ -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()
@@ -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)
@@ -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,