From b2b1519aeacf036400c107c3a7da949dd031ff00 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 5 Aug 2024 18:26:27 +0900 Subject: [PATCH] android, desktop: multiple messages deletion (#4559) * android, desktop: multiple messages deletion * icons * icon --- .../chat/simplex/common/model/ChatModel.kt | 21 +- .../simplex/common/views/chat/ChatView.kt | 372 +++++++++++++----- .../views/chat/SelectableChatItemToolbars.kt | 132 +++++++ .../common/views/chat/item/ChatItemView.kt | 109 ++++- .../common/views/helpers/DefaultTopAppBar.kt | 9 + .../commonMain/resources/MR/base/strings.xml | 7 + .../resources/MR/images/ic_check_circle.svg | 1 + .../MR/images/ic_radio_button_unchecked.svg | 1 + 8 files changed, 529 insertions(+), 123 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radio_button_unchecked.svg diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index c95beecb59..366ce13847 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1912,7 +1912,7 @@ data class ChatItem ( } } - fun memberToModerate(chatInfo: ChatInfo): Pair? { + fun memberToModerate(chatInfo: ChatInfo): Pair? { return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { val m = chatInfo.groupInfo.membership if (m.memberRole >= GroupMemberRole.Admin && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) { @@ -1920,11 +1920,30 @@ data class ChatItem ( } else { null } + } else if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupSnd) { + val m = chatInfo.groupInfo.membership + if (m.memberRole >= GroupMemberRole.Admin) { + chatInfo.groupInfo to null + } else { + null + } } else { null } } + val showLocalDelete: Boolean + get() = when (content) { + is CIContent.SndDirectE2EEInfo -> false + is CIContent.RcvDirectE2EEInfo -> false + is CIContent.SndGroupE2EEInfo -> false + is CIContent.RcvGroupE2EEInfo -> false + else -> true + } + + val canBeDeletedForSelf: Boolean + get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete + val showNotification: Boolean get() = when (content) { is CIContent.SndMsgContent -> false 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 03742817f0..c65aaabc02 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 @@ -1,5 +1,7 @@ package chat.simplex.common.views.chat +import androidx.compose.animation.* +import androidx.compose.animation.core.* import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.gestures.* @@ -50,13 +52,14 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - val shouldReturn = remember { mutableStateOf(false) } val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } val showSearch = rememberSaveable { mutableStateOf(false) } - val activeChatInfo = remember { derivedStateOf { - val info = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo - if (info == null) { - shouldReturn.value = true + val activeChatInfo = remember { + derivedStateOf { + val info = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo + if (info == null) { + shouldReturn.value = true + } + return@derivedStateOf info ?: ChatInfo.Direct.sampleData } - return@derivedStateOf info ?: ChatInfo.Direct.sampleData - } } val user = chatModel.currentUser.value if (shouldReturn.value || user == null) { @@ -82,6 +85,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - val attachmentOption = rememberSaveable { mutableStateOf(null) } val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() + val selectedChatItems = rememberSaveable { mutableStateOf(null as Set?) } LaunchedEffect(Unit) { // snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value. // With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view @@ -95,8 +99,12 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } } + KeyChangeEffect(chatModel.chatId.value) { + if (chatModel.chatId.value != null) { + selectedChatItems.value = null + } + } val view = LocalMultiplatformView() - val chatRh = remoteHostId.value // We need to have real unreadCount value for displaying it inside top right button // Having activeChat reloaded on every change in it is inefficient (UI lags) @@ -117,26 +125,61 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - unreadCount, composeState, composeView = { - Column( - Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - if ( - chatInfo is ChatInfo.Direct - && !chatInfo.contact.sndReady - && chatInfo.contact.active - && !chatInfo.contact.nextSendGrpInv + if (selectedChatItems.value == null) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - generalGetString(MR.strings.contact_connection_pending), - Modifier.padding(top = 4.dp), - fontSize = 14.sp, - color = MaterialTheme.colors.secondary + if ( + chatInfo is ChatInfo.Direct + && !chatInfo.contact.sndReady + && chatInfo.contact.active + && !chatInfo.contact.nextSendGrpInv + ) { + Text( + generalGetString(MR.strings.contact_connection_pending), + Modifier.padding(top = 4.dp), + fontSize = 14.sp, + color = MaterialTheme.colors.secondary + ) + } + ComposeView( + chatModel, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, + showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } } ) } - ComposeView( - chatModel, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, - showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } } + } else { + SelectedItemsBottomToolbar( + chatItems = remember { chatModel.chatItems }.value, + selectedChatItems = selectedChatItems, + chatInfo = chatInfo, + deleteItems = { canDeleteForAll -> + val itemIds = selectedChatItems.value + if (itemIds != null) { + deleteMessagesAlertDialog( + itemIds.sorted(), + generalGetString(if (itemIds.size == 1) MR.strings.delete_message_mark_deleted_warning else MR.strings.delete_messages_mark_deleted_warning), + forAll = canDeleteForAll, + deleteMessages = { ids, forAll -> + deleteMessages(chatRh, chatInfo, ids, forAll, moderate = false) { + selectedChatItems.value = null + } + } + ) + } + }, + moderateItems = { + if (chatInfo is ChatInfo.Group) { + val itemIds = selectedChatItems.value + if (itemIds != null) { + moderateMessagesAlertDialog(itemIds.sorted(), moderateMessageQuestionText(chatInfo.featureEnabled(ChatFeature.FullDelete), itemIds.size), deleteMessages = { ids -> + deleteMessages(chatRh, chatInfo, ids, true, moderate = true) { + selectedChatItems.value = null + } + }) + } + } + } ) } }, @@ -145,6 +188,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - searchText, useLinkPreviews = useLinkPreviews, linkMode = chatModel.simplexLinkMode.value, + selectedChatItems = selectedChatItems, back = { hideKeyboard(view) AudioPlayer.stop() @@ -271,22 +315,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } }, - deleteMessages = { itemIds -> - if (itemIds.isNotEmpty()) { - withBGApi { - val deleted = chatModel.controller.apiDeleteChatItems( - chatRh, chatInfo.chatType, chatInfo.apiId, itemIds, CIDeleteMode.cidmInternal - ) - if (deleted != null) { - withChats { - for (di in deleted) { - removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) - } - } - } - } - } - }, + deleteMessages = { itemIds -> deleteMessages(chatRh, chatInfo, itemIds, false, moderate = false) }, receiveFile = { fileId -> withBGApi { chatModel.controller.receiveFile(chatRh, user, fileId) } }, @@ -536,6 +565,7 @@ fun ChatLayout( searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, + selectedChatItems: MutableState?>, back: () -> Unit, info: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, @@ -613,7 +643,13 @@ fun ChatLayout( } Scaffold( - topBar = { ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) }, + topBar = { + if (selectedChatItems.value == null) { + ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + } else { + SelectedItemsTopToolbar(selectedChatItems) + } + }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, @@ -636,7 +672,7 @@ fun ChatLayout( ) { ChatItemsList( remoteHostId, chatInfo, unreadCount, composeState, searchValue, - useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages, + useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, showViaProxy, @@ -901,6 +937,7 @@ fun BoxWithConstraintsScope.ChatItemsList( searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, + selectedChatItems: MutableState?>, showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, @@ -1014,86 +1051,117 @@ fun BoxWithConstraintsScope.ChatItemsList( tryOrShowError("${cItem.id}ChatItem", error = { CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) }) { - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, 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, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy) + ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, 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, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy) } } @Composable fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?) { - val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null - if (chatInfo is ChatInfo.Group) { - if (cItem.chatDir is CIDirection.GroupRcv) { - val member = cItem.chatDir.groupMember - val (prevMember, memCount) = - if (range != null) { - chatModel.getPrevHiddenMember(member, range) - } else { - null to 1 - } - if (prevItem == null || showMemberImage(member, prevItem) || prevMember != null) { - Column( - Modifier - .padding(top = 8.dp) - .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.Start - ) { - if (cItem.content.showMemberName) { - val memberNameStyle = SpanStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary) - val memberNameString = if (memCount == 1 && member.memberRole > GroupMemberRole.Member) { - buildAnnotatedString { - withStyle(memberNameStyle.copy(fontWeight = FontWeight.Medium)) { append(member.memberRole.text) } - append(" ") - withStyle(memberNameStyle) { append(memberNames(member, prevMember, memCount)) } - } - } else { - buildAnnotatedString { - withStyle(memberNameStyle) { append(memberNames(member, prevMember, memCount)) } - } - } - Text( - memberNameString, - Modifier.padding(start = MEMBER_IMAGE_SIZE + 10.dp), - maxLines = 2 - ) + val sent = cItem.chatDir.sent + Box(Modifier.padding(bottom = 4.dp)) { + 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 swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() } + if (chatInfo is ChatInfo.Group) { + if (cItem.chatDir is CIDirection.GroupRcv) { + val member = cItem.chatDir.groupMember + val (prevMember, memCount) = + if (range != null) { + chatModel.getPrevHiddenMember(member, range) + } else { + null to 1 } - Row( - swipeableModifier, - horizontalArrangement = Arrangement.spacedBy(4.dp) + if (prevItem == null || showMemberImage(member, prevItem) || prevMember != null) { + Column( + Modifier + .padding(top = 8.dp) + .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start ) { - Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { - MemberImage(member) + if (cItem.content.showMemberName) { + val memberNameStyle = SpanStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary) + val memberNameString = if (memCount == 1 && member.memberRole > GroupMemberRole.Member) { + buildAnnotatedString { + withStyle(memberNameStyle.copy(fontWeight = FontWeight.Medium)) { append(member.memberRole.text) } + append(" ") + withStyle(memberNameStyle) { append(memberNames(member, prevMember, memCount)) } + } + } else { + buildAnnotatedString { + withStyle(memberNameStyle) { append(memberNames(member, prevMember, memCount)) } + } + } + Text( + memberNameString, + Modifier.padding(start = MEMBER_IMAGE_SIZE + 10.dp), + maxLines = 2 + ) + } + Box(contentAlignment = Alignment.CenterStart) { + androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedChatItem(Modifier, cItem.id, selectedChatItems) + } + Row( + swipeableOrSelectionModifier, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { + MemberImage(member) + } + ChatItemViewShortHand(cItem, range) + } + } + } + } else { + Box(contentAlignment = Alignment.CenterStart) { + AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + Row( + Modifier + .padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp) + .then(swipeableOrSelectionModifier) + ) { + ChatItemViewShortHand(cItem, range) } - ChatItemViewShortHand(cItem, range) } } } else { - Row( - Modifier - .padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp) - .then(swipeableModifier) + Box(contentAlignment = Alignment.CenterStart) { + AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + Box( + Modifier + .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) + .then(if (selectionVisible) Modifier else swipeableModifier) + ) { + ChatItemViewShortHand(cItem, range) + } + } + } + } else { // direct message + Box(contentAlignment = Alignment.CenterStart) { + AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + Box( + Modifier.padding( + start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp, + end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, + ).then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier) ) { ChatItemViewShortHand(cItem, range) } } - } else { - Box( - Modifier - .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) - .then(swipeableModifier) - ) { - ChatItemViewShortHand(cItem, range) - } } - } else { // direct message - val sent = cItem.chatDir.sent - Box( - Modifier.padding( - start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp, - end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, - ).then(swipeableModifier) - ) { - ChatItemViewShortHand(cItem, range) + if (selectionVisible) { + Box(Modifier.matchParentSize().clickable { + val checked = selectedChatItems.value?.contains(cItem.id) == true + selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems) + }) } } } @@ -1418,6 +1486,96 @@ private fun bottomEndFloatingButton( } } +@Composable +private fun SelectedChatItem( + modifier: Modifier, + ciId: Long, + selectedChatItems: State?>, +) { + val checked = remember { derivedStateOf { selectedChatItems.value?.contains(ciId) == true } } + Icon( + painterResource(if (checked.value) MR.images.ic_check_circle_filled else MR.images.ic_radio_button_unchecked), + null, + modifier.size(22.dp * fontSizeMultiplier), + tint = if (checked.value) { + MaterialTheme.colors.primary + } else if (isInDarkTheme()) { + // .tertiaryLabel instead of .secondary + Color(red = 235f / 255f, 235f / 255f, 245f / 255f, 76f / 255f) + } else { + // .tertiaryLabel instead of .secondary + Color(red = 60f / 255f, 60f / 255f, 67f / 255f, 76f / 255f) + } + ) +} + +private fun selectUnselectChatItem(select: Boolean, ci: ChatItem, revealed: State, selectedChatItems: MutableState?>) { + val itemIds = mutableSetOf() + if (!revealed.value) { + val currIndex = chatModel.getChatItemIndexOrNull(ci) + val ciCategory = ci.mergeCategory + if (currIndex != null && ciCategory != null) { + val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val range = chatViewItemsRange(currIndex, prevHidden) + if (range != null) { + val reversedChatItems = chatModel.chatItems.asReversed() + for (i in range) { + itemIds.add(reversedChatItems[i].id) + } + } else { + itemIds.add(ci.id) + } + } else { + itemIds.add(ci.id) + } + } else { + itemIds.add(ci.id) + } + if (select) { + val sel = selectedChatItems.value ?: setOf() + selectedChatItems.value = sel.union(itemIds) + } else { + val sel = (selectedChatItems.value ?: setOf()).toMutableSet() + sel.removeAll(itemIds) + selectedChatItems.value = sel + } +} + +private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List, forAll: Boolean, moderate: Boolean, onSuccess: () -> Unit = {}) { + if (itemIds.isNotEmpty()) { + withBGApi { + val deleted = if (chatInfo is ChatInfo.Group && forAll && moderate) { + chatModel.controller.apiDeleteMemberChatItems( + chatRh, + groupId = chatInfo.groupInfo.groupId, + itemIds = itemIds + ) + } else { + chatModel.controller.apiDeleteChatItems( + chatRh, + type = chatInfo.chatType, + id = chatInfo.apiId, + itemIds = itemIds, + mode = if (forAll) CIDeleteMode.cidmBroadcast else CIDeleteMode.cidmInternal + ) + } + if (deleted != null) { + withChats { + for (di in deleted) { + val toChatItem = di.toChatItem?.chatItem + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) + } + } + } + onSuccess() + } + } + } +} + private fun markUnreadChatAsRead(chatId: String) { val chat = chatModel.chats.value.firstOrNull { it.id == chatId } if (chat?.chatStats?.unreadChat != true) return @@ -1595,6 +1753,7 @@ fun PreviewChatLayout() { searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, + selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, showMemberInfo = { _, _ -> }, @@ -1666,6 +1825,7 @@ fun PreviewGroupChatLayout() { searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, + selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, showMemberInfo = { _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt new file mode 100644 index 0000000000..f3519e9f28 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -0,0 +1,132 @@ +package chat.simplex.common.views.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.BackHandler +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.helpers.* +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +fun SelectedItemsTopToolbar(selectedChatItems: MutableState?>) { + val onBackClicked = { selectedChatItems.value = null } + BackHandler(onBack = onBackClicked) + val count = selectedChatItems.value?.size ?: 0 + DefaultTopAppBar( + navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) }, + title = { + Text( + if (count == 0) { + stringResource(MR.strings.selected_chat_items_nothing_selected) + } else { + stringResource(MR.strings.selected_chat_items_selected_n).format(count) + }, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + onTitleClick = null, + showSearch = false, + onSearchValueChanged = {}, + ) + Divider(Modifier.padding(top = AppBarHeight * fontSizeSqrtMultiplier)) +} + +@Composable +fun SelectedItemsBottomToolbar( + chatInfo: ChatInfo, + chatItems: List, + selectedChatItems: MutableState?>, + deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible + moderateItems: () -> Unit, +// shareItems: () -> Unit, +) { + val deleteEnabled = remember { mutableStateOf(false) } + val deleteForEveryoneEnabled = remember { mutableStateOf(false) } + val canModerate = remember { mutableStateOf(false) } + val moderateEnabled = remember { mutableStateOf(false) } + val allButtonsDisabled = remember { mutableStateOf(false) } + Box { + // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty + ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}) + Row(Modifier.matchParentSize().background(MaterialTheme.colors.background), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + IconButton({ deleteItems(deleteForEveryoneEnabled.value) }, enabled = deleteEnabled.value && !allButtonsDisabled.value) { + Icon( + painterResource(MR.images.ic_delete), + null, + Modifier.size(24.dp), + tint = if (!deleteEnabled.value || allButtonsDisabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.error + ) + } + + IconButton({ moderateItems() }, Modifier.alpha(if (canModerate.value) 1f else 0f), enabled = moderateEnabled.value && !allButtonsDisabled.value) { + Icon( + painterResource(MR.images.ic_flag), + null, + Modifier.size(24.dp), + tint = if (!moderateEnabled.value || allButtonsDisabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.error + ) + } + + IconButton({ /*shareItems()*/ }, Modifier.alpha(0f), enabled = false/*!allButtonsDisabled.value*/) { + Icon( + painterResource(MR.images.ic_share), + null, + Modifier.size(24.dp), + tint = if (allButtonsDisabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + } + } + LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) { + recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, allButtonsDisabled) + } +} + +private fun recheckItems(chatInfo: ChatInfo, + chatItems: List, + selectedChatItems: MutableState?>, + deleteEnabled: MutableState, + deleteForEveryoneEnabled: MutableState, + canModerate: MutableState, + moderateEnabled: MutableState, + allButtonsDisabled: MutableState +) { + val count = selectedChatItems.value?.size ?: 0 + allButtonsDisabled.value = count == 0 || count > 20 + canModerate.value = possibleToModerate(chatInfo) + val selected = selectedChatItems.value ?: return + var rDeleteEnabled = true + var rDeleteForEveryoneEnabled = true + var rModerateEnabled = true + var rOnlyOwnGroupItems = true + val rSelectedChatItems = mutableSetOf() + for (ci in chatItems) { + if (selected.contains(ci.id)) { + rDeleteEnabled = rDeleteEnabled && ci.canBeDeletedForSelf + rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote + rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd + rModerateEnabled = rModerateEnabled && !rOnlyOwnGroupItems && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null + rSelectedChatItems.add(ci.id) // we are collecting new selected items here to account for any changes in chat items list + } + } + deleteEnabled.value = rDeleteEnabled + deleteForEveryoneEnabled.value = rDeleteForEveryoneEnabled + moderateEnabled.value = rModerateEnabled + selectedChatItems.value = rSelectedChatItems +} + +private fun possibleToModerate(chatInfo: ChatInfo): Boolean = + chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Admin 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 6f5cb63262..29717e3ecf 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 @@ -50,6 +50,8 @@ fun ChatItemView( linkMode: SimplexLinkMode, revealed: MutableState, range: IntRange?, + selectedChatItems: MutableState?>, + selectChatItem: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, @@ -81,9 +83,7 @@ fun ChatItemView( val live = composeState.value.liveMessage != null Box( - modifier = Modifier - .padding(bottom = 4.dp) - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), contentAlignment = alignment, ) { val info = cItem.meta.itemStatus.statusInto @@ -142,14 +142,6 @@ fun ChatItemView( } } - fun moderateMessageQuestionText(): String { - return if (fullDeleteAllowed) { - generalGetString(MR.strings.moderate_message_will_be_deleted_warning) - } else { - generalGetString(MR.strings.moderate_message_will_be_marked_warning) - } - } - @Composable fun MsgReactionsMenu() { val rs = MsgReaction.values.mapNotNull { r -> @@ -180,6 +172,10 @@ fun ChatItemView( fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } } } @@ -277,8 +273,12 @@ fun ChatItemView( DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + if (groupInfo != null && cItem.chatDir !is CIDirection.GroupSnd) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + } + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) } } } @@ -293,12 +293,20 @@ fun ChatItemView( } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } } } cItem.isDeletedContent -> { DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } } } cItem.mergeCategory != null && ((range?.count() ?: 0) > 1 || revealed.value) -> { @@ -309,11 +317,19 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu) } DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } } } else -> { DefaultDropdownMenu(showMenu) { DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (selectedChatItems.value == null) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } } } } @@ -327,6 +343,10 @@ fun ChatItemView( } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } } } @@ -357,6 +377,10 @@ fun ChatItemView( DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } } } @@ -410,6 +434,10 @@ fun ChatItemView( DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } } } @@ -607,7 +635,12 @@ fun DeleteItemAction( for (i in range) { itemIds.add(reversedChatItems[i].id) } - deleteMessagesAlertDialog(itemIds, generalGetString(MR.strings.delete_message_mark_deleted_warning), deleteMessages = deleteMessages) + deleteMessagesAlertDialog( + itemIds, + generalGetString(if (itemIds.size == 1) MR.strings.delete_message_mark_deleted_warning else MR.strings.delete_messages_mark_deleted_warning), + forAll = false, + deleteMessages = { ids, _ -> deleteMessages(ids) } + ) } else { deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) } @@ -640,6 +673,21 @@ fun ModerateItemAction( ) } +@Composable +fun SelectItemAction( + showMenu: MutableState, + selectChatItem: () -> Unit, +) { + ItemAction( + stringResource(MR.strings.select_verb), + painterResource(MR.images.ic_check_circle), + onClick = { + showMenu.value = false + selectChatItem() + } + ) +} + @Composable private fun RevealItemAction(revealed: MutableState, showMenu: MutableState) { ItemAction( @@ -784,7 +832,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes ) } -fun deleteMessagesAlertDialog(itemIds: List, questionText: String, deleteMessages: (List) -> Unit) { +fun deleteMessagesAlertDialog(itemIds: List, questionText: String, forAll: Boolean, deleteMessages: (List, Boolean) -> Unit) { AlertManager.shared.showAlertDialogButtons( title = generalGetString(MR.strings.delete_messages__question).format(itemIds.size), text = questionText, @@ -796,14 +844,29 @@ fun deleteMessagesAlertDialog(itemIds: List, questionText: String, deleteM horizontalArrangement = Arrangement.Center, ) { TextButton(onClick = { - deleteMessages(itemIds) + deleteMessages(itemIds, false) AlertManager.shared.hideAlert() }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + + if (forAll) { + TextButton(onClick = { + deleteMessages(itemIds, true) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.for_everybody), color = MaterialTheme.colors.error) } + } } } ) } +fun moderateMessageQuestionText(fullDeleteAllowed: Boolean, count: Int): String { + return if (fullDeleteAllowed) { + generalGetString(if (count == 1) MR.strings.moderate_message_will_be_deleted_warning else MR.strings.moderate_messages_will_be_deleted_warning) + } else { + generalGetString(if (count == 1) MR.strings.moderate_message_will_be_marked_warning else MR.strings.moderate_messages_will_be_marked_warning) + } +} + fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_member_message__question), @@ -816,6 +879,16 @@ fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteM ) } +fun moderateMessagesAlertDialog(itemIds: List, questionText: String, deleteMessages: (List) -> Unit) { + AlertManager.shared.showAlertDialog( + title = if (itemIds.size == 1) generalGetString(MR.strings.delete_member_message__question) else generalGetString(MR.strings.delete_members_messages__question).format(itemIds.size), + text = questionText, + confirmText = generalGetString(MR.strings.delete_verb), + destructive = true, + onConfirm = { deleteMessages(itemIds) } + ) +} + expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) @Preview @@ -832,6 +905,8 @@ fun PreviewChatItemView( composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, revealed = remember { mutableStateOf(false) }, range = 0..1, + selectedChatItems = remember { mutableStateOf(setOf()) }, + selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, @@ -869,6 +944,8 @@ fun PreviewChatItemViewDeletedContent() { composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, revealed = remember { mutableStateOf(false) }, range = 0..1, + selectedChatItems = remember { mutableStateOf(setOf()) }, + selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index c7ae522684..28e9a997ae 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -53,6 +53,15 @@ fun NavigationButtonBack(onButtonClicked: (() -> Unit)?, tintColor: Color = if ( } } +@Composable +fun NavigationButtonClose(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, height: Dp = 24.dp) { + IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) { + Icon( + painterResource(MR.images.ic_close), stringResource(MR.strings.back), Modifier.height(height), tint = tintColor + ) + } +} + @Composable fun ShareButton(onButtonClicked: () -> Unit) { IconButton(onButtonClicked) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 02213d023a..68199c84af 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -311,14 +311,19 @@ Hide Allow Moderate + Select Expand Delete message? Delete %d messages? Message will be deleted - this cannot be undone! Message will be marked for deletion. The recipient(s) will be able to reveal this message. + Messages will be marked for deletion. The recipient(s) will be able to reveal these messages. Delete member message? + Delete %d messages of members? The message will be deleted for all members. + The messages will be deleted for all members. The message will be marked as moderated for all members. + The messages will be marked as moderated for all members. Delete for me For everyone Stop file @@ -368,6 +373,8 @@ No selected chat + Nothing selected + Selected %d Share message… diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle.svg new file mode 100644 index 0000000000..d44f9a2d1b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radio_button_unchecked.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radio_button_unchecked.svg new file mode 100644 index 0000000000..7433962953 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radio_button_unchecked.svg @@ -0,0 +1 @@ + \ No newline at end of file