mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-11 13:57:42 +00:00
desktop: render chat items as flat rows
This commit is contained in:
1 parent
4d645cfe94
commit
d96efb9477
3 files changed
+282
-5
No files matched your search
+18
-5
@@ -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
|
||||
}
|
||||
|
||||
+190
@@ -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)
|
||||
}
|
||||
}
|
||||
+74
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user