diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 2e7cd217e3..1f8e72ca97 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.* import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* +import chat.simplex.common.LocalFlatLayout import chat.simplex.common.model.* import chat.simplex.common.model.CIDirection.GroupRcv import chat.simplex.common.model.ChatController.appPrefs @@ -1923,6 +1924,7 @@ fun BoxScope.ChatItemsList( itemAtZeroIndexInWholeList: Boolean, range: State, showAvatar: Boolean, + flatHeader: Boolean, cItem: ChatItem, itemSeparation: ItemSeparation, previousItemSeparationLargeGap: Boolean, @@ -1964,7 +1966,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp, swipeOffset = swipeOffset) + ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp && !LocalFlatLayout.current, swipeOffset = swipeOffset) } } @@ -2019,9 +2021,19 @@ fun BoxScope.ChatItemsList( Box { val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf - val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp) + val flat = LocalFlatLayout.current + val selectionOffset by animateDpAsState(if (selectionVisible && (!sent || flat)) 4.dp + 22.dp * fontSizeMultiplier else 0.dp) val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() } - if (chatInfo is ChatInfo.Group) { + if (flat) { + ChatItemBox { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + FlatChatItemRow(chatInfo, cItem, flatHeader, hoveredItemId, showMemberInfo, showChatInfo, Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { + ChatItemViewShortHand(cItem, itemSeparation, range) + } + } + } else if (chatInfo is ChatInfo.Group) { if (cItem.chatDir is CIDirection.GroupRcv) { if (showAvatar) { Column( @@ -2423,6 +2435,7 @@ fun BoxScope.ChatItemsList( null } val showAvatar = shouldShowAvatar(item, merged.oldest().nextItem) + val flatHeader = flatItemHasHeader(item, merged.oldest().nextItem) val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } val itemSeparation: ItemSeparation val prevItemSeparationLargeGap: Boolean @@ -2436,7 +2449,7 @@ fun BoxScope.ChatItemsList( prevItemSeparationLargeGap = false } CompositionLocalProvider(LocalItemContext provides ItemContext(selectionIndex = index)) { - ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { + ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, flatHeader, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) } } @@ -3769,7 +3782,7 @@ private fun getItemSeparation(chatItem: ChatItem, prevItem: ChatItem?): ItemSepa ) } -private fun getItemSeparationLargeGap(chatItem: ChatItem, nextItem: ChatItem?): Boolean { +internal fun getItemSeparationLargeGap(chatItem: ChatItem, nextItem: ChatItem?): Boolean { if (nextItem == null) { return true } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FlatChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FlatChatItemView.kt new file mode 100644 index 0000000000..5b77e39e25 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FlatChatItemView.kt @@ -0,0 +1,190 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.* + +private val FLAT_GUTTER_WIDTH = 56.dp +private val FLAT_HEADER_TOP_PADDING = 6.dp +// matches the horizontal text padding in CIMarkdownText +private val FLAT_HEADER_START_PADDING = 12.dp +private const val FLAT_HOVER_ALPHA = 0.04f + +fun flatItemHasHeader(item: ChatItem, older: ChatItem?): Boolean = + item.content.hasFlatSender && ( + older == null + || !older.content.hasFlatSender + || getTimestampDateText(older.meta.itemTs) != getTimestampDateText(item.meta.itemTs) + || getItemSeparationLargeGap(item, older) + ) + +private val CIContent.hasFlatSender: Boolean get() = when (this) { + is CIContent.SndMsgContent, + is CIContent.RcvMsgContent, + is CIContent.SndDeleted, + is CIContent.RcvDeleted, + is CIContent.SndCall, + is CIContent.RcvCall, + is CIContent.RcvIntegrityError, + is CIContent.RcvDecryptionError, + is CIContent.RcvMsgErrorContent, + is CIContent.RcvGroupInvitation, + is CIContent.SndGroupInvitation, + is CIContent.SndModerated, + is CIContent.RcvModerated, + is CIContent.RcvBlocked, + is CIContent.InvalidJSON -> true + is CIContent.RcvDirectEventContent, + is CIContent.RcvGroupEventContent, + is CIContent.SndGroupEventContent, + is CIContent.RcvConnEventContent, + is CIContent.SndConnEventContent, + is CIContent.RcvChatFeature, + is CIContent.SndChatFeature, + is CIContent.RcvChatPreference, + is CIContent.SndChatPreference, + is CIContent.RcvGroupFeature, + is CIContent.SndGroupFeature, + is CIContent.RcvChatFeatureRejected, + is CIContent.RcvGroupFeatureRejected, + is CIContent.SndDirectE2EEInfo, + is CIContent.RcvDirectE2EEInfo, + is CIContent.SndGroupE2EEInfo, + is CIContent.RcvGroupE2EEInfo, + is CIContent.ChatBanner -> false +} + +@Composable +fun FlatChatItemRow( + chatInfo: ChatInfo, + cItem: ChatItem, + showHeader: Boolean, + hoveredItemId: MutableState, + showMemberInfo: (GroupInfo, GroupMember) -> Unit, + showChatInfo: () -> Unit, + modifier: Modifier, + content: @Composable () -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + val hovered = interactionSource.collectIsHoveredAsState().value + LaunchedEffect(hovered) { + if (hovered) { + hoveredItemId.value = cItem.id + } else if (hoveredItemId.value == cItem.id) { + hoveredItemId.value = null + } + } + Row( + modifier + .fillMaxWidth() + .hoverable(interactionSource) + .background(if (hovered) MaterialTheme.colors.onBackground.copy(alpha = FLAT_HOVER_ALPHA) else Color.Transparent) + .padding(top = if (showHeader) FLAT_HEADER_TOP_PADDING else 0.dp, end = DEFAULT_PADDING_HALF) + ) { + Box(Modifier.width(FLAT_GUTTER_WIDTH * fontSizeSqrtMultiplier), contentAlignment = Alignment.TopCenter) { + if (showHeader) { + FlatSenderImage(chatInfo, cItem.chatDir, showMemberInfo, showChatInfo) + } else if (hovered && cItem.content.hasFlatSender) { + Text(cItem.timestampText, Modifier.padding(top = 6.dp), fontSize = 11.sp, color = MaterialTheme.colors.secondary, maxLines = 1) + } + } + Column(Modifier.weight(1f)) { + if (showHeader) { + FlatSenderHeader(chatInfo, cItem) + } + content() + } + } +} + +private data class FlatSender(val name: String, val badge: LocalBadge?, val role: String?) + +private fun flatSender(chatInfo: ChatInfo, dir: CIDirection): FlatSender = when (dir) { + is CIDirection.GroupRcv -> memberSender(dir.groupMember, chatInfo.isChannel) + is CIDirection.ChannelRcv -> FlatSender(chatInfo.chatViewName, null, generalGetString(MR.strings.channel_role_label)) + is CIDirection.GroupSnd -> if (chatInfo is ChatInfo.Group) memberSender(chatInfo.groupInfo.membership, chatInfo.isChannel) else userSender(chatInfo) + is CIDirection.DirectRcv -> FlatSender(chatInfo.displayName, chatInfo.nameBadge, null) + is CIDirection.DirectSnd, + is CIDirection.LocalSnd, + is CIDirection.LocalRcv -> userSender(chatInfo) +} + +private fun memberSender(member: GroupMember, isChannel: Boolean): FlatSender = + FlatSender(member.displayName, member.nameBadge, if (member.memberRole > GroupMemberRole.Member) member.memberRole.text(isChannel) else null) + +private fun userSender(chatInfo: ChatInfo): FlatSender { + val user = chatModel.currentUser.value + return if (chatInfo.incognito) { + FlatSender(generalGetString(MR.strings.incognito), null, null) + } else { + FlatSender(user?.displayName ?: "", user?.profile?.localBadge, null) + } +} + +@Composable +private fun FlatSenderHeader(chatInfo: ChatInfo, cItem: ChatItem) { + val sender = flatSender(chatInfo, cItem.chatDir) + Row(Modifier.padding(start = FLAT_HEADER_START_PADDING), verticalAlignment = Alignment.CenterVertically) { + NameWithBadge( + sender.name, + sender.badge, + Modifier.weight(1f, false), + fontWeight = FontWeight.SemiBold, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + if (sender.role != null) { + Text(sender.role, Modifier.padding(start = DEFAULT_PADDING_HALF), fontSize = 13.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colors.secondary, maxLines = 1) + } + Text(cItem.timestampText, Modifier.padding(start = DEFAULT_PADDING_HALF), fontSize = 12.sp, color = MaterialTheme.colors.secondary, maxLines = 1) + } +} + +@Composable +private fun FlatSenderImage(chatInfo: ChatInfo, dir: CIDirection, showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit) { + val size = MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier + when (dir) { + is CIDirection.GroupRcv -> + Box(if (chatInfo is ChatInfo.Group) Modifier.clip(CircleShape).clickable { showMemberInfo(chatInfo.groupInfo, dir.groupMember) } else Modifier) { + MemberImage(dir.groupMember) + } + is CIDirection.ChannelRcv, + is CIDirection.DirectRcv -> + Box(Modifier.clip(CircleShape).clickable { showChatInfo() }) { + ChatInfoImage(chatInfo, size) + } + is CIDirection.GroupSnd -> + if (chatInfo is ChatInfo.Group) MemberImage(chatInfo.groupInfo.membership) else UserImage(chatInfo, size) + is CIDirection.DirectSnd, + is CIDirection.LocalSnd, + is CIDirection.LocalRcv -> UserImage(chatInfo, size) + } +} + +@Composable +private fun UserImage(chatInfo: ChatInfo, size: Dp) { + if (chatInfo.incognito) { + IncognitoImage(size) + } else { + ProfileImage(size, chatModel.currentUser.value?.image) + } +} diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/FlatChatItemViewTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/FlatChatItemViewTest.kt new file mode 100644 index 0000000000..5a07e18629 --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/FlatChatItemViewTest.kt @@ -0,0 +1,74 @@ +package chat.simplex.app + +import chat.simplex.common.model.* +import chat.simplex.common.views.chat.item.flatItemHasHeader +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FlatChatItemViewTest { + private val noon = Instant.parse("2026-10-12T12:00:00Z") + private val alice = GroupMember.sampleData.copy(groupMemberId = 1, memberId = "alice") + private val bob = GroupMember.sampleData.copy(groupMemberId = 2, memberId = "bob") + + private fun msg(id: Long, dir: CIDirection, secondsAfterNoon: Long) = + ChatItem.getSampleData(id, dir, Instant.fromEpochSeconds(noon.epochSeconds + secondsAfterNoon)) + + private fun featureEvent(id: Long, secondsAfterNoon: Long) = + ChatItem( + CIDirection.DirectRcv(), + CIMeta.getSample(id, Instant.fromEpochSeconds(noon.epochSeconds + secondsAfterNoon), ""), + CIContent.RcvChatFeature(ChatFeature.Voice, FeatureEnabled(forUser = true, forContact = true)), + reactions = emptyList() + ) + + @Test + fun headerShownForOldestItem() { + assertTrue(flatItemHasHeader(msg(1, CIDirection.DirectRcv(), 0), older = null)) + } + + @Test + fun headerHiddenForSameSenderWithinMinute() { + assertFalse(flatItemHasHeader(msg(2, CIDirection.DirectSnd(), 30), older = msg(1, CIDirection.DirectSnd(), 0))) + assertFalse(flatItemHasHeader(msg(2, CIDirection.GroupRcv(alice), 59), older = msg(1, CIDirection.GroupRcv(alice), 0))) + } + + @Test + fun headerShownAfterMinuteGap() { + assertTrue(flatItemHasHeader(msg(2, CIDirection.DirectRcv(), 60), older = msg(1, CIDirection.DirectRcv(), 0))) + } + + @Test + fun headerShownForDifferentGroupMember() { + assertTrue(flatItemHasHeader(msg(2, CIDirection.GroupRcv(bob), 10), older = msg(1, CIDirection.GroupRcv(alice), 0))) + } + + @Test + fun headerShownWhenDirectionChanges() { + assertTrue(flatItemHasHeader(msg(2, CIDirection.DirectSnd(), 10), older = msg(1, CIDirection.DirectRcv(), 0))) + assertTrue(flatItemHasHeader(msg(2, CIDirection.GroupSnd(), 10), older = msg(1, CIDirection.GroupRcv(alice), 0))) + } + + @Test + fun headerShownAfterEventItem() { + assertTrue(flatItemHasHeader(msg(2, CIDirection.DirectRcv(), 10), older = featureEvent(1, 0))) + } + + @Test + fun headerShownOnDateChangeWithinMinute() { + val midnight = LocalDate(2026, 10, 13).atStartOfDayIn(TimeZone.currentSystemDefault()) + val older = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Instant.fromEpochSeconds(midnight.epochSeconds - 10)) + val item = ChatItem.getSampleData(2, CIDirection.DirectRcv(), Instant.fromEpochSeconds(midnight.epochSeconds + 10)) + assertTrue(flatItemHasHeader(item, older)) + } + + @Test + fun noHeaderForEventItem() { + assertFalse(flatItemHasHeader(featureEvent(2, 10), older = msg(1, CIDirection.DirectRcv(), 0))) + assertFalse(flatItemHasHeader(featureEvent(1, 0), older = null)) + } +}