desktop: fix flat row header, hover bar and highlight

This commit is contained in:
sh committed 2026-10-14 07:43:26 +00:00
1 parent 6996731397
commit ec3dc6ccf4
4 files changed
+179 -67

No files matched your search

@@ -1952,7 +1952,7 @@ fun BoxScope.ChatItemsList(
}
@Composable
fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State<IntRange?>, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f) {
fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State<IntRange?>, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f, showMenu: MutableState<Boolean> = 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
}
@@ -114,14 +114,13 @@ fun ChatItemView(
itemSeparation: ItemSeparation,
preview: Boolean = false,
swipeOffset: Float = 0f,
showMenu: MutableState<Boolean> = 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)
)
}
}
}
}
@@ -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<Long?>,
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<ComposeState>,
useLinkPreviews: Boolean,
selecting: Boolean,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
forwardItem: (ChatInfo, ChatItem) -> Unit,
showMenu: MutableState<Boolean>
) {
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<Boolean>,
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)
}
}
@@ -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)))