Add avatars to chat list

This commit is contained in:
2026-04-03 19:04:47 +03:00
Unverified
parent 8cdf5b2167
commit 3d999ab512
3 changed files with 163 additions and 27 deletions
@@ -42,6 +42,35 @@ object ProfileCache {
* Fills or refreshes a lightweight profile from a public chat [Message] (username, avatar URL). * 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). * Skips when a full API profile is already stored ([UserProfile.isClientPreviewOnly] is false).
*/ */
/**
* Seeds or refreshes a lightweight profile from a DM conversations list [User].
* Skips when a full `/user/...` profile is already cached.
*/
fun mergeFromDmUser(user: User) {
val existing = get(user.id)
if (existing != null && !existing.isClientPreviewOnly) return
put(
UserProfile(
id = user.id,
username = user.username.ifBlank { existing?.username.orEmpty() },
displayName = existing?.displayName?.takeIf { it.isNotBlank() }
?: user.username.takeIf { it.isNotBlank() }
?: existing?.username.orEmpty(),
profilePicture = user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture,
bio = existing?.bio,
online = user.online,
lastSeen = user.last_seen.takeIf { it.isNotBlank() } ?: existing?.lastSeen,
createdAt = user.created_at.takeIf { it.isNotBlank() } ?: existing?.createdAt,
verified = existing?.verified,
suspended = existing?.suspended,
suspensionReason = existing?.suspensionReason,
deleted = existing?.deleted,
isClientPreviewOnly = true
)
)
}
fun mergePreviewFromPublicMessage(message: Message) { fun mergePreviewFromPublicMessage(message: Message) {
val uid = message.user_id val uid = message.user_id
if (uid <= 0) return if (uid <= 0) return
@@ -6,9 +6,14 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -26,6 +31,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -33,6 +39,7 @@ import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ConnectionStateStore import ru.fromchat.api.ConnectionStateStore
import ru.fromchat.api.ConnectionStatus import ru.fromchat.api.ConnectionStatus
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.db.CachedConversation import ru.fromchat.api.db.CachedConversation
import ru.fromchat.api.db.MessageCacheStore import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.chat_last_mesaage import ru.fromchat.chat_last_mesaage
@@ -40,6 +47,32 @@ import ru.fromchat.public_chat
import ru.fromchat.net.NetworkConnectivity import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.ConnectingEllipsis import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar
@Composable
private fun ChatRowAvatar(
profilePictureUrl: String?,
displayNameForInitials: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val interaction = remember { MutableInteractionSource() }
Box(
modifier
.size(40.dp)
.clickable(
interactionSource = interaction,
indication = LocalIndication.current,
onClick = onClick
)
) {
Avatar(
profilePictureUrl = profilePictureUrl,
displayName = displayNameForInitials,
modifier = Modifier.fillMaxSize()
)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -49,6 +82,10 @@ fun ChatsTab() {
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true) val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) } var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
var publicListAvatarUserId by remember { mutableStateOf<Int?>(null) }
var publicListAvatarUrl by remember { mutableStateOf<String?>(null) }
var publicListAvatarLabel by remember { mutableStateOf<String?>(null) }
var publicLastMessagePreview by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Load cached DM conversations first for instant offline display. // Load cached DM conversations first for instant offline display.
@@ -56,11 +93,28 @@ fun ChatsTab() {
dmConversations = MessageCacheStore.loadCachedDmConversations() dmConversations = MessageCacheStore.loadCachedDmConversations()
} }
runCatching {
val last = MessageCacheStore.loadRecentPublicMessages(1).lastOrNull()
publicLastMessagePreview = last?.content?.trim()?.takeIf { it.isNotEmpty() }
if (last != null && last.user_id > 0) {
ProfileCache.mergePreviewFromPublicMessage(last)
publicListAvatarUserId = last.user_id
publicListAvatarUrl = last.profile_picture
publicListAvatarLabel = last.username.takeIf { it.isNotBlank() }
?: ProfileCache.get(last.user_id)?.displayName?.takeIf { it.isNotBlank() }
} else {
publicListAvatarUserId = null
publicListAvatarUrl = null
publicListAvatarLabel = null
}
}
// Then refresh from network and update cache + state. // Then refresh from network and update cache + state.
runCatching { runCatching {
ApiClient.getDmConversations() ApiClient.getDmConversations()
}.onSuccess { conversations -> }.onSuccess { conversations ->
runCatching { runCatching {
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
MessageCacheStore.replaceDmConversations(conversations) MessageCacheStore.replaceDmConversations(conversations)
dmConversations = MessageCacheStore.loadCachedDmConversations() dmConversations = MessageCacheStore.loadCachedDmConversations()
} }
@@ -130,11 +184,41 @@ fun ChatsTab() {
) )
} }
) { innerPadding -> ) { innerPadding ->
val publicChatTitle = stringResource(Res.string.public_chat)
LazyColumn(contentPadding = innerPadding) { LazyColumn(contentPadding = innerPadding) {
item { item {
val publicUid = publicListAvatarUserId
val publicPic = publicListAvatarUrl
val publicLabel = publicListAvatarLabel?.takeIf { it.isNotBlank() } ?: publicChatTitle
ListItem( ListItem(
headlineContent = { Text(stringResource(Res.string.public_chat)) }, leadingContent = {
supportingContent = { Text(stringResource(Res.string.chat_last_mesaage)) }, ChatRowAvatar(
profilePictureUrl = publicPic,
displayNameForInitials = publicLabel,
onClick = {
when {
publicUid != null && publicUid > 0 ->
navController.navigate("profile/$publicUid")
else -> navController.navigate("chats/publicChat")
}
}
)
},
headlineContent = {
Text(
text = publicChatTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = {
val preview = publicLastMessagePreview
Text(
text = preview ?: stringResource(Res.string.chat_last_mesaage),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
},
modifier = Modifier.clickable { modifier = Modifier.clickable {
navController.navigate("chats/publicChat") navController.navigate("chats/publicChat")
} }
@@ -143,11 +227,30 @@ fun ChatsTab() {
items(dmConversations.size) { index -> items(dmConversations.size) { index ->
val conv = dmConversations[index] val conv = dmConversations[index]
ListItem( val cached = ProfileCache.get(conv.otherUserId)
headlineContent = { Text(conv.displayName.ifBlank { "User ${conv.otherUserId}" }) }, val avatarUrl = cached?.profilePicture
supportingContent = { val avatarLabel = cached?.displayName?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() }
?: conv.displayName.ifBlank { "User ${conv.otherUserId}" }
val preview = conv.lastMessagePreview ?: "Direct messages" val preview = conv.lastMessagePreview ?: "Direct messages"
Text(preview) ListItem(
leadingContent = {
ChatRowAvatar(
profilePictureUrl = avatarUrl,
displayNameForInitials = avatarLabel,
onClick = {
if (conv.otherUserId != 0) {
navController.navigate("profile/${conv.otherUserId}")
}
}
)
},
headlineContent = {
Text(
text = preview,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}, },
trailingContent = { trailingContent = {
if (conv.unreadCount > 0) { if (conv.unreadCount > 0) {
@@ -299,12 +299,14 @@ fun ProfileScreen(
userId = profile.id userId = profile.id
) )
} }
if (profile.username.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "@${profile.username}", text = "@${profile.username}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
}
Spacer(modifier = Modifier.height(36.dp)) Spacer(modifier = Modifier.height(36.dp))
Row( Row(
@@ -418,6 +420,7 @@ fun ProfileScreen(
} }
Category(Modifier.padding(top = 16.dp), title = "Details") { Category(Modifier.padding(top = 16.dp), title = "Details") {
if (profile.username.isNotBlank()) {
ListItem( ListItem(
headline = "Username", headline = "Username",
supportingText = profile.username, supportingText = profile.username,
@@ -433,6 +436,7 @@ fun ProfileScreen(
dividerColor = CategoryDefaults.dividerColor, dividerColor = CategoryDefaults.dividerColor,
dividerThickness = CategoryDefaults.dividerThickness dividerThickness = CategoryDefaults.dividerThickness
) )
}
if (!profile.bio.isNullOrBlank()) { if (!profile.bio.isNullOrBlank()) {
ListItem( ListItem(