desktop: render chat items as flat rows

This commit is contained in:
sh committed 2026-10-14 07:43:16 +00:00
1 parent 4d645cfe94
commit d96efb9477
3 files changed
+282 -5

No files matched your search

@@ -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<IntRange?>,
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
}
@@ -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<Long?>,
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)
}
}
@@ -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))
}
}