From 5fb783c0f58eb58b06174ff4b0b8dc3d6c35f1a8 Mon Sep 17 00:00:00 2001 From: hayk-space Date: Tue, 20 Jan 2026 05:08:55 +0400 Subject: [PATCH] Done integration with media api Implemented items showing, clicks and most of the group media functionality. Needs some UX improvements. --- .../views/chat/group/GroupChatInfoTabs.kt | 578 +++++++++++++++++- .../views/chat/group/GroupChatInfoView.kt | 147 ++++- .../commonMain/resources/MR/base/strings.xml | 2 + 3 files changed, 696 insertions(+), 31 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoTabs.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoTabs.kt index 96ab604f4f..25e9790883 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoTabs.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoTabs.kt @@ -8,23 +8,34 @@ import SectionView import androidx.compose.animation.* import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* +import chat.simplex.common.platform.base64ToBitmap +import kotlinx.datetime.* +import java.text.SimpleDateFormat +import java.util.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.chatModel import chat.simplex.common.ui.theme.* @@ -38,14 +49,22 @@ import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.chatlist.UnreadBadge import chat.simplex.common.views.chatlist.unreadCountStr import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.views.usersettings.SettingsActionItemWithContent +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.ProviderMedia +import androidx.compose.ui.platform.LocalUriHandler +import java.io.File +import java.net.URI +import kotlinx.coroutines.runBlocking import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.launch +import kotlin.math.sign enum class GroupInfoTab { Members, @@ -70,7 +89,9 @@ fun LazyListScope.GroupChatInfoTabs( chat: Chat, groupLink: GroupLink?, manageGroupLink: () -> Unit, - openMemberSupport: () -> Unit + openMemberSupport: () -> Unit, + isLoadingTab: Boolean = false, + onLoadMoreTabItems: (() -> Unit)? = null ) { item { @@ -134,7 +155,10 @@ fun LazyListScope.GroupChatInfoTabs( GroupInfoTab.Voice -> { ContentItemsTab( filteredChatItems = filteredChatItems, - scrollToItemId = scrollToItemId + selectedTab = selectedTab.value, + chat = chat, + isLoading = isLoadingTab, + onLoadMore = onLoadMoreTabItems ) } } @@ -252,9 +276,23 @@ private fun LazyListScope.MembersTabContent( private fun LazyListScope.ContentItemsTab( filteredChatItems: State>, - scrollToItemId: MutableState + selectedTab: GroupInfoTab, + chat: Chat, + isLoading: Boolean = false, + onLoadMore: (() -> Unit)? = null ) { - if (filteredChatItems.value.isEmpty()) { + if (isLoading && filteredChatItems.value.isEmpty()) { + item { + Box( + Modifier + .fillMaxWidth() + .padding(vertical = DEFAULT_PADDING * 2), + contentAlignment = Alignment.Center + ) { + ProgressIndicator() + } + } + } else if (filteredChatItems.value.isEmpty()) { item { Box( Modifier @@ -264,20 +302,66 @@ private fun LazyListScope.ContentItemsTab( ) { //TODO: this is just a temporary UI, if no item, tab will not be shown Text( - "No items found", + stringResource(MR.strings.no_items_found), color = MaterialTheme.colors.secondary ) } } } else { - items(filteredChatItems.value, key = { it.id }) { chatItem -> - Divider() - SectionItemView( - click = { scrollToItemId.value = chatItem.id }, - minHeight = 54.dp, - padding = PaddingValues(horizontal = DEFAULT_PADDING) - ) { - GroupChatItemRow(chatItem) + when (selectedTab) { + GroupInfoTab.Images, GroupInfoTab.Videos -> { + MediaGridTabContent( + chatItems = filteredChatItems.value, + isVideo = selectedTab == GroupInfoTab.Videos + ) + } + GroupInfoTab.Files -> { + MediaListTabContent( + chatItems = filteredChatItems.value, + type = MediaListType.Files, + chat = chat + ) + } + GroupInfoTab.Links -> { + MediaListTabContent( + chatItems = filteredChatItems.value, + type = MediaListType.Links, + chat = chat + ) + } + GroupInfoTab.Voice -> { + MediaListTabContent( + chatItems = filteredChatItems.value, + type = MediaListType.Voice, + chat = chat + ) + } + else -> {} + } + + // Load more button at the end + if (onLoadMore != null && filteredChatItems.value.isNotEmpty()) { + item { + Box( + Modifier + .fillMaxWidth() + .padding(vertical = DEFAULT_PADDING), + contentAlignment = Alignment.Center + ) { + if (isLoading) { + ProgressIndicator() + } else { + TextButton( + onClick = { onLoadMore() }, + modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) + ) { + Text( + stringResource(MR.strings.load_more), + color = MaterialTheme.colors.primary + ) + } + } + } } } } @@ -297,6 +381,474 @@ private fun tabLabel(tab: GroupInfoTab): Pair { } +// Helper enum for media list types +private enum class MediaListType { + Files, Links, Voice +} + +// Data class for date grouping +private data class DateGroup( + val monthYear: String, // e.g., "December 2025" + val items: List +) + +// Group items by month and year +private fun groupItemsByDate(items: List): List { + val grouped = items.groupBy { item -> + val tz = TimeZone.currentSystemDefault() + val date = item.meta.itemTs.toLocalDateTime(tz).date + val month = date.monthNumber + val year = date.year + + // Format: "December 2025" + val monthNames = arrayOf( + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ) + "${monthNames[month - 1]} $year" + } + + // Sort by date (newest first) + return grouped.map { (monthYear, items) -> + DateGroup(monthYear, items.sortedByDescending { it.meta.itemTs }) + }.sortedByDescending { group -> + val tz = TimeZone.currentSystemDefault() + group.items.firstOrNull()?.meta?.itemTs?.toLocalDateTime(tz)?.date + } +} + +// Provider for gallery in group info tabs +private fun providerForGroupGallery( + chatItems: List, + cItemId: Long +): ImageGalleryProvider { + fun canShowMedia(item: ChatItem): Boolean = + (item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && + (item.file?.loaded == true && (getLoadedFilePath(item.file) != null || chatModel.connectedToRemote())) + + fun item(skipInternalIndex: Int, initialChatId: Long): Pair? { + var processedInternalIndex = -skipInternalIndex.sign + val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId } + // The first was deleted or moderated + if (indexOfFirst == -1) return null + for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) { + val item = chatItems[chatItemsIndex] + if (canShowMedia(item)) { + processedInternalIndex += skipInternalIndex.sign + } + if (processedInternalIndex == skipInternalIndex) { + return chatItemsIndex to item + } + } + return null + } + + // Pager has a bug with overflowing when total pages is around Int.MAX_VALUE. Using smaller value + var initialIndex = 10000 / 2 + var initialChatId = cItemId + return object: ImageGalleryProvider { + override val initialIndex: Int = initialIndex + override val totalMediaSize = mutableStateOf(10000) + override fun getMedia(index: Int): ProviderMedia? { + val internalIndex = initialIndex - index + val item = item(internalIndex, initialChatId)?.second ?: return null + return when (item.content.msgContent) { + is MsgContent.MCImage -> { + val res = runBlocking { getLoadedImage(item.file) } + val filePath = getLoadedFilePath(item.file) + if (res != null && filePath != null) { + val (imageBitmap: ImageBitmap, data: ByteArray) = res + ProviderMedia.Image(data, imageBitmap) + } else null + } + is MsgContent.MCVideo -> { + val filePath = if (chatModel.connectedToRemote() && item.file?.loaded == true) getAppFilePath(item.file.fileName) else getLoadedFilePath(item.file) + if (filePath != null) { + val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) + ProviderMedia.Video(uri, item.file?.fileSource, (item.content.msgContent as MsgContent.MCVideo).image) + } else null + } + else -> null + } + } + + override fun currentPageChanged(index: Int) { + val internalIndex = initialIndex - index + val item = item(internalIndex, initialChatId) ?: return + initialIndex = index + initialChatId = item.second.id + } + + override fun scrollToStart() { + initialIndex = 0 + initialChatId = chatItems.firstOrNull { canShowMedia(it) }?.id ?: return + } + + override fun onDismiss(index: Int) { + // No-op for group info tabs + } + } +} + +// Grid layout for images and videos +private fun LazyListScope.MediaGridTabContent( + chatItems: List, + isVideo: Boolean +) { + val groupedItems = groupItemsByDate(chatItems) + + groupedItems.forEach { group -> + // Date header + item { + SectionView(title = group.monthYear) {} + } + + // Grid items (4 columns) + val columns = 4 + val rows = (group.items.size + columns - 1) / columns + + repeat(rows) { rowIndex -> + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING_HALF), + horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF) + ) { + repeat(columns) { colIndex -> + val itemIndex = rowIndex * columns + colIndex + val chatItem = if (itemIndex < group.items.size) group.items[itemIndex] else null + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .padding(DEFAULT_PADDING_HALF) + .clip(RoundedCornerShape(4.dp)) + .clickable { + chatItem?.let { item -> + // Check if file is loaded before opening gallery + if (item.file?.loaded == true && (getLoadedFilePath(item.file) != null || chatModel.connectedToRemote())) { + val provider = providerForGroupGallery(chatItems, item.id) + ModalManager.fullscreen.showCustomModal(animated = false) { close -> + ImageFullScreenView({ provider }, close) + } + } + // If not loaded, do nothing + } + } + ) { + if (chatItem != null) { + MediaThumbnail(chatItem, isVideo) + } + } + } + } + } + } + } +} + +// Thumbnail for images/videos +@Composable +private fun MediaThumbnail(chatItem: ChatItem, isVideo: Boolean) { + val content = chatItem.content.msgContent + val image = when (content) { + is MsgContent.MCImage -> content.image + is MsgContent.MCVideo -> content.image + else -> null + } + + Box(modifier = Modifier.fillMaxSize()) { + if (image != null) { + Image( + bitmap = base64ToBitmap(image), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.surface), + contentAlignment = Alignment.Center + ) { + Icon( + painterResource(if (isVideo) MR.images.ic_videocam else MR.images.ic_image), + null, + tint = MaterialTheme.colors.secondary + ) + } + } + + // Video play icon overlay + if (isVideo || content is MsgContent.MCVideo) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center + ) { + Icon( + painterResource(MR.images.ic_play_arrow_filled), + null, + modifier = Modifier.size(32.dp), + tint = Color.White + ) + } + } + } +} + +// List layout for files, links, and voice +private fun LazyListScope.MediaListTabContent( + chatItems: List, + type: MediaListType, + chat: Chat +) { + val groupedItems = groupItemsByDate(chatItems) + + groupedItems.forEach { group -> + // Date header + item { + SectionView(title = group.monthYear) {} + } + + // List items + items(group.items, key = { it.id }) { chatItem -> + Divider() + when (type) { + MediaListType.Files -> FileItemRow(chatItem, chat) + MediaListType.Links -> LinkItemRow(chatItem) + MediaListType.Voice -> VoiceItemRow(chatItem, chat) + } + } + } +} + +// File item row - reuses CIFileView component +@Composable +private fun FileItemRow(chatItem: ChatItem, chat: Chat) { + val file = chatItem.file + val senderName = getSenderName(chatItem) + val dateTime = formatDateTimeForList(chatItem.meta.itemTs) + val scope = rememberCoroutineScope() + val user = chatModel.currentUser.value + + Column( + Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) + ) { + // Sender name and date row + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + senderName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Medium + ) + Text( + dateTime, + fontSize = 11.sp, + color = MaterialTheme.colors.secondary + ) + } + Spacer(Modifier.height(8.dp)) + // File component - using CIFileView which handles all file actions + CIFileView( + file = file, + edited = chatItem.meta.itemEdited, + showMenu = remember { mutableStateOf(false) }, + smallView = false, + receiveFile = { fileId -> + if (user != null) { + scope.launch { + withBGApi { + chatModel.controller.receiveFile(chat.remoteHostId, user, fileId) + } + } + } + } + ) + } +} + +// Link item row +@Composable +private fun LinkItemRow(chatItem: ChatItem) { + val content = chatItem.content.msgContent as? MsgContent.MCLink + ?: return + + val preview = content.preview + val senderName = getSenderName(chatItem) + val dateTime = formatDateTimeForList(chatItem.meta.itemTs) + val uriHandler = LocalUriHandler.current + + SectionItemView( + click = { openBrowserAlert(preview.uri, uriHandler) }, + minHeight = 80.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) + ) { + Column(Modifier.fillMaxWidth()) { + // Sender name and date row + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + senderName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Medium + ) + Text( + dateTime, + fontSize = 11.sp, + color = MaterialTheme.colors.secondary + ) + } + Spacer(Modifier.height(8.dp)) + // Link preview - compact list style + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + if (preview.image.isNotEmpty()) { + Image( + bitmap = base64ToBitmap(preview.image), + contentDescription = stringResource(MR.strings.image_descr_link_preview), + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + Spacer(Modifier.width(DEFAULT_PADDING)) + } else { + Icon( + painterResource(MR.images.ic_link), + null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colors.primary + ) + Spacer(Modifier.width(DEFAULT_PADDING)) + } + Column(Modifier.weight(1f)) { + if (preview.title.isNotEmpty()) { + Text( + preview.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2 + ) + Spacer(Modifier.height(4.dp)) + } + Text( + preview.uri, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + } + } + } + } +} + +// Voice item row +@Composable +private fun VoiceItemRow(chatItem: ChatItem, chat: Chat) { + val content = chatItem.content.msgContent as? MsgContent.MCVoice + val duration = content?.duration ?: 0 + val senderName = getSenderName(chatItem) + val dateTime = formatDateTimeForList(chatItem.meta.itemTs) + val scope = rememberCoroutineScope() + val user = chatModel.currentUser.value + + val receiveFile: (Long) -> Unit = { fileId -> + if (user != null) { + scope.launch { + withBGApi { + chatModel.controller.receiveFile(chat.remoteHostId, user, fileId) + } + } + } + } + + SectionItemView( + click = { /* No action - voice playback is handled by CIVoiceView */ }, + minHeight = 64.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) + ) { + Column(Modifier.fillMaxWidth()) { + // Sender name and date row + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + senderName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Medium + ) + Text( + dateTime, + fontSize = 11.sp, + color = MaterialTheme.colors.secondary + ) + } + Spacer(Modifier.height(8.dp)) + + CIVoiceView( + providedDurationSec = duration, + file = chatItem.file, + edited = chatItem.meta.itemEdited, + sent = chatItem.chatDir.sent, + hasText = false, + ci = chatItem, + timedMessagesTTL = null, + showViaProxy = false, + showTimestamp = true, + smallView = false, + longClick = { }, + receiveFile = receiveFile + ) + } + } +} + +// Get sender name from chat item +private fun getSenderName(chatItem: ChatItem): String { + return when (val dir = chatItem.chatDir) { + is CIDirection.GroupRcv -> dir.groupMember.chatViewName + is CIDirection.GroupSnd -> "ME" // TODO: Replace with actual sender name + else -> "Sender" + } +} + +// Format date/time for list items (e.g., "Dec 20 2022 12:01 PM") +private fun formatDateTimeForList(timestamp: Instant): String { + val tz = TimeZone.currentSystemDefault() + val dateTime = timestamp.toLocalDateTime(tz) + val javaDate = dateTime.toJavaLocalDateTime() + + // Convert to milliseconds for SimpleDateFormat + val millis = timestamp.epochSeconds * 1000L + timestamp.nanosecondsOfSecond / 1_000_000 + + val dateFormat = SimpleDateFormat("MMM dd yyyy hh:mm a", Locale.getDefault()) + dateFormat.timeZone = java.util.TimeZone.getDefault() + return dateFormat.format(Date(millis)) +} + @Composable private fun GroupChatItemRow(chatItem: ChatItem) { Row( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index f3d64e3192..fb9f2b08d5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.lazy.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -45,6 +46,8 @@ import chat.simplex.common.views.database.TtlOptions import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 val MEMBER_ROW_AVATAR_SIZE = 42.dp @@ -463,29 +466,136 @@ fun ModalData.GroupChatInfoLayout( if (s.isEmpty()) activeSortedMembers else activeSortedMembers.filter { m -> m.anyNameContains(s) } } } - val selectedTab = rememberSaveable { mutableStateOf(GroupInfoTab.Members) } - val filteredChatItems = remember(chat.chatItems, selectedTab.value) { + val selectedTab = remember { stateGetOrPut("selectedTab") { GroupInfoTab.Members } } + + // Helper function to map tab to content tag + fun tabToContentTag(tab: GroupInfoTab): MsgContentTag? { + return when (tab) { + GroupInfoTab.Members -> null + GroupInfoTab.Images -> MsgContentTag.Image + GroupInfoTab.Videos -> MsgContentTag.Video + GroupInfoTab.Files -> MsgContentTag.File + GroupInfoTab.Links -> MsgContentTag.Link + GroupInfoTab.Voice -> MsgContentTag.Voice + } + } + // State for storing items per tab and initing with empty list + val tabItemsState = remember { mutableStateMapOf>() } + val loadingTabItems = remember { mutableStateOf(null) } + val tabPaginationState = remember { mutableStateMapOf() } + LaunchedEffect(Unit) { + GroupInfoTab.entries.forEach { tab -> + if (tab != GroupInfoTab.Members && !tabItemsState.containsKey(tab)) { + tabItemsState[tab] = SnapshotStateList() + } + } + } + + fun loadTabItems( + tab: GroupInfoTab, + pagination: ChatPagination, + items: SnapshotStateList, + clearExisting: Boolean + ) { + loadingTabItems.value = tab + + withBGApi { + val contentTag = tabToContentTag(tab) ?: run { + loadingTabItems.value = null + return@withBGApi + } + + val apiResult = chatModel.controller.apiGetChat( + chat.remoteHostId, + chat.chatInfo.chatType, + chat.chatInfo.apiId, + scope = null, + contentTag = contentTag, + pagination = pagination, + search = "" + ) ?: run { + loadingTabItems.value = null + return@withBGApi + } + + val loadedItems = apiResult.first.chatItems + + withContext(Dispatchers.Main) { + // Filter out deleted items and validate content type matches the tab + val filteredItems = loadedItems.filter { item -> + item.meta.itemDeleted == null && when (tab) { + GroupInfoTab.Images -> item.content.msgContent is MsgContent.MCImage + GroupInfoTab.Videos -> item.content.msgContent is MsgContent.MCVideo + GroupInfoTab.Files -> item.content.msgContent is MsgContent.MCFile + GroupInfoTab.Links -> item.content.msgContent is MsgContent.MCLink + GroupInfoTab.Voice -> item.content.msgContent is MsgContent.MCVoice + else -> true + } + } + + if (clearExisting) { + items.clear() + items.addAll(filteredItems) + } else { + // Filter out duplicates when appending + val existingIds = items.map { it.id }.toSet() + val newItems = filteredItems.filter { it.id !in existingIds } + items.addAll(newItems) + } + + if (clearExisting) { + tabPaginationState[tab] = pagination + } + loadingTabItems.value = null + } + } + } + + // Load items when tab is selected + LaunchedEffect(selectedTab.value) { + val tab = selectedTab.value + if (tab == GroupInfoTab.Members) return@LaunchedEffect + val items = tabItemsState[tab] ?: return@LaunchedEffect + // Only load if empty (initial load) + if (items.isNotEmpty()) return@LaunchedEffect + + val pagination = ChatPagination.Initial(ChatPagination.PRELOAD_COUNT) + loadTabItems(tab, pagination, items, clearExisting = true) + } + // Get current tab items list and track its size for recomposition + val currentTabItems = remember(selectedTab.value) { + tabItemsState[selectedTab.value] + } + val currentTabItemsSize = remember(currentTabItems) { + currentTabItems?.size ?: 0 + } + // Create filteredChatItems from tabItemsState - updates when tab or list size changes + // Using derivedStateOf to observe the SnapshotStateList changes + val filteredChatItems = remember(selectedTab.value, currentTabItemsSize) { derivedStateOf { when (selectedTab.value) { GroupInfoTab.Members -> emptyList() - GroupInfoTab.Images -> chat.chatItems.filter { - it.content.msgContent is MsgContent.MCImage && it.meta.itemDeleted == null - } - GroupInfoTab.Videos -> chat.chatItems.filter { - it.content.msgContent is MsgContent.MCVideo && it.meta.itemDeleted == null - } - GroupInfoTab.Files -> chat.chatItems.filter { - it.content.msgContent is MsgContent.MCFile && it.meta.itemDeleted == null - } - GroupInfoTab.Links -> chat.chatItems.filter { - it.content.msgContent is MsgContent.MCLink && it.meta.itemDeleted == null - } - GroupInfoTab.Voice -> chat.chatItems.filter { - it.content.msgContent is MsgContent.MCVoice && it.meta.itemDeleted == null + else -> { + // Access the list - SnapshotStateList changes will be observed + val items = tabItemsState[selectedTab.value] + items?.toList() ?: emptyList() } } } } + + val loadMoreTabItems: () -> Unit = loadMoreTabItems@{ + val tab = selectedTab.value + if (tab == GroupInfoTab.Members) return@loadMoreTabItems + val items = tabItemsState[tab] ?: return@loadMoreTabItems + val lastItemId = items.lastOrNull()?.id ?: return@loadMoreTabItems + + scope.launch(Dispatchers.Default) { + val pagination = ChatPagination.Before(lastItemId, ChatPagination.INITIAL_COUNT) + loadTabItems(tab, pagination, items, clearExisting = false) + } + } + Box { val oneHandUI = remember { appPrefs.oneHandUI.state } val selectedItemsBarHeight = if (selectedItems.value != null) AppBarHeight * fontSizeSqrtMultiplier else 0.dp @@ -567,7 +677,9 @@ fun ModalData.GroupChatInfoLayout( chat = chat, groupLink = groupLink, manageGroupLink = manageGroupLink, - openMemberSupport = openMemberSupport + openMemberSupport = openMemberSupport, + isLoadingTab = loadingTabItems.value == selectedTab.value, + onLoadMoreTabItems = loadMoreTabItems ) item { @@ -987,7 +1099,6 @@ fun DeleteGroupButton(titleId: StringResource, onClick: () -> Unit) { ) } - @Composable fun MemberListSearchRowView( searchText: MutableState = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index a7d58dbe1a..4de2cfdaad 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2774,6 +2774,8 @@ Subscriptions ignored Subscription errors Uploaded files + Load more + No items found Size Chunks uploaded Upload errors