From 7a3663f1e064088a2df358cda614061f4c39a0eb Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 6 Mar 2025 16:42:14 +0700 Subject: [PATCH] android, desktop: hovered item in chat (#5684) * android, desktop: hovered item in chat * circle background for button * icon size * change * change * comment * refactor --- .../simplex/common/views/chat/ChatView.kt | 6 +- .../common/views/chat/item/ChatItemView.kt | 67 +++++++++++++------ 2 files changed, 52 insertions(+), 21 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 a889cf6c9a..1c3f4b7749 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 @@ -1214,6 +1214,7 @@ fun BoxScope.ChatItemsList( } } val highlightedItems = remember { mutableStateOf(setOf()) } + val hoveredItemId = remember { mutableStateOf(null as Long?) } val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) { val openAroundItemId = chatModel.openAroundItemId.value val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() } @@ -1222,6 +1223,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value += openAroundItemId chatModel.openAroundItemId.value = null } + hoveredItemId.value = null if (reportsState != null) { reportsListState = null reportsState @@ -1331,7 +1333,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, 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, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + ChatItemView(remoteHostId, chatInfo, 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, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) } } @@ -1618,6 +1620,8 @@ private fun LoadLastItems(loadingMoreItems: MutableState, resetListStat } } +// TODO: in extra rare case when after loading last items only 1 item is loaded, the view will jump like when receiving new message +// can be reproduced by forwarding a message to notes that is (ChatPagination.INITIAL_COUNT - 1) away from bottom and going to that message @Composable private fun SmallScrollOnNewMessage(listState: State, reversedChatItems: State>) { val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() } 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 1268975f96..e399d9e88d 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 @@ -73,6 +73,7 @@ fun ChatItemView( linkMode: SimplexLinkMode, revealed: State, highlighted: State, + hoveredItemId: MutableState, range: State, selectedChatItems: MutableState?>, searchIsNotBlank: State, @@ -115,16 +116,8 @@ fun ChatItemView( val onLinkLongClick = { _: String -> showMenu.value = true } val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value - val bubbleInteractionSource = remember { MutableInteractionSource() } - val lineInteractionSource = remember { MutableInteractionSource() } - val bubblePressed = bubbleInteractionSource.collectIsPressedAsState() - val linePressed = lineInteractionSource.collectIsPressedAsState() - val lineHovered = lineInteractionSource.collectIsHoveredAsState() - val lineActivated = remember { derivedStateOf { lineHovered.value || linePressed.value || bubblePressed.value } } Box( - modifier = (if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier) - .hoverable(lineInteractionSource) - .clickable(onClick = {}, interactionSource = lineInteractionSource, indication = null), + modifier = (if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier), contentAlignment = alignment, ) { val info = cItem.meta.itemStatus.statusInto @@ -240,20 +233,42 @@ fun ChatItemView( } @Composable - fun GoToItemInnerButton(alignStart: Boolean, icon: ImageResource, parentActivated: State, onClick: () -> Unit) { + fun GoToItemInnerButton(alignStart: Boolean, icon: ImageResource, iconSize: Dp = 22.dp, parentActivated: State, onClick: () -> Unit) { val buttonInteractionSource = remember { MutableInteractionSource() } val buttonHovered = buttonInteractionSource.collectIsHoveredAsState() val buttonPressed = buttonInteractionSource.collectIsPressedAsState() val buttonActivated = remember { derivedStateOf { buttonHovered.value || buttonPressed.value } } + + val fullyVisible = parentActivated.value || buttonActivated.value || hoveredItemId.value == cItem.id + val mixAlpha = 0.6f + val mixedBackgroundColor = if (fullyVisible) { + if (MaterialTheme.colors.isLight) { + MaterialTheme.colors.secondary.mixWith(Color.White, mixAlpha) + } else { + MaterialTheme.colors.secondary.mixWith(Color.Black, mixAlpha) + } + } else { + Color.Unspecified + } + val iconTint = if (fullyVisible) { + Color.White + } else { + if (MaterialTheme.colors.isLight) { + MaterialTheme.colors.secondary.mixWith(Color.White, mixAlpha) + } else { + MaterialTheme.colors.secondary.mixWith(Color.Black, mixAlpha) + } + } + IconButton( onClick, Modifier .padding(start = if (alignStart) 0.dp else DEFAULT_PADDING_HALF + 3.dp, end = if (alignStart) DEFAULT_PADDING_HALF + 3.dp else 0.dp) - .size(22.dp) - .alpha(if (parentActivated.value || buttonActivated.value) 1f else 0.4f), + .then(if (fullyVisible) Modifier.background(mixedBackgroundColor, CircleShape) else Modifier) + .size(22.dp), interactionSource = buttonInteractionSource ) { - Icon(painterResource(icon), null, Modifier.size(22.dp), tint = MaterialTheme.colors.secondary) + Icon(painterResource(icon), null, Modifier.size(iconSize), tint = iconTint) } } @@ -261,14 +276,14 @@ fun ChatItemView( fun GoToItemButton(alignStart: Boolean, parentActivated: State) { val chatTypeApiIdMsgId = cItem.meta.itemForwarded?.chatTypeApiIdMsgId if (searchIsNotBlank.value) { - GoToItemInnerButton(alignStart, MR.images.ic_search, parentActivated) { + GoToItemInnerButton(alignStart, MR.images.ic_search, 17.dp, parentActivated) { withBGApi { openChat(rhId, cInfo.chatType, cInfo.apiId, null, cItem.id) closeReportsIfNeeded() } } } else if (chatTypeApiIdMsgId != null) { - GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, parentActivated) { + GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, 22.dp, parentActivated) { val (chatType, apiId, msgId) = chatTypeApiIdMsgId withBGApi { openChat(rhId, chatType, apiId, null, msgId) @@ -280,13 +295,15 @@ fun ChatItemView( Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { Row(verticalAlignment = Alignment.CenterVertically) { + val bubbleInteractionSource = remember { MutableInteractionSource() } + val bubbleHovered = bubbleInteractionSource.collectIsHoveredAsState() if (cItem.chatDir.sent) { - GoToItemButton(true, lineActivated) + GoToItemButton(true, bubbleHovered) } Column(Modifier.weight(1f, fill = false)) { val enterInteraction = remember { HoverInteraction.Enter() } - KeyChangeEffect(highlighted.value) { - if (highlighted.value) { + LaunchedEffect(highlighted.value, hoveredItemId.value) { + if (highlighted.value || hoveredItemId.value == cItem.id) { bubbleInteractionSource.emit(enterInteraction) } else { bubbleInteractionSource.emit(HoverInteraction.Exit(enterInteraction)) @@ -295,7 +312,15 @@ fun ChatItemView( Column( Modifier .clipChatItem(cItem, itemSeparation.largeGap, revealed.value) - .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick, interactionSource = bubbleInteractionSource, indication = LocalIndication.current) + .hoverable(bubbleInteractionSource) + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = { + if (appPlatform.isAndroid && (searchIsNotBlank.value || cItem.meta.itemForwarded?.chatTypeApiIdMsgId != null)) { + hoveredItemId.value = if (hoveredItemId.value == cItem.id) null else cItem.id + } + onClick() + }, interactionSource = bubbleInteractionSource, indication = LocalIndication.current) .onRightClick { showMenu.value = true }, ) { @Composable @@ -744,7 +769,7 @@ fun ChatItemView( } } if (!cItem.chatDir.sent) { - GoToItemButton(false, lineActivated) + GoToItemButton(false, bubbleHovered) } } if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) { @@ -1399,6 +1424,7 @@ fun PreviewChatItemView( composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, revealed = remember { mutableStateOf(false) }, highlighted = remember { mutableStateOf(false) }, + hoveredItemId = remember { mutableStateOf(null) }, range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, searchIsNotBlank = remember { mutableStateOf(false) }, @@ -1447,6 +1473,7 @@ fun PreviewChatItemViewDeletedContent() { composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, revealed = remember { mutableStateOf(false) }, highlighted = remember { mutableStateOf(false) }, + hoveredItemId = remember { mutableStateOf(null) }, range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, searchIsNotBlank = remember { mutableStateOf(false) },