android, desktop: multiple messages deletion (#4559)

* android, desktop: multiple messages deletion

* icons

* icon
This commit is contained in:
Stanislav Dmitrenko
2024-08-05 18:26:27 +09:00
committed by GitHub
parent e769abf14a
commit b2b1519aea
8 changed files with 529 additions and 123 deletions
@@ -1912,7 +1912,7 @@ data class ChatItem (
}
}
fun memberToModerate(chatInfo: ChatInfo): Pair<GroupInfo, GroupMember>? {
fun memberToModerate(chatInfo: ChatInfo): Pair<GroupInfo, GroupMember?>? {
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
@@ -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<String?>, 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<String?>, onComposed: suspend (chatId: String) -
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val selectedChatItems = rememberSaveable { mutableStateOf(null as Set<Long>?) }
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<String?>, 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<String?>, 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<String?>, 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<String?>, 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<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
selectedChatItems: MutableState<Set<Long>?>,
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<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
selectedChatItems: MutableState<Set<Long>?>,
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<Set<Long>?>,
) {
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<Boolean>, selectedChatItems: MutableState<Set<Long>?>) {
val itemIds = mutableSetOf<Long>()
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<Long>, 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 = { _, _ -> },
@@ -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<Set<Long>?>) {
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<ChatItem>,
selectedChatItems: MutableState<Set<Long>?>,
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<ChatItem>,
selectedChatItems: MutableState<Set<Long>?>,
deleteEnabled: MutableState<Boolean>,
deleteForEveryoneEnabled: MutableState<Boolean>,
canModerate: MutableState<Boolean>,
moderateEnabled: MutableState<Boolean>,
allButtonsDisabled: MutableState<Boolean>
) {
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<Long>()
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
@@ -50,6 +50,8 @@ fun ChatItemView(
linkMode: SimplexLinkMode,
revealed: MutableState<Boolean>,
range: IntRange?,
selectedChatItems: MutableState<Set<Long>?>,
selectChatItem: () -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> 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<Boolean>,
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<Boolean>, showMenu: MutableState<Boolean>) {
ItemAction(
@@ -784,7 +832,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
)
}
fun deleteMessagesAlertDialog(itemIds: List<Long>, questionText: String, deleteMessages: (List<Long>) -> Unit) {
fun deleteMessagesAlertDialog(itemIds: List<Long>, questionText: String, forAll: Boolean, deleteMessages: (List<Long>, 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<Long>, 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<Long>, questionText: String, deleteMessages: (List<Long>) -> 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 = { _ -> },
@@ -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) {
@@ -311,14 +311,19 @@
<string name="hide_verb">Hide</string>
<string name="allow_verb">Allow</string>
<string name="moderate_verb">Moderate</string>
<string name="select_verb">Select</string>
<string name="expand_verb">Expand</string>
<string name="delete_message__question">Delete message?</string>
<string name="delete_messages__question">Delete %d messages?</string>
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
<string name="delete_message_mark_deleted_warning">Message will be marked for deletion. The recipient(s) will be able to reveal this message.</string>
<string name="delete_messages_mark_deleted_warning">Messages will be marked for deletion. The recipient(s) will be able to reveal these messages.</string>
<string name="delete_member_message__question">Delete member message?</string>
<string name="delete_members_messages__question">Delete %d messages of members?</string>
<string name="moderate_message_will_be_deleted_warning">The message will be deleted for all members.</string>
<string name="moderate_messages_will_be_deleted_warning">The messages will be deleted for all members.</string>
<string name="moderate_message_will_be_marked_warning">The message will be marked as moderated for all members.</string>
<string name="moderate_messages_will_be_marked_warning">The messages will be marked as moderated for all members.</string>
<string name="for_me_only">Delete for me</string>
<string name="for_everybody">For everyone</string>
<string name="stop_file__action">Stop file</string>
@@ -368,6 +373,8 @@
<!-- ChatView.kt -->
<string name="no_selected_chat">No selected chat</string>
<string name="selected_chat_items_nothing_selected">Nothing selected</string>
<string name="selected_chat_items_selected_n">Selected %d</string>
<!-- ShareListView.kt -->
<string name="share_message">Share message…</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M421.5-300.5 701-580l-44.5-43.5-235 235-119-119L259-464l162.5 163.5ZM480.06-85q-80.97 0-153.13-31.26-72.15-31.27-125.79-85Q147.5-255 116.25-327.02 85-399.05 85-479.94q0-81.97 31.26-154.13 31.27-72.15 85-125.54Q255-813 327.02-844q72.03-31 152.92-31 81.97 0 154.13 31.13 72.17 31.13 125.55 84.5Q813-706 844-633.98q31 72.03 31 153.92 0 80.97-31.01 153.13-31.02 72.15-84.5 125.79Q706-147.5 633.98-116.25 561.95-85 480.06-85Zm-.09-57.5q140.53 0 239.03-98.97 98.5-98.96 98.5-238.5 0-140.53-98.47-239.03-98.46-98.5-239-98.5-139.53 0-238.53 98.47-99 98.46-99 239 0 139.53 98.97 238.53 98.96 99 238.5 99ZM480-480Z"/></svg>

After

Width:  |  Height:  |  Size: 729 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480.06-85q-80.97 0-153.13-31.26-72.15-31.27-125.79-85Q147.5-255 116.25-327.02 85-399.05 85-479.94q0-81.97 31.26-154.13 31.27-72.15 85-125.54Q255-813 327.02-844q72.03-31 152.92-31 81.97 0 154.13 31.13 72.17 31.13 125.55 84.5Q813-706 844-633.98q31 72.03 31 153.92 0 80.97-31.01 153.13-31.02 72.15-84.5 125.79Q706-147.5 633.98-116.25 561.95-85 480.06-85Zm-.09-57.5q140.53 0 239.03-98.97 98.5-98.96 98.5-238.5 0-140.53-98.47-239.03-98.46-98.5-239-98.5-139.53 0-238.53 98.47-99 98.46-99 239 0 139.53 98.97 238.53 98.96 99 238.5 99ZM480-480Z"/></svg>

After

Width:  |  Height:  |  Size: 661 B