mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
More caching and UI fixes
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -445,6 +445,7 @@ object ApiClient {
|
||||
settings.remove("current_user_id")
|
||||
token = null
|
||||
user = null
|
||||
runCatching { ProfileCache.clear() }
|
||||
}
|
||||
|
||||
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
|
||||
|
||||
@@ -48,7 +48,12 @@ data class UserProfile(
|
||||
val verified: Boolean? = null,
|
||||
val suspended: Boolean? = null,
|
||||
@SerialName("suspension_reason") val suspensionReason: String? = null,
|
||||
val deleted: Boolean? = null
|
||||
val deleted: Boolean? = null,
|
||||
/**
|
||||
* Client-only: true when this row was built from public-chat message metadata, not a full
|
||||
* `/user/...` response. The backend does not send this key.
|
||||
*/
|
||||
@SerialName("client_preview_only") val isClientPreviewOnly: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,15 +1,108 @@
|
||||
package ru.fromchat.api
|
||||
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* In-memory cache for user profiles. Used to show profile immediately from cache
|
||||
* and reload in the background.
|
||||
* In-memory profile cache with disk persistence. [get] reads a volatile snapshot (lock-free).
|
||||
* [put] copy-on-writes the map and schedules an async flush of the full list to settings.
|
||||
*/
|
||||
object ProfileCache {
|
||||
private val cache = mutableMapOf<Int, UserProfile>()
|
||||
private const val SETTINGS_KEY = "profile_cache_profiles_v1"
|
||||
|
||||
fun get(userId: Int): UserProfile? = cache[userId]
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var profiles: Map<Int, UserProfile> = emptyMap()
|
||||
|
||||
private val persistMutex = Mutex()
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
fun get(userId: Int): UserProfile? = profiles[userId]
|
||||
|
||||
fun put(profile: UserProfile) {
|
||||
cache[profile.id] = profile
|
||||
val cur = profiles
|
||||
profiles = cur + (profile.id to profile)
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills or refreshes a lightweight profile from a public chat [Message] (username, avatar URL).
|
||||
* Skips when a full API profile is already stored ([UserProfile.isClientPreviewOnly] is false).
|
||||
*/
|
||||
fun mergePreviewFromPublicMessage(message: Message) {
|
||||
val uid = message.user_id
|
||||
if (uid <= 0) return
|
||||
val existing = get(uid)
|
||||
if (existing != null && !existing.isClientPreviewOnly) return
|
||||
|
||||
val uname = message.username.ifBlank { existing?.username ?: return }
|
||||
val display = existing?.displayName?.takeIf { it.isNotBlank() } ?: uname
|
||||
val pic = message.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture
|
||||
|
||||
put(
|
||||
UserProfile(
|
||||
id = uid,
|
||||
username = uname,
|
||||
displayName = display,
|
||||
profilePicture = pic,
|
||||
bio = existing?.bio,
|
||||
online = existing?.online ?: false,
|
||||
lastSeen = existing?.lastSeen,
|
||||
createdAt = existing?.createdAt,
|
||||
verified = existing?.verified,
|
||||
suspended = existing?.suspended,
|
||||
suspensionReason = existing?.suspensionReason,
|
||||
deleted = existing?.deleted,
|
||||
isClientPreviewOnly = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load stored profiles into memory (merged: existing runtime entries win on id clash).
|
||||
* Call after [ApiClient.loadPersistedData].
|
||||
*/
|
||||
suspend fun hydrateFromDisk() {
|
||||
persistMutex.withLock {
|
||||
val raw = settings.getString(SETTINGS_KEY, "").ifBlank { return }
|
||||
runCatching {
|
||||
val list = json.decodeFromString(ListSerializer(UserProfile.serializer()), raw)
|
||||
val fromDisk = list.associateBy { it.id }
|
||||
profiles = fromDisk + profiles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
persistMutex.withLock {
|
||||
profiles = emptyMap()
|
||||
settings.putString(SETTINGS_KEY, "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun schedulePersist() {
|
||||
ioScope.launch {
|
||||
persistMutex.withLock {
|
||||
val snap = profiles
|
||||
val blob = json.encodeToString(
|
||||
ListSerializer(UserProfile.serializer()),
|
||||
snap.values.toList()
|
||||
)
|
||||
settings.putString(SETTINGS_KEY, blob)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.ProfileCache
|
||||
import ru.fromchat.api.UpdateSyncManager
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.net.NetworkConnectivity
|
||||
@@ -53,6 +54,8 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
||||
// Load persisted token and user data
|
||||
ApiClient.loadPersistedData()
|
||||
|
||||
runCatching { ProfileCache.hydrateFromDisk() }
|
||||
|
||||
// Initialize update sync state for the current user (if any)
|
||||
runCatching {
|
||||
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.fromchat.ui
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.Indication
|
||||
@@ -29,13 +30,15 @@ import kotlinx.coroutines.launch
|
||||
* When [interactionSource] is non-null: uses it for scale only (caller adds clickable on child so ripple scales with content).
|
||||
* [indication] when non-null is used for the clickable (e.g. ripple); null = no indication.
|
||||
* [clipShape] when non-null clips the scaled result.
|
||||
* [animationSpec] drives the scale transition (default is a spring).
|
||||
*/
|
||||
fun Modifier.scaleOnPress(
|
||||
scale: Float = 0.96f,
|
||||
onClick: (() -> Unit)? = null,
|
||||
indication: Indication? = null,
|
||||
clipShape: Shape? = null,
|
||||
interactionSource: MutableInteractionSource? = null
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
animationSpec: AnimationSpec<Float> = spring()
|
||||
): Modifier = composed {
|
||||
val source = interactionSource ?: remember { MutableInteractionSource() }
|
||||
var pressed by remember { mutableStateOf(false) }
|
||||
@@ -62,7 +65,7 @@ fun Modifier.scaleOnPress(
|
||||
|
||||
val scaleValue by animateFloatAsState(
|
||||
targetValue = if (pressed) scale else 1f,
|
||||
animationSpec = spring(),
|
||||
animationSpec = animationSpec,
|
||||
label = "scaleOnPress"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package ru.fromchat.ui.main
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -101,8 +102,9 @@ fun SettingsTab(
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
var materialYouSwitch by remember {
|
||||
mutableStateOf(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ru.fromchat.ui.profile
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
@@ -45,7 +47,11 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
@@ -76,6 +82,12 @@ private data class ProfileUiState(
|
||||
val linkStatus: String? = null
|
||||
)
|
||||
|
||||
private val profileActionCardPressSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
visibilityThreshold = 0.001f
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
@@ -95,10 +107,11 @@ fun ProfileScreen(
|
||||
val navController = LocalNavController.current
|
||||
val hideBackButton = navController.currentDestination?.route == "chat"
|
||||
val targetUserId = userId.takeIf { it != null && it > 0 }
|
||||
val fetchKey = targetUserId ?: 0
|
||||
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 }
|
||||
val cacheLookupId = targetUserId ?: ownUserId
|
||||
|
||||
var state by remember(fetchKey) {
|
||||
val cached = targetUserId?.let { ProfileCache.get(it) }
|
||||
var state by remember(targetUserId, ownUserId) {
|
||||
val cached = cacheLookupId?.let { ProfileCache.get(it) }
|
||||
mutableStateOf(
|
||||
ProfileUiState(
|
||||
profile = cached,
|
||||
@@ -108,7 +121,11 @@ fun ProfileScreen(
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(fetchKey) {
|
||||
val latestUi by rememberUpdatedState(state)
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(cacheLookupId, targetUserId, lifecycleOwner) {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
runCatching {
|
||||
if (targetUserId == null) {
|
||||
ApiClient.getOwnProfile()
|
||||
@@ -117,14 +134,22 @@ fun ProfileScreen(
|
||||
}
|
||||
}.onSuccess { profile ->
|
||||
ProfileCache.put(profile)
|
||||
state = state.copy(profile = profile, isLoading = false, error = null)
|
||||
}.onFailure {
|
||||
state = state.copy(
|
||||
error = if (state.profile == null) it.message ?: "Unable to load profile" else null,
|
||||
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
||||
}.onFailure { err ->
|
||||
val fallbackId = targetUserId ?: ownUserId
|
||||
val fallback = fallbackId?.let { ProfileCache.get(it) }
|
||||
state = latestUi.copy(
|
||||
error = if (latestUi.profile == null && fallback == null) {
|
||||
err.message ?: "Unable to load profile"
|
||||
} else {
|
||||
null
|
||||
},
|
||||
profile = latestUi.profile ?: fallback,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
@@ -154,9 +179,7 @@ fun ProfileScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val errorMessage = state.error
|
||||
val profile = state.profile
|
||||
@@ -171,9 +194,9 @@ fun ProfileScreen(
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
,
|
||||
.padding(innerPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
when {
|
||||
@@ -267,7 +290,7 @@ fun ProfileScreen(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -292,7 +315,8 @@ fun ProfileScreen(
|
||||
.scaleOnPress(
|
||||
scale = 0.90f,
|
||||
interactionSource = primarySource,
|
||||
clipShape = MaterialTheme.shapes.extraLarge
|
||||
clipShape = MaterialTheme.shapes.extraLarge,
|
||||
animationSpec = profileActionCardPressSpring
|
||||
),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
||||
@@ -331,7 +355,8 @@ fun ProfileScreen(
|
||||
.scaleOnPress(
|
||||
scale = 0.90f,
|
||||
interactionSource = linkSource,
|
||||
clipShape = MaterialTheme.shapes.extraLarge
|
||||
clipShape = MaterialTheme.shapes.extraLarge,
|
||||
animationSpec = profileActionCardPressSpring
|
||||
),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
||||
|
||||
Reference in New Issue
Block a user