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.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) { StatusSubscriptionCoordinator.acquire(userId)
runCatching { ApiClient.sendSubscribeStatus(userId) } }
} try {
try { kotlinx.coroutines.awaitCancellation()
kotlinx.coroutines.awaitCancellation() } finally {
} finally { StatusSubscriptionCoordinator.release(userId)
runCatching { ApiClient.sendUnsubscribeStatus(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
ProfileCache.mergePreview( LaunchedEffect(otherUserId, initialDisplayName, initialProfilePictureUrl) {
id = otherUserId, ProfileCache.mergePreview(
displayName = initialDisplayName, id = otherUserId,
profilePicture = initialProfilePictureUrl, displayName = initialDisplayName,
) 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,12 +268,14 @@ 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 }
targetUserId?.let { id -> LaunchedEffect(targetUserId, initialDisplayName, initialProfilePictureUrl) {
ProfileCache.mergePreview( targetUserId?.let { id ->
id = id, ProfileCache.mergePreview(
displayName = initialDisplayName?.takeIf { it.isNotBlank() }, id = id,
profilePicture = initialProfilePictureUrl?.takeIf { it.isNotBlank() }, displayName = initialDisplayName?.takeIf { it.isNotBlank() },
) profilePicture = initialProfilePictureUrl?.takeIf { it.isNotBlank() },
)
}
} }
val cacheLookupId = when { val cacheLookupId = when {
@@ -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,