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")
|
settings.remove("current_user_id")
|
||||||
token = null
|
token = null
|
||||||
user = null
|
user = null
|
||||||
|
runCatching { ProfileCache.clear() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
|
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ data class UserProfile(
|
|||||||
val verified: Boolean? = null,
|
val verified: Boolean? = null,
|
||||||
val suspended: Boolean? = null,
|
val suspended: Boolean? = null,
|
||||||
@SerialName("suspension_reason") val suspensionReason: String? = 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
|
@Serializable
|
||||||
|
|||||||
@@ -1,15 +1,108 @@
|
|||||||
package ru.fromchat.api
|
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
|
* In-memory profile cache with disk persistence. [get] reads a volatile snapshot (lock-free).
|
||||||
* and reload in the background.
|
* [put] copy-on-writes the map and schedules an async flush of the full list to settings.
|
||||||
*/
|
*/
|
||||||
object ProfileCache {
|
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) {
|
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.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.ProfileCache
|
||||||
import ru.fromchat.api.UpdateSyncManager
|
import ru.fromchat.api.UpdateSyncManager
|
||||||
import ru.fromchat.api.WebSocketManager
|
import ru.fromchat.api.WebSocketManager
|
||||||
import ru.fromchat.net.NetworkConnectivity
|
import ru.fromchat.net.NetworkConnectivity
|
||||||
@@ -53,6 +54,8 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
|||||||
// Load persisted token and user data
|
// Load persisted token and user data
|
||||||
ApiClient.loadPersistedData()
|
ApiClient.loadPersistedData()
|
||||||
|
|
||||||
|
runCatching { ProfileCache.hydrateFromDisk() }
|
||||||
|
|
||||||
// Initialize update sync state for the current user (if any)
|
// Initialize update sync state for the current user (if any)
|
||||||
runCatching {
|
runCatching {
|
||||||
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
|
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package ru.fromchat.ui
|
package ru.fromchat.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.AnimationSpec
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.Indication
|
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).
|
* 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.
|
* [indication] when non-null is used for the clickable (e.g. ripple); null = no indication.
|
||||||
* [clipShape] when non-null clips the scaled result.
|
* [clipShape] when non-null clips the scaled result.
|
||||||
|
* [animationSpec] drives the scale transition (default is a spring).
|
||||||
*/
|
*/
|
||||||
fun Modifier.scaleOnPress(
|
fun Modifier.scaleOnPress(
|
||||||
scale: Float = 0.96f,
|
scale: Float = 0.96f,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
indication: Indication? = null,
|
indication: Indication? = null,
|
||||||
clipShape: Shape? = null,
|
clipShape: Shape? = null,
|
||||||
interactionSource: MutableInteractionSource? = null
|
interactionSource: MutableInteractionSource? = null,
|
||||||
|
animationSpec: AnimationSpec<Float> = spring()
|
||||||
): Modifier = composed {
|
): Modifier = composed {
|
||||||
val source = interactionSource ?: remember { MutableInteractionSource() }
|
val source = interactionSource ?: remember { MutableInteractionSource() }
|
||||||
var pressed by remember { mutableStateOf(false) }
|
var pressed by remember { mutableStateOf(false) }
|
||||||
@@ -62,7 +65,7 @@ fun Modifier.scaleOnPress(
|
|||||||
|
|
||||||
val scaleValue by animateFloatAsState(
|
val scaleValue by animateFloatAsState(
|
||||||
targetValue = if (pressed) scale else 1f,
|
targetValue = if (pressed) scale else 1f,
|
||||||
animationSpec = spring(),
|
animationSpec = animationSpec,
|
||||||
label = "scaleOnPress"
|
label = "scaleOnPress"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ru.fromchat.ui.main
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -101,8 +102,9 @@ fun SettingsTab(
|
|||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(innerPadding)
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(innerPadding)
|
||||||
) {
|
) {
|
||||||
var materialYouSwitch by remember {
|
var materialYouSwitch by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package ru.fromchat.ui.profile
|
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.AnimatedVisibilityScope
|
||||||
import androidx.compose.animation.SharedTransitionScope
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.foundation.LocalIndication
|
import androidx.compose.foundation.LocalIndication
|
||||||
@@ -45,7 +47,11 @@ import androidx.compose.runtime.getValue
|
|||||||
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
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
@@ -76,6 +82,12 @@ private data class ProfileUiState(
|
|||||||
val linkStatus: String? = null
|
val linkStatus: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val profileActionCardPressSpring = spring<Float>(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow,
|
||||||
|
visibilityThreshold = 0.001f
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(
|
fun ProfileScreen(
|
||||||
@@ -95,10 +107,11 @@ fun ProfileScreen(
|
|||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val hideBackButton = navController.currentDestination?.route == "chat"
|
val hideBackButton = navController.currentDestination?.route == "chat"
|
||||||
val targetUserId = userId.takeIf { it != null && it > 0 }
|
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) {
|
var state by remember(targetUserId, ownUserId) {
|
||||||
val cached = targetUserId?.let { ProfileCache.get(it) }
|
val cached = cacheLookupId?.let { ProfileCache.get(it) }
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
ProfileUiState(
|
ProfileUiState(
|
||||||
profile = cached,
|
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 {
|
runCatching {
|
||||||
if (targetUserId == null) {
|
if (targetUserId == null) {
|
||||||
ApiClient.getOwnProfile()
|
ApiClient.getOwnProfile()
|
||||||
@@ -117,14 +134,22 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
}.onSuccess { profile ->
|
}.onSuccess { profile ->
|
||||||
ProfileCache.put(profile)
|
ProfileCache.put(profile)
|
||||||
state = state.copy(profile = profile, isLoading = false, error = null)
|
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
||||||
}.onFailure {
|
}.onFailure { err ->
|
||||||
state = state.copy(
|
val fallbackId = targetUserId ?: ownUserId
|
||||||
error = if (state.profile == null) it.message ?: "Unable to load profile" else null,
|
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
|
isLoading = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
|
|
||||||
@@ -154,9 +179,7 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize()
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
) {
|
) {
|
||||||
val errorMessage = state.error
|
val errorMessage = state.error
|
||||||
val profile = state.profile
|
val profile = state.profile
|
||||||
@@ -171,9 +194,9 @@ fun ProfileScreen(
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
,
|
.padding(innerPadding),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
@@ -267,7 +290,7 @@ fun ProfileScreen(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(36.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -292,7 +315,8 @@ fun ProfileScreen(
|
|||||||
.scaleOnPress(
|
.scaleOnPress(
|
||||||
scale = 0.90f,
|
scale = 0.90f,
|
||||||
interactionSource = primarySource,
|
interactionSource = primarySource,
|
||||||
clipShape = MaterialTheme.shapes.extraLarge
|
clipShape = MaterialTheme.shapes.extraLarge,
|
||||||
|
animationSpec = profileActionCardPressSpring
|
||||||
),
|
),
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
||||||
@@ -331,7 +355,8 @@ fun ProfileScreen(
|
|||||||
.scaleOnPress(
|
.scaleOnPress(
|
||||||
scale = 0.90f,
|
scale = 0.90f,
|
||||||
interactionSource = linkSource,
|
interactionSource = linkSource,
|
||||||
clipShape = MaterialTheme.shapes.extraLarge
|
clipShape = MaterialTheme.shapes.extraLarge,
|
||||||
|
animationSpec = profileActionCardPressSpring
|
||||||
),
|
),
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
|||||||
Reference in New Issue
Block a user