mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-02 17:50:00 +00:00
Done integration with media api
Implemented items showing, clicks and most of the group media functionality. Needs some UX improvements.
This commit is contained in:
+565
-13
@@ -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<List<ChatItem>>,
|
||||
scrollToItemId: MutableState<Long?>
|
||||
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<ImageResource, StringResource> {
|
||||
}
|
||||
|
||||
|
||||
// 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<ChatItem>
|
||||
)
|
||||
|
||||
// Group items by month and year
|
||||
private fun groupItemsByDate(items: List<ChatItem>): List<DateGroup> {
|
||||
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<ChatItem>,
|
||||
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<Int, ChatItem>? {
|
||||
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<ChatItem>,
|
||||
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<ChatItem>,
|
||||
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(
|
||||
|
||||
+129
-18
@@ -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<GroupInfoTab, SnapshotStateList<ChatItem>>() }
|
||||
val loadingTabItems = remember { mutableStateOf<GroupInfoTab?>(null) }
|
||||
val tabPaginationState = remember { mutableStateMapOf<GroupInfoTab, ChatPagination?>() }
|
||||
LaunchedEffect(Unit) {
|
||||
GroupInfoTab.entries.forEach { tab ->
|
||||
if (tab != GroupInfoTab.Members && !tabItemsState.containsKey(tab)) {
|
||||
tabItemsState[tab] = SnapshotStateList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadTabItems(
|
||||
tab: GroupInfoTab,
|
||||
pagination: ChatPagination,
|
||||
items: SnapshotStateList<ChatItem>,
|
||||
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<TextFieldValue> = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
|
||||
|
||||
@@ -2774,6 +2774,8 @@
|
||||
<string name="subscription_results_ignored">Subscriptions ignored</string>
|
||||
<string name="subscription_errors">Subscription errors</string>
|
||||
<string name="uploaded_files">Uploaded files</string>
|
||||
<string name="load_more">Load more</string>
|
||||
<string name="no_items_found">No items found</string>
|
||||
<string name="size">Size</string>
|
||||
<string name="chunks_uploaded">Chunks uploaded</string>
|
||||
<string name="upload_errors">Upload errors</string>
|
||||
|
||||
Reference in New Issue
Block a user