mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Add avatars to chat list
This commit is contained in:
@@ -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]
|
||||||
|
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(
|
ListItem(
|
||||||
headlineContent = { Text(conv.displayName.ifBlank { "User ${conv.otherUserId}" }) },
|
leadingContent = {
|
||||||
supportingContent = {
|
ChatRowAvatar(
|
||||||
val preview = conv.lastMessagePreview ?: "Direct messages"
|
profilePictureUrl = avatarUrl,
|
||||||
Text(preview)
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
if (profile.username.isNotBlank()) {
|
||||||
Text(
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
text = "@${profile.username}",
|
Text(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = "@${profile.username}",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(36.dp))
|
Spacer(modifier = Modifier.height(36.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -418,21 +420,23 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Category(Modifier.padding(top = 16.dp), title = "Details") {
|
Category(Modifier.padding(top = 16.dp), title = "Details") {
|
||||||
ListItem(
|
if (profile.username.isNotBlank()) {
|
||||||
headline = "Username",
|
ListItem(
|
||||||
supportingText = profile.username,
|
headline = "Username",
|
||||||
leadingContent = {
|
supportingText = profile.username,
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
leadingContent = {
|
||||||
Icon(
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||||
imageVector = Icons.Filled.AlternateEmail,
|
Icon(
|
||||||
contentDescription = null
|
imageVector = Icons.Filled.AlternateEmail,
|
||||||
)
|
contentDescription = null
|
||||||
}
|
)
|
||||||
},
|
}
|
||||||
divider = true,
|
},
|
||||||
dividerColor = CategoryDefaults.dividerColor,
|
divider = true,
|
||||||
dividerThickness = CategoryDefaults.dividerThickness
|
dividerColor = CategoryDefaults.dividerColor,
|
||||||
)
|
dividerThickness = CategoryDefaults.dividerThickness
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!profile.bio.isNullOrBlank()) {
|
if (!profile.bio.isNullOrBlank()) {
|
||||||
ListItem(
|
ListItem(
|
||||||
|
|||||||
Reference in New Issue
Block a user