From 3d999ab51224d2ea4992bc1536fb10d43d15ff01 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 3 Apr 2026 19:04:47 +0300 Subject: [PATCH] Add avatars to chat list --- .../kotlin/ru/fromchat/api/ProfileCache.kt | 29 +++++ .../kotlin/ru/fromchat/ui/main/ChatsTab.kt | 115 +++++++++++++++++- .../ru/fromchat/ui/profile/ProfileScreen.kt | 46 +++---- 3 files changed, 163 insertions(+), 27 deletions(-) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt index 871012d..753bb1d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt @@ -42,6 +42,35 @@ object ProfileCache { * 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). */ + /** + * 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) { val uid = message.user_id if (uid <= 0) return diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt index dab25a3..a31c4cc 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt @@ -6,9 +6,14 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box 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.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -26,6 +31,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow import org.jetbrains.compose.resources.stringResource @@ -33,6 +39,7 @@ import ru.fromchat.Res import ru.fromchat.api.ApiClient import ru.fromchat.api.ConnectionStateStore import ru.fromchat.api.ConnectionStatus +import ru.fromchat.api.ProfileCache import ru.fromchat.api.db.CachedConversation import ru.fromchat.api.db.MessageCacheStore import ru.fromchat.chat_last_mesaage @@ -40,6 +47,32 @@ import ru.fromchat.public_chat import ru.fromchat.net.NetworkConnectivity import ru.fromchat.ui.ConnectingEllipsis 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) @Composable @@ -49,6 +82,10 @@ fun ChatsTab() { val connectionStatus by ConnectionStateStore.status.collectAsState() val online by NetworkConnectivity.isOnline.collectAsState(initial = true) var dmConversations by remember { mutableStateOf>(emptyList()) } + var publicListAvatarUserId by remember { mutableStateOf(null) } + var publicListAvatarUrl by remember { mutableStateOf(null) } + var publicListAvatarLabel by remember { mutableStateOf(null) } + var publicLastMessagePreview by remember { mutableStateOf(null) } LaunchedEffect(Unit) { // Load cached DM conversations first for instant offline display. @@ -56,11 +93,28 @@ fun ChatsTab() { 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. runCatching { ApiClient.getDmConversations() }.onSuccess { conversations -> runCatching { + conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } MessageCacheStore.replaceDmConversations(conversations) dmConversations = MessageCacheStore.loadCachedDmConversations() } @@ -130,11 +184,41 @@ fun ChatsTab() { ) } ) { innerPadding -> + val publicChatTitle = stringResource(Res.string.public_chat) LazyColumn(contentPadding = innerPadding) { item { + val publicUid = publicListAvatarUserId + val publicPic = publicListAvatarUrl + val publicLabel = publicListAvatarLabel?.takeIf { it.isNotBlank() } ?: publicChatTitle ListItem( - headlineContent = { Text(stringResource(Res.string.public_chat)) }, - supportingContent = { Text(stringResource(Res.string.chat_last_mesaage)) }, + leadingContent = { + 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 { navController.navigate("chats/publicChat") } @@ -143,11 +227,30 @@ fun ChatsTab() { items(dmConversations.size) { index -> val conv = dmConversations[index] + val cached = ProfileCache.get(conv.otherUserId) + val avatarUrl = cached?.profilePicture + val avatarLabel = cached?.displayName?.takeIf { it.isNotBlank() } + ?: cached?.username?.takeIf { it.isNotBlank() } + ?: conv.displayName.ifBlank { "User ${conv.otherUserId}" } + val preview = conv.lastMessagePreview ?: "Direct messages" ListItem( - headlineContent = { Text(conv.displayName.ifBlank { "User ${conv.otherUserId}" }) }, - supportingContent = { - val preview = conv.lastMessagePreview ?: "Direct messages" - Text(preview) + 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 = { if (conv.unreadCount > 0) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 5851a0f..62a8fe0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -299,12 +299,14 @@ fun ProfileScreen( userId = profile.id ) } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "@${profile.username}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (profile.username.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "@${profile.username}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } Spacer(modifier = Modifier.height(36.dp)) Row( @@ -418,21 +420,23 @@ fun ProfileScreen( } Category(Modifier.padding(top = 16.dp), title = "Details") { - ListItem( - headline = "Username", - supportingText = profile.username, - leadingContent = { - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { - Icon( - imageVector = Icons.Filled.AlternateEmail, - contentDescription = null - ) - } - }, - divider = true, - dividerColor = CategoryDefaults.dividerColor, - dividerThickness = CategoryDefaults.dividerThickness - ) + if (profile.username.isNotBlank()) { + ListItem( + headline = "Username", + supportingText = profile.username, + leadingContent = { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + Icon( + imageVector = Icons.Filled.AlternateEmail, + contentDescription = null + ) + } + }, + divider = true, + dividerColor = CategoryDefaults.dividerColor, + dividerThickness = CategoryDefaults.dividerThickness + ) + } if (!profile.bio.isNullOrBlank()) { ListItem(