From ec3dc6ccf466d487c3d37627bb86bb0be691057a Mon Sep 17 00:00:00 2001 From: sh Date: Tue, 13 Oct 2026 13:35:55 +0000 Subject: [PATCH] desktop: fix flat row header, hover bar and highlight --- .../simplex/common/views/chat/ChatView.kt | 18 +- .../common/views/chat/item/ChatItemView.kt | 20 +-- .../views/chat/item/FlatChatItemView.kt | 169 +++++++++++++----- .../chat/simplex/app/FlatChatItemViewTest.kt | 39 ++++ 4 files changed, 179 insertions(+), 67 deletions(-) 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 c33a1e6cd6..c9c7ae9472 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 @@ -1952,7 +1952,7 @@ fun BoxScope.ChatItemsList( } @Composable - fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f) { + fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f, showMenu: MutableState = remember { mutableStateOf(false) }) { tryOrShowError("${cItem.id}ChatItem", error = { CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) }) { @@ -1966,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 && !LocalFlatLayout.current, 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, showMenu = showMenu) } } @@ -2029,8 +2029,16 @@ fun BoxScope.ChatItemsList( 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) + val highlighted = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } } + val showMenu = remember { mutableStateOf(false) } + FlatChatItemRow( + chatInfo, cItem, flatHeader, highlighted.value, showMemberInfo, showChatInfo, + Modifier.padding(start = selectionOffset), + hoverBar = { rowHovered -> + FlatHoverBarSlot(rowHovered, chatInfo, cItem, composeState, useLinkPreviews, selecting = selectedChatItems.value != null, setReaction, forwardItem, showMenu) + } + ) { + ChatItemViewShortHand(cItem, itemSeparation, range, showMenu = showMenu) } } } else if (chatInfo is ChatInfo.Group) { @@ -3782,7 +3790,7 @@ private fun getItemSeparation(chatItem: ChatItem, prevItem: ChatItem?): ItemSepa ) } -internal fun getItemSeparationLargeGap(chatItem: ChatItem, nextItem: ChatItem?): Boolean { +private 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/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index b9fdef6db0..792eae1475 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -114,14 +114,13 @@ fun ChatItemView( itemSeparation: ItemSeparation, preview: Boolean = false, swipeOffset: Float = 0f, + showMenu: MutableState = remember { mutableStateOf(false) }, ) { val cInfo = chat.chatInfo val uriHandler = LocalUriHandler.current val sent = cItem.chatDir.sent val flat = LocalFlatLayout.current val alignment = if (sent && !flat) Alignment.CenterEnd else Alignment.CenterStart - val showMenu = remember { mutableStateOf(false) } - val showReactionPicker = remember { mutableStateOf(false) } val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } val onLinkLongClick = { _: String -> showMenu.value = true } val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value @@ -377,8 +376,8 @@ fun ChatItemView( } } cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { - val quickActions = itemQuickActions(cInfo, cItem, live) DefaultDropdownMenu(showMenu) { + val quickActions = itemQuickActions(cInfo, cItem, live) if (quickActions.react) { MsgReactionsMenu(cInfo, cItem, setReaction, showMenu) } @@ -791,21 +790,6 @@ fun ChatItemView( ChatItemReactions() } } - if (flat && !preview && !cItem.isLiveDummy && cItem.content.hasFlatSender && selectedChatItems.value == null && (hoveredItemId.value == cItem.id || showReactionPicker.value)) { - Box(Modifier.matchParentSize(), contentAlignment = Alignment.TopEnd) { - FlatHoverBar( - itemQuickActions(cInfo, cItem, live), - cInfo, - cItem, - setReaction, - showReactionPicker, - reply = { replyToItem(composeState, cItem, useLinkPreviews) }, - forward = { forwardItem(cInfo, cItem) }, - showMenu, - Modifier.wrapContentSize(Alignment.TopEnd, unbounded = 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 index a3534d421b..2ffaad74d2 100644 --- 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 @@ -26,25 +26,56 @@ import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.math.abs private val FLAT_GUTTER_WIDTH = 56.dp -private val FLAT_HEADER_TOP_PADDING = 6.dp +private val FLAT_GUTTER_TIME_END_PADDING = 6.dp +private val FLAT_HEADER_TOP_PADDING = 8.dp // matches the horizontal text padding in CIMarkdownText -private val FLAT_HEADER_START_PADDING = 12.dp +val FLAT_TEXT_START_PADDING = 12.dp +val FLAT_TEXT_VERTICAL_PADDING = 2.dp private const val FLAT_HOVER_ALPHA = 0.04f +private const val FLAT_HIGHLIGHT_ALPHA = 0.12f private const val FLAT_BAR_BORDER_ALPHA = 0.12f -private val FLAT_BAR_BUTTON_SIZE = 30.dp +private val FLAT_BAR_BUTTON_SIZE = 24.dp +private val FLAT_BAR_ICON_SIZE = 16.dp +private val FLAT_BAR_PADDING = 2.dp +// the hover bar has to fit inside the row: pointer events do not reach a part drawn outside the row bounds +private val FLAT_ROW_MIN_HEIGHT = FLAT_BAR_BUTTON_SIZE + FLAT_BAR_PADDING * 3 private const val FLAT_QUICK_REACTIONS = 3 +// same window as getItemSeparation in ChatView.kt +private const val FLAT_GROUP_SECONDS = 60 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) + || !sameFlatSender(item, older) + || abs(item.meta.createdAt.epochSeconds - older.meta.createdAt.epochSeconds) >= FLAT_GROUP_SECONDS + || localDate(item) != localDate(older) ) -val CIContent.hasFlatSender: Boolean get() = when (this) { +private fun sameFlatSender(item: ChatItem, older: ChatItem): Boolean { + val o = older.chatDir + return when (val d = item.chatDir) { + is CIDirection.GroupRcv -> o is CIDirection.GroupRcv && o.groupMember.groupMemberId == d.groupMember.groupMemberId + is CIDirection.ChannelRcv -> o is CIDirection.ChannelRcv + is CIDirection.GroupSnd -> o is CIDirection.GroupSnd && older.meta.showGroupAsSender == item.meta.showGroupAsSender + is CIDirection.DirectSnd -> o is CIDirection.DirectSnd + is CIDirection.DirectRcv -> o is CIDirection.DirectRcv + is CIDirection.LocalSnd -> o is CIDirection.LocalSnd + is CIDirection.LocalRcv -> o is CIDirection.LocalRcv + } +} + +private fun localDate(item: ChatItem) = item.meta.itemTs.toLocalDateTime(TimeZone.currentSystemDefault()).date + +fun flatHoverBarVisible(cItem: ChatItem, rowHovered: Boolean, reactionPickerOpen: Boolean, canReact: Boolean, selecting: Boolean): Boolean = + !selecting && cItem.content.msgContent != null && !cItem.isLiveDummy && (rowHovered || (reactionPickerOpen && canReact)) + +private val CIContent.hasFlatSender: Boolean get() = when (this) { is CIContent.SndMsgContent, is CIContent.RcvMsgContent, is CIContent.SndDeleted, @@ -85,56 +116,103 @@ fun FlatChatItemRow( chatInfo: ChatInfo, cItem: ChatItem, showHeader: Boolean, - hoveredItemId: MutableState, + highlighted: Boolean, showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit, - modifier: Modifier, + modifier: Modifier = Modifier, + hoverBar: @Composable BoxScope.(rowHovered: Boolean) -> Unit, 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 - } + val background = when { + highlighted -> MaterialTheme.colors.primary.copy(alpha = FLAT_HIGHLIGHT_ALPHA) + hovered -> MaterialTheme.colors.onBackground.copy(alpha = FLAT_HOVER_ALPHA) + else -> Color.Transparent } - 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) + Box(modifier.fillMaxWidth().hoverable(interactionSource).background(background)) { + Row( + Modifier + .heightIn(min = FLAT_ROW_MIN_HEIGHT) + .padding(top = if (showHeader) FLAT_HEADER_TOP_PADDING else 0.dp, end = DEFAULT_PADDING_HALF) + ) { + Box(Modifier.width(FLAT_GUTTER_WIDTH * fontSizeSqrtMultiplier)) { + if (showHeader) { + Box(Modifier.align(Alignment.TopCenter)) { + FlatSenderImage(chatInfo, cItem, showMemberInfo, showChatInfo) + } + } else if (hovered && cItem.content.hasFlatSender) { + Text( + cItem.timestampText, + Modifier.align(Alignment.TopEnd).padding(top = FLAT_TEXT_VERTICAL_PADDING + 3.dp, end = FLAT_GUTTER_TIME_END_PADDING), + fontSize = 11.sp, + color = MaterialTheme.colors.secondary, + maxLines = 1 + ) + } + } + Column(Modifier.weight(1f)) { + if (showHeader) { + FlatSenderHeader(chatInfo, cItem) + } + content() } } - Column(Modifier.weight(1f)) { - if (showHeader) { - FlatSenderHeader(chatInfo, cItem) - } - content() - } + hoverBar(hovered) + } +} + +@Composable +fun BoxScope.FlatHoverBarSlot( + rowHovered: Boolean, + cInfo: ChatInfo, + cItem: ChatItem, + composeState: MutableState, + useLinkPreviews: Boolean, + selecting: Boolean, + setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, + forwardItem: (ChatInfo, ChatItem) -> Unit, + showMenu: MutableState +) { + val showReactionPicker = remember { mutableStateOf(false) } + if (!rowHovered && !showReactionPicker.value) return + val quickActions = itemQuickActions(cInfo, cItem, live = composeState.value.liveMessage != null) + if (flatHoverBarVisible(cItem, rowHovered, showReactionPicker.value, quickActions.react, selecting)) { + FlatHoverBar( + quickActions, + cInfo, + cItem, + setReaction, + showReactionPicker, + reply = { replyToItem(composeState, cItem, useLinkPreviews) }, + forward = { forwardItem(cInfo, cItem) }, + showMenu, + Modifier.align(Alignment.TopEnd).padding(end = DEFAULT_PADDING) + ) + } else { + showReactionPicker.value = false } } private data class FlatSender(val name: String, val badge: LocalBadge?, val role: String?) -private fun flatSender(chatInfo: ChatInfo, dir: CIDirection): FlatSender = when (dir) { +private fun flatSender(chatInfo: ChatInfo, cItem: ChatItem): FlatSender = when (val dir = cItem.chatDir) { 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.ChannelRcv -> channelSender(chatInfo) + is CIDirection.GroupSnd -> when { + cItem.meta.showGroupAsSender -> channelSender(chatInfo) + 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 channelSender(chatInfo: ChatInfo): FlatSender = + FlatSender(chatInfo.chatViewName, null, generalGetString(MR.strings.channel_role_label)) + private fun memberSender(member: GroupMember, isChannel: Boolean): FlatSender = FlatSender(member.displayName, member.nameBadge, if (member.memberRole > GroupMemberRole.Member) member.memberRole.text(isChannel) else null) @@ -149,8 +227,8 @@ private fun userSender(chatInfo: ChatInfo): FlatSender { @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) { + val sender = flatSender(chatInfo, cItem) + Row(Modifier.padding(start = FLAT_TEXT_START_PADDING, bottom = FLAT_TEXT_VERTICAL_PADDING), verticalAlignment = Alignment.CenterVertically) { NameWithBadge( sender.name, sender.badge, @@ -167,9 +245,9 @@ private fun FlatSenderHeader(chatInfo: ChatInfo, cItem: ChatItem) { } @Composable -private fun FlatSenderImage(chatInfo: ChatInfo, dir: CIDirection, showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit) { +private fun FlatSenderImage(chatInfo: ChatInfo, cItem: ChatItem, showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit) { val size = MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier - when (dir) { + when (val dir = cItem.chatDir) { is CIDirection.GroupRcv -> Box(if (chatInfo is ChatInfo.Group) Modifier.clip(CircleShape).clickable { showMemberInfo(chatInfo.groupInfo, dir.groupMember) } else Modifier) { MemberImage(dir.groupMember) @@ -179,8 +257,11 @@ private fun FlatSenderImage(chatInfo: ChatInfo, dir: CIDirection, showMemberInfo 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.GroupSnd -> when { + cItem.meta.showGroupAsSender -> ChatInfoImage(chatInfo, size) + chatInfo is ChatInfo.Group -> MemberImage(chatInfo.groupInfo.membership) + else -> UserImage(chatInfo, size) + } is CIDirection.DirectSnd, is CIDirection.LocalSnd, is CIDirection.LocalRcv -> UserImage(chatInfo, size) @@ -206,7 +287,7 @@ fun FlatHoverBar( reply: () -> Unit, forward: () -> Unit, showMenu: MutableState, - modifier: Modifier + modifier: Modifier = Modifier ) { Surface( modifier, @@ -214,7 +295,7 @@ fun FlatHoverBar( border = BorderStroke(1.dp, MaterialTheme.colors.onBackground.copy(alpha = FLAT_BAR_BORDER_ALPHA)), elevation = 2.dp ) { - Row(Modifier.padding(2.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.padding(FLAT_BAR_PADDING), verticalAlignment = Alignment.CenterVertically) { if (quickActions.react) { availableReactions(cItem).take(FLAT_QUICK_REACTIONS).forEach { r -> Box( @@ -245,6 +326,6 @@ fun FlatHoverBar( @Composable private fun FlatBarButton(icon: ImageResource, description: String, onClick: () -> Unit) { IconButton(onClick, Modifier.size(FLAT_BAR_BUTTON_SIZE)) { - Icon(painterResource(icon), description, Modifier.size(18.dp), tint = MaterialTheme.colors.secondary) + Icon(painterResource(icon), description, Modifier.size(FLAT_BAR_ICON_SIZE), tint = MaterialTheme.colors.secondary) } } 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 index 5a07e18629..c4904f182a 100644 --- a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/FlatChatItemViewTest.kt +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/FlatChatItemViewTest.kt @@ -1,6 +1,7 @@ package chat.simplex.app import chat.simplex.common.model.* +import chat.simplex.common.views.chat.item.flatHoverBarVisible import chat.simplex.common.views.chat.item.flatItemHasHeader import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate @@ -66,6 +67,44 @@ class FlatChatItemViewTest { assertTrue(flatItemHasHeader(item, older)) } + @Test + fun headerShownWhenChannelPostFollowsMemberMessage() { + assertTrue(flatItemHasHeader(msg(2, CIDirection.GroupRcv(alice), 10), older = msg(1, CIDirection.ChannelRcv(), 0))) + assertTrue(flatItemHasHeader(msg(2, CIDirection.ChannelRcv(), 10), older = msg(1, CIDirection.GroupRcv(alice), 0))) + assertFalse(flatItemHasHeader(msg(2, CIDirection.ChannelRcv(), 10), older = msg(1, CIDirection.ChannelRcv(), 0))) + } + + @Test + fun headerShownWhenOwnerSwitchesBetweenChannelAndOwnName() { + val asChannel = msg(1, CIDirection.GroupSnd(), 0).let { it.copy(meta = it.meta.copy(showGroupAsSender = true)) } + assertTrue(flatItemHasHeader(msg(2, CIDirection.GroupSnd(), 10), older = asChannel)) + val asChannelToo = msg(2, CIDirection.GroupSnd(), 10).let { it.copy(meta = it.meta.copy(showGroupAsSender = true)) } + assertFalse(flatItemHasHeader(asChannelToo, older = asChannel)) + } + + @Test + fun headerHiddenAfterDeletedMessageFromSameSender() { + val deleted = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Instant.fromEpochSeconds(noon.epochSeconds), itemDeleted = CIDeleted.Deleted(null)) + assertFalse(flatItemHasHeader(msg(2, CIDirection.DirectRcv(), 10), older = deleted)) + } + + @Test + fun hoverBarShownForHoveredMessageOnly() { + val message = msg(1, CIDirection.DirectRcv(), 0) + assertTrue(flatHoverBarVisible(message, rowHovered = true, reactionPickerOpen = false, canReact = true, selecting = false)) + assertFalse(flatHoverBarVisible(message, rowHovered = false, reactionPickerOpen = false, canReact = true, selecting = false)) + assertFalse(flatHoverBarVisible(message, rowHovered = true, reactionPickerOpen = false, canReact = true, selecting = true)) + assertFalse(flatHoverBarVisible(featureEvent(2, 0), rowHovered = true, reactionPickerOpen = false, canReact = true, selecting = false)) + assertFalse(flatHoverBarVisible(msg(ChatItem.TEMP_LIVE_CHAT_ITEM_ID, CIDirection.DirectSnd(), 0), rowHovered = true, reactionPickerOpen = false, canReact = true, selecting = false)) + } + + @Test + fun hoverBarStaysOpenForReactionPickerUntilReactionsUnavailable() { + val message = msg(1, CIDirection.DirectRcv(), 0) + assertTrue(flatHoverBarVisible(message, rowHovered = false, reactionPickerOpen = true, canReact = true, selecting = false)) + assertFalse(flatHoverBarVisible(message, rowHovered = false, reactionPickerOpen = true, canReact = false, selecting = false)) + } + @Test fun noHeaderForEventItem() { assertFalse(flatItemHasHeader(featureEvent(2, 10), older = msg(1, CIDirection.DirectRcv(), 0)))