android, desktop: go to forwarded item or search result (#5666)

* android, desktop: go to forwarded item or search result

* changes

* reactions back

* button appearance

* indentation

* change

* rename variable

* rename function

* rename variable

* rename variable

* fix scroll position
This commit is contained in:
Stanislav Dmitrenko 2025-03-01 01:55:49 +07:00 committed by GitHub
parent 1b757911fa
commit 50232fd179
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 591 additions and 448 deletions

View file

@ -65,6 +65,7 @@ object ChatModel {
// current chat // current chat
val chatId = mutableStateOf<String?>(null) val chatId = mutableStateOf<String?>(null)
val openAroundItemId: MutableState<Long?> = mutableStateOf(null)
val chatsContext = ChatsContext(null) val chatsContext = ChatsContext(null)
val reportsChatsContext = ChatsContext(MsgContentTag.Report) val reportsChatsContext = ChatsContext(MsgContentTag.Report)
// declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions // declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions
@ -3111,6 +3112,13 @@ sealed class CIForwardedFrom {
is Group -> chatName is Group -> chatName
} }
val chatTypeApiIdMsgId: Triple<ChatType, Long, Long?>?
get() = when (this) {
Unknown -> null
is Contact -> if (contactId != null) Triple(ChatType.Direct, contactId, chatItemId) else null
is Group -> if (groupId != null) Triple(ChatType.Group, groupId, chatItemId) else null
}
fun text(chatType: ChatType): String = fun text(chatType: ChatType): String =
if (chatType == ChatType.Local) { if (chatType == ChatType.Local) {
if (chatName.isEmpty()) { if (chatName.isEmpty()) {

View file

@ -1398,7 +1398,8 @@ private suspend fun afterSetChatTTL(rhId: Long?, chatInfo: ChatInfo, progressInd
chat, chat,
navInfo, navInfo,
contentTag = null, contentTag = null,
pagination = pagination pagination = pagination,
openAroundItemId = null
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "apiGetChat error: ${e.stackTraceToString()}") Log.e(TAG, "apiGetChat error: ${e.stackTraceToString()}")

View file

@ -28,13 +28,15 @@ suspend fun apiLoadMessages(
contentTag: MsgContentTag?, contentTag: MsgContentTag?,
pagination: ChatPagination, pagination: ChatPagination,
search: String = "", search: String = "",
openAroundItemId: Long? = null,
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
) = coroutineScope { ) = coroutineScope {
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, pagination, search) ?: return@coroutineScope val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, pagination, search) ?: return@coroutineScope
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last) /** When [openAroundItemId] is provided, chatId can be different too */
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null)
|| !isActive) return@coroutineScope || !isActive) return@coroutineScope
processLoadedChat(chat, navInfo, contentTag, pagination, visibleItemIndexesNonReversed) processLoadedChat(chat, navInfo, contentTag, pagination, openAroundItemId, visibleItemIndexesNonReversed)
} }
suspend fun processLoadedChat( suspend fun processLoadedChat(
@ -42,6 +44,7 @@ suspend fun processLoadedChat(
navInfo: NavigationInfo, navInfo: NavigationInfo,
contentTag: MsgContentTag?, contentTag: MsgContentTag?,
pagination: ChatPagination, pagination: ChatPagination,
openAroundItemId: Long?,
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
) { ) {
val chatState = chatModel.chatStateForContent(contentTag) val chatState = chatModel.chatStateForContent(contentTag)
@ -67,7 +70,7 @@ suspend fun processLoadedChat(
withChats(contentTag) { withChats(contentTag) {
chatItemStatuses.clear() chatItemStatuses.clear()
chatItems.replaceAll(chat.chatItems) chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id chatModel.chatId.value = chat.id
splits.value = newSplits splits.value = newSplits
if (chat.chatItems.isNotEmpty()) { if (chat.chatItems.isNotEmpty()) {
unreadAfterItemId.value = chat.chatItems.last().id unreadAfterItemId.value = chat.chatItems.last().id
@ -119,10 +122,15 @@ suspend fun processLoadedChat(
} }
} }
is ChatPagination.Around -> { is ChatPagination.Around -> {
val newSplits = if (openAroundItemId == null) {
newItems.addAll(oldItems) newItems.addAll(oldItems)
val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)
} else {
emptyList()
}
// currently, items will always be added on top, which is index 0 // currently, items will always be added on top, which is index 0
newItems.addAll(0, chat.chatItems) newItems.addAll(0, chat.chatItems)
withChats(contentTag) { withChats(contentTag) {
chatItems.replaceAll(newItems) chatItems.replaceAll(newItems)
splits.value = listOf(chat.chatItems.last().id) + newSplits splits.value = listOf(chat.chatItems.last().id) + newSplits
@ -130,10 +138,17 @@ suspend fun processLoadedChat(
totalAfter.value = navInfo.afterTotal totalAfter.value = navInfo.afterTotal
unreadTotal.value = chat.chatStats.unreadCount unreadTotal.value = chat.chatStats.unreadCount
unreadAfter.value = navInfo.afterUnread unreadAfter.value = navInfo.afterUnread
if (openAroundItemId != null) {
unreadAfterNewestLoaded.value = navInfo.afterUnread
chatModel.openAroundItemId.value = openAroundItemId
chatModel.chatId.value = chat.id
} else {
// no need to set it, count will be wrong // no need to set it, count will be wrong
// unreadAfterNewestLoaded.value = navInfo.afterUnread // unreadAfterNewestLoaded.value = navInfo.afterUnread
} }
} }
}
is ChatPagination.Last -> { is ChatPagination.Last -> {
newItems.addAll(oldItems) newItems.addAll(oldItems)
removeDuplicates(newItems, chat) removeDuplicates(newItems, chat)

View file

@ -133,9 +133,12 @@ fun ChatView(
SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) { SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) {
val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value -> val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value ->
if (searchText.value == value) return@onSearchValueChanged val sameText = searchText.value == value
val c = chatModel.getChat(chatInfo.id) ?: return@onSearchValueChanged // showSearch can be false with empty text when it was closed manually after clicking on message from search to load .around it
if (chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged // (required on Android to have this check to prevent call to search with old text)
val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && contentTag == null
val c = chatModel.getChat(chatInfo.id)
if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged
withBGApi { withBGApi {
apiFindMessages(c, value, contentTag) apiFindMessages(c, value, contentTag)
searchText.value = value searchText.value = value
@ -344,7 +347,7 @@ fun ChatView(
val c = chatModel.getChat(chatId) val c = chatModel.getChat(chatId)
if (chatModel.chatId.value != chatId) return@ChatLayout if (chatModel.chatId.value != chatId) return@ChatLayout
if (c != null) { if (c != null) {
apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, visibleItemIndexes) apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, null, visibleItemIndexes)
} }
}, },
deleteMessage = { itemId, mode -> deleteMessage = { itemId, mode ->
@ -602,6 +605,10 @@ fun ChatView(
}, },
changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) }, changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) },
onSearchValueChanged = onSearchValueChanged, onSearchValueChanged = onSearchValueChanged,
closeSearch = {
showSearch.value = false
searchText.value = ""
},
onComposed, onComposed,
developerTools = chatModel.controller.appPrefs.developerTools.get(), developerTools = chatModel.controller.appPrefs.developerTools.get(),
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
@ -699,6 +706,7 @@ fun ChatLayout(
markChatRead: () -> Unit, markChatRead: () -> Unit,
changeNtfsState: (MsgFilter, currentValue: MutableState<MsgFilter>) -> Unit, changeNtfsState: (MsgFilter, currentValue: MutableState<MsgFilter>) -> Unit,
onSearchValueChanged: (String) -> Unit, onSearchValueChanged: (String) -> Unit,
closeSearch: () -> Unit,
onComposed: suspend (chatId: String) -> Unit, onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean, developerTools: Boolean,
showViaProxy: Boolean, showViaProxy: Boolean,
@ -751,7 +759,7 @@ fun ChatLayout(
useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, setReaction, showItemDetails, markItemsRead, markChatRead, closeSearch, remember { { onComposed(it) } }, developerTools, showViaProxy,
) )
} }
if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) { if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) {
@ -1160,11 +1168,13 @@ fun BoxScope.ChatItemsList(
showItemDetails: (ChatInfo, ChatItem) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit,
markItemsRead: (List<Long>) -> Unit, markItemsRead: (List<Long>) -> Unit,
markChatRead: () -> Unit, markChatRead: () -> Unit,
closeSearch: () -> Unit,
onComposed: suspend (chatId: String) -> Unit, onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean, developerTools: Boolean,
showViaProxy: Boolean showViaProxy: Boolean
) { ) {
val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } }
val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } }
val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf<Long>()) } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf<Long>()) }
val contentTag = LocalContentTag.current val contentTag = LocalContentTag.current
// not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state
@ -1177,15 +1187,29 @@ fun BoxScope.ChatItemsList(
val reportsCount = reportsCount(chatInfo.id) val reportsCount = reportsCount(chatInfo.id)
val topPaddingToContent = topPaddingToContent(chatView = contentTag == null, contentTag == null && reportsCount > 0) val topPaddingToContent = topPaddingToContent(chatView = contentTag == null, contentTag == null && reportsCount > 0)
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() })
val numberOfBottomAppBars = numberOfBottomAppBars()
/** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of /** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of
* [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears * [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears
* */ * */
val maxHeightForList = rememberUpdatedState( val maxHeightForList = rememberUpdatedState(
with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * 2).roundToPx() } with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * numberOfBottomAppBars).roundToPx() }
) )
val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, saver = LazyListState.Saver) { val resetListState = remember { mutableStateOf(false) }
val index = mergedItems.value.items.indexOfLast { it.hasUnread() } remember(chatModel.openAroundItemId.value) {
if (chatModel.openAroundItemId.value != null) {
closeSearch()
resetListState.value = !resetListState.value
}
}
val highlightedItems = remember { mutableStateOf(setOf<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() }
val reportsState = reportsListState val reportsState = reportsListState
if (openAroundItemId != null) {
highlightedItems.value += openAroundItemId
chatModel.openAroundItemId.value = null
}
if (reportsState != null) { if (reportsState != null) {
reportsListState = null reportsListState = null
reportsState reportsState
@ -1221,7 +1245,6 @@ fun BoxScope.ChatItemsList(
val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) val remoteHostIdUpdated = rememberUpdatedState(remoteHostId)
val chatInfoUpdated = rememberUpdatedState(chatInfo) val chatInfoUpdated = rememberUpdatedState(chatInfo)
val highlightedItems = remember { mutableStateOf(setOf<Long>()) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val scrollToItem: (Long) -> Unit = remember { val scrollToItem: (Long) -> Unit = remember {
// In group reports just set the itemId to scroll to so the main ChatView will handle scrolling // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling
@ -1238,7 +1261,7 @@ fun BoxScope.ChatItemsList(
scrollToItemId.value = null } scrollToItemId.value = null }
} }
} }
LoadLastItems(loadingMoreItems, remoteHostId, chatInfo) LoadLastItems(loadingMoreItems, resetListState, remoteHostId, chatInfo)
SmallScrollOnNewMessage(listState, reversedChatItems) SmallScrollOnNewMessage(listState, reversedChatItems)
val finishedInitialComposition = remember { mutableStateOf(false) } val finishedInitialComposition = remember { mutableStateOf(false) }
NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed)
@ -1296,7 +1319,7 @@ fun BoxScope.ChatItemsList(
highlightedItems.value = setOf() highlightedItems.value = setOf()
} }
} }
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, 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, 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)
} }
} }
@ -1567,9 +1590,9 @@ fun BoxScope.ChatItemsList(
} }
@Composable @Composable
private fun LoadLastItems(loadingMoreItems: MutableState<Boolean>, remoteHostId: Long?, chatInfo: ChatInfo) { private fun LoadLastItems(loadingMoreItems: MutableState<Boolean>, resetListState: State<Boolean>, remoteHostId: Long?, chatInfo: ChatInfo) {
val contentTag = LocalContentTag.current val contentTag = LocalContentTag.current
LaunchedEffect(remoteHostId, chatInfo.id) { LaunchedEffect(remoteHostId, chatInfo.id, resetListState.value) {
try { try {
loadingMoreItems.value = true loadingMoreItems.value = true
if (chatModel.chatStateForContent(contentTag).totalAfter.value <= 0) return@LaunchedEffect if (chatModel.chatStateForContent(contentTag).totalAfter.value <= 0) return@LaunchedEffect
@ -1888,6 +1911,17 @@ fun topPaddingToContent(chatView: Boolean, additionalTopBar: Boolean = false): D
} }
} }
@Composable
private fun numberOfBottomAppBars(): Int {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val chatBottomBar = remember { appPrefs.chatBottomBar.state }
return if (oneHandUI.value && chatBottomBar.value) {
2
} else {
1
}
}
@Composable @Composable
private fun FloatingDate( private fun FloatingDate(
modifier: Modifier, modifier: Modifier,
@ -2798,6 +2832,7 @@ fun PreviewChatLayout() {
markChatRead = {}, markChatRead = {},
changeNtfsState = { _, _ -> }, changeNtfsState = { _, _ -> },
onSearchValueChanged = {}, onSearchValueChanged = {},
closeSearch = {},
onComposed = {}, onComposed = {},
developerTools = false, developerTools = false,
showViaProxy = false, showViaProxy = false,
@ -2874,6 +2909,7 @@ fun PreviewGroupChatLayout() {
markChatRead = {}, markChatRead = {},
changeNtfsState = { _, _ -> }, changeNtfsState = { _, _ -> },
onSearchValueChanged = {}, onSearchValueChanged = {},
closeSearch = {},
onComposed = {}, onComposed = {},
developerTools = false, developerTools = false,
showViaProxy = false, showViaProxy = false,

View file

@ -3,14 +3,14 @@ package chat.simplex.common.views.chat.item
import SectionItemView import SectionItemView
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.HoverInteraction import androidx.compose.foundation.interaction.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.* import androidx.compose.foundation.shape.*
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.* import androidx.compose.ui.geometry.*
import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.*
@ -31,8 +31,10 @@ import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.chat.group.LocalContentTag
import chat.simplex.common.views.chatlist.openChat
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlin.math.* import kotlin.math.*
@ -73,6 +75,7 @@ fun ChatItemView(
highlighted: State<Boolean>, highlighted: State<Boolean>,
range: State<IntRange?>, range: State<IntRange?>,
selectedChatItems: MutableState<Set<Long>?>, selectedChatItems: MutableState<Set<Long>?>,
searchIsNotBlank: State<Boolean>,
fillMaxWidth: Boolean = true, fillMaxWidth: Boolean = true,
selectChatItem: () -> Unit, selectChatItem: () -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit,
@ -112,8 +115,16 @@ fun ChatItemView(
val onLinkLongClick = { _: String -> showMenu.value = true } val onLinkLongClick = { _: String -> showMenu.value = true }
val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value 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( Box(
modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, modifier = (if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier)
.hoverable(lineInteractionSource)
.clickable(onClick = {}, interactionSource = lineInteractionSource, indication = null),
contentAlignment = alignment, contentAlignment = alignment,
) { ) {
val info = cItem.meta.itemStatus.statusInto val info = cItem.meta.itemStatus.statusInto
@ -228,20 +239,63 @@ fun ChatItemView(
} }
} }
@Composable
fun GoToItemInnerButton(alignStart: Boolean, icon: ImageResource, parentActivated: State<Boolean>, onClick: () -> Unit) {
val buttonInteractionSource = remember { MutableInteractionSource() }
val buttonHovered = buttonInteractionSource.collectIsHoveredAsState()
val buttonPressed = buttonInteractionSource.collectIsPressedAsState()
val buttonActivated = remember { derivedStateOf { buttonHovered.value || buttonPressed.value } }
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),
interactionSource = buttonInteractionSource
) {
Icon(painterResource(icon), null, Modifier.size(22.dp), tint = MaterialTheme.colors.secondary)
}
}
@Composable
fun GoToItemButton(alignStart: Boolean, parentActivated: State<Boolean>) {
val chatTypeApiIdMsgId = cItem.meta.itemForwarded?.chatTypeApiIdMsgId
if (searchIsNotBlank.value) {
GoToItemInnerButton(alignStart, MR.images.ic_search, parentActivated) {
withBGApi {
openChat(rhId, cInfo.chatType, cInfo.apiId, null, cItem.id)
closeReportsIfNeeded()
}
}
} else if (chatTypeApiIdMsgId != null) {
GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, parentActivated) {
val (chatType, apiId, msgId) = chatTypeApiIdMsgId
withBGApi {
openChat(rhId, chatType, apiId, null, msgId)
closeReportsIfNeeded()
}
}
}
}
Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) {
val interactionSource = remember { MutableInteractionSource() } Row(verticalAlignment = Alignment.CenterVertically) {
if (cItem.chatDir.sent) {
GoToItemButton(true, lineActivated)
}
Column(Modifier.weight(1f, fill = false)) {
val enterInteraction = remember { HoverInteraction.Enter() } val enterInteraction = remember { HoverInteraction.Enter() }
KeyChangeEffect(highlighted.value) { KeyChangeEffect(highlighted.value) {
if (highlighted.value) { if (highlighted.value) {
interactionSource.emit(enterInteraction) bubbleInteractionSource.emit(enterInteraction)
} else { } else {
interactionSource.emit(HoverInteraction.Exit(enterInteraction)) bubbleInteractionSource.emit(HoverInteraction.Exit(enterInteraction))
} }
} }
Column( Column(
Modifier Modifier
.clipChatItem(cItem, itemSeparation.largeGap, revealed.value) .clipChatItem(cItem, itemSeparation.largeGap, revealed.value)
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick, interactionSource = interactionSource, indication = LocalIndication.current) .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick, interactionSource = bubbleInteractionSource, indication = LocalIndication.current)
.onRightClick { showMenu.value = true }, .onRightClick { showMenu.value = true },
) { ) {
@Composable @Composable
@ -326,7 +380,6 @@ fun ChatItemView(
} }
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests }
val copyAndShareAllowed = when { val copyAndShareAllowed = when {
cItem.content.text.isNotEmpty() -> true cItem.content.text.isNotEmpty() -> true
cItem.file?.forwardingAllowed() == true -> true cItem.file?.forwardingAllowed() == true -> true
@ -689,7 +742,11 @@ fun ChatItemView(
} }
} }
} }
}
if (!cItem.chatDir.sent) {
GoToItemButton(false, lineActivated)
}
}
if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) { if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) {
ChatItemReactions() ChatItemReactions()
} }
@ -1109,7 +1166,7 @@ fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = fal
private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, _ -> private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, _ ->
val (msgTailWidth, msgBubbleMaxRadius) = with(density) { Pair(msgTailWidthDp.toPx(), msgBubbleMaxRadius.toPx()) } val (msgTailWidth, msgBubbleMaxRadius) = with(density) { Pair(msgTailWidthDp.toPx(), msgBubbleMaxRadius.toPx()) }
val width = if (sent && tailVisible) size.width - msgTailWidth else size.width val width = size.width
val height = size.height val height = size.height
val rxMax = min(msgBubbleMaxRadius, width / 2) val rxMax = min(msgBubbleMaxRadius, width / 2)
val ryMax = min(msgBubbleMaxRadius, height / 2) val ryMax = min(msgBubbleMaxRadius, height / 2)
@ -1224,6 +1281,12 @@ fun shapeStyle(chatItem: ChatItem? = null, tailEnabled: Boolean, tailVisible: Bo
} }
} }
private fun closeReportsIfNeeded() {
if (appPlatform.isAndroid && ModalManager.end.isLastModalOpen(ModalViewId.GROUP_REPORTS)) {
ModalManager.end.closeModals()
}
}
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) { fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) {
AlertManager.shared.showAlertDialog( AlertManager.shared.showAlertDialog(
title = generalGetString(cancelAction.alert.titleId), title = generalGetString(cancelAction.alert.titleId),
@ -1338,6 +1401,7 @@ fun PreviewChatItemView(
highlighted = remember { mutableStateOf(false) }, highlighted = remember { mutableStateOf(false) },
range = remember { mutableStateOf(0..1) }, range = remember { mutableStateOf(0..1) },
selectedChatItems = remember { mutableStateOf(setOf()) }, selectedChatItems = remember { mutableStateOf(setOf()) },
searchIsNotBlank = remember { mutableStateOf(false) },
selectChatItem = {}, selectChatItem = {},
deleteMessage = { _, _ -> }, deleteMessage = { _, _ -> },
deleteMessages = { _ -> }, deleteMessages = { _ -> },
@ -1385,6 +1449,7 @@ fun PreviewChatItemViewDeletedContent() {
highlighted = remember { mutableStateOf(false) }, highlighted = remember { mutableStateOf(false) },
range = remember { mutableStateOf(0..1) }, range = remember { mutableStateOf(0..1) },
selectedChatItems = remember { mutableStateOf(setOf()) }, selectedChatItems = remember { mutableStateOf(setOf()) },
searchIsNotBlank = remember { mutableStateOf(false) },
selectChatItem = {}, selectChatItem = {},
deleteMessage = { _, _ -> }, deleteMessage = { _, _ -> },
deleteMessages = { _ -> }, deleteMessages = { _ -> },

View file

@ -202,7 +202,7 @@ fun FramedItemView(
Column( Column(
Modifier Modifier
.width(IntrinsicSize.Max) .width(IntrinsicSize.Max)
.padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) .padding(start = if (!sent && tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp)
) { ) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
@Composable @Composable

View file

@ -210,8 +210,26 @@ suspend fun openGroupChat(rhId: Long?, groupId: Long, contentTag: MsgContentTag?
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, contentTag: MsgContentTag? = null) = openChat(rhId, chatInfo.chatType, chatInfo.apiId, contentTag) suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, contentTag: MsgContentTag? = null) = openChat(rhId, chatInfo.chatType, chatInfo.apiId, contentTag)
private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long, contentTag: MsgContentTag? = null) = suspend fun openChat(
apiLoadMessages(rhId, chatType, apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) rhId: Long?,
chatType: ChatType,
apiId: Long,
contentTag: MsgContentTag? = null,
openAroundItemId: Long? = null
) =
apiLoadMessages(
rhId,
chatType,
apiId,
contentTag,
if (openAroundItemId != null) {
ChatPagination.Around(openAroundItemId, ChatPagination.INITIAL_COUNT)
} else {
ChatPagination.Initial(ChatPagination.INITIAL_COUNT)
},
"",
openAroundItemId
)
suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) { suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) {
withChats(contentTag) { withChats(contentTag) {