android, desktop: bulk actions with group members (#5708)

* android, desktop: bulk actions with group members

* fix layout

* fix update

* fix responsivenes when closing selecting bar

* events

* unused

* role
This commit is contained in:
Stanislav Dmitrenko 2025-03-05 22:01:44 +07:00 committed by GitHub
parent 8c7df76c24
commit 9dac472191
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 531 additions and 140 deletions

View file

@ -1958,8 +1958,8 @@ data class GroupMember (
fun canBlockForAll(groupInfo: GroupInfo): Boolean { fun canBlockForAll(groupInfo: GroupInfo): Boolean {
val userRole = groupInfo.membership.memberRole val userRole = groupInfo.membership.memberRole
return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft && memberRole < GroupMemberRole.Admin return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft && memberRole < GroupMemberRole.Moderator
&& userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberActive && userRole >= GroupMemberRole.Moderator && userRole >= memberRole && groupInfo.membership.memberActive
} }
val memberIncognito = memberProfile.profileId != memberContactProfileId val memberIncognito = memberProfile.profileId != memberContactProfileId
@ -2439,14 +2439,14 @@ 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) { return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) {
val m = chatInfo.groupInfo.membership val m = chatInfo.groupInfo.membership
if (m.memberRole >= GroupMemberRole.Admin && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) { if (m.memberRole >= GroupMemberRole.Moderator && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) {
chatInfo.groupInfo to chatDir.groupMember chatInfo.groupInfo to chatDir.groupMember
} else { } else {
null null
} }
} else if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupSnd) { } else if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupSnd) {
val m = chatInfo.groupInfo.membership val m = chatInfo.groupInfo.membership
if (m.memberRole >= GroupMemberRole.Admin) { if (m.memberRole >= GroupMemberRole.Moderator) {
chatInfo.groupInfo to null chatInfo.groupInfo to null
} else { } else {
null null
@ -3259,6 +3259,7 @@ sealed class CIContent: ItemContent {
when (role) { when (role) {
GroupMemberRole.Owner -> generalGetString(MR.strings.feature_roles_owners) GroupMemberRole.Owner -> generalGetString(MR.strings.feature_roles_owners)
GroupMemberRole.Admin -> generalGetString(MR.strings.feature_roles_admins) GroupMemberRole.Admin -> generalGetString(MR.strings.feature_roles_admins)
GroupMemberRole.Moderator -> generalGetString(MR.strings.feature_roles_moderators)
else -> generalGetString(MR.strings.feature_roles_all_members) else -> generalGetString(MR.strings.feature_roles_all_members)
} }

View file

@ -175,7 +175,7 @@ fun ChatView(
) )
} }
} else { } else {
SelectedItemsBottomToolbar( SelectedItemsButtonsToolbar(
contentTag = contentTag, contentTag = contentTag,
selectedChatItems = selectedChatItems, selectedChatItems = selectedChatItems,
chatInfo = chatInfo, chatInfo = chatInfo,
@ -274,34 +274,46 @@ fun ChatView(
} }
if (!isActive) return@launch if (!isActive) return@launch
ModalManager.end.showModalCloseable(true) { close -> val selectedItems: MutableState<Set<Long>?> = mutableStateOf(null)
val chatInfo = remember { activeChatInfo }.value ModalManager.end.showCustomModal { close ->
if (chatInfo is ChatInfo.Direct) { val appBar = remember { mutableStateOf(null as @Composable (BoxScope.() -> Unit)?) }
var contactInfo: Pair<ConnectionStats?, Profile?>? by remember { mutableStateOf(preloadedContactInfo) } ModalView(close, appBar = appBar.value) {
var code: String? by remember { mutableStateOf(preloadedCode) } val chatInfo = remember { activeChatInfo }.value
KeyChangeEffect(chatInfo.id, ChatModel.networkStatuses.toMap()) { if (chatInfo is ChatInfo.Direct) {
contactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) var contactInfo: Pair<ConnectionStats?, Profile?>? by remember { mutableStateOf(preloadedContactInfo) }
preloadedContactInfo = contactInfo var code: String? by remember { mutableStateOf(preloadedCode) }
code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second KeyChangeEffect(chatInfo.id, ChatModel.networkStatuses.toMap()) {
preloadedCode = code contactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId)
preloadedContactInfo = contactInfo
code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second
preloadedCode = code
}
ChatInfoView(chatModel, chatInfo.contact, contactInfo?.first, contactInfo?.second, chatInfo.localAlias, code, close) {
showSearch.value = true
}
} else if (chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chatInfo.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chatInfo.id) {
setGroupMembers(chatRh, chatInfo.groupInfo, chatModel)
link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId)
preloadedLink = link
}
GroupChatInfoView(chatRh, chatInfo.id, link?.first, link?.second, selectedItems, appBar, scrollToItemId, {
link = it
preloadedLink = it
}, close, { showSearch.value = true })
} else {
LaunchedEffect(Unit) {
close()
}
} }
ChatInfoView(chatModel, chatInfo.contact, contactInfo?.first, contactInfo?.second, chatInfo.localAlias, code, close) {
showSearch.value = true
}
} else if (chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chatInfo.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chatInfo.id) {
setGroupMembers(chatRh, chatInfo.groupInfo, chatModel)
link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId)
preloadedLink = link
}
GroupChatInfoView(chatModel, chatRh, chatInfo.id, link?.first, link?.second, scrollToItemId, {
link = it
preloadedLink = it
}, close, { showSearch.value = true })
} else {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
close() snapshotFlow { activeChatInfo.value?.id }
.drop(1)
.collect {
appBar.value = null
selectedItems.value = null
}
} }
} }
} }
@ -788,7 +800,7 @@ fun ChatLayout(
) { ) {
AnimatedVisibility(selectedChatItems.value != null) { AnimatedVisibility(selectedChatItems.value != null) {
if (chatInfo != null) { if (chatInfo != null) {
SelectedItemsBottomToolbar( SelectedItemsButtonsToolbar(
contentTag = contentTag, contentTag = contentTag,
selectedChatItems = selectedChatItems, selectedChatItems = selectedChatItems,
chatInfo = chatInfo, chatInfo = chatInfo,
@ -846,7 +858,7 @@ fun ChatLayout(
if (selectedChatItems.value == null) { if (selectedChatItems.value == null) {
GroupReportsAppBar(contentTag, { ModalManager.end.closeModal() }, onSearchValueChanged) GroupReportsAppBar(contentTag, { ModalManager.end.closeModal() }, onSearchValueChanged)
} else { } else {
SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value) SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value)
} }
} }
} }
@ -858,7 +870,7 @@ fun ChatLayout(
ChatInfoToolbar(chatInfo, contentTag, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) ChatInfoToolbar(chatInfo, contentTag, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
} }
} else { } else {
SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value)
} }
} }
if (contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { if (contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) {
@ -1432,7 +1444,7 @@ fun BoxScope.ChatItemsList(
fun Item() { fun Item() {
ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) {
androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
SelectedChatItem(Modifier, cItem.id, selectedChatItems) SelectedListItem(Modifier, cItem.id, selectedChatItems)
} }
Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) {
val member = cItem.chatDir.groupMember val member = cItem.chatDir.groupMember
@ -1457,7 +1469,7 @@ fun BoxScope.ChatItemsList(
} else { } else {
ChatItemBox { ChatItemBox {
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
} }
Row( Row(
Modifier Modifier
@ -1472,7 +1484,7 @@ fun BoxScope.ChatItemsList(
} else { } else {
ChatItemBox { ChatItemBox {
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
} }
Box( Box(
Modifier Modifier
@ -1487,7 +1499,7 @@ fun BoxScope.ChatItemsList(
} else { // direct message } else { // direct message
ChatItemBox { ChatItemBox {
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
} }
Box( Box(
@ -2296,12 +2308,12 @@ private fun BoxScope.BottomEndFloatingButton(
} }
@Composable @Composable
private fun SelectedChatItem( fun SelectedListItem(
modifier: Modifier, modifier: Modifier,
ciId: Long, id: Long,
selectedChatItems: State<Set<Long>?>, selectedItems: State<Set<Long>?>,
) { ) {
val checked = remember { derivedStateOf { selectedChatItems.value?.contains(ciId) == true } } val checked = remember { derivedStateOf { selectedItems.value?.contains(id) == true } }
Icon( Icon(
painterResource(if (checked.value) MR.images.ic_check_circle_filled else MR.images.ic_radio_button_unchecked), painterResource(if (checked.value) MR.images.ic_check_circle_filled else MR.images.ic_radio_button_unchecked),
null, null,

View file

@ -9,12 +9,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.BackHandler import chat.simplex.common.platform.BackHandler
import chat.simplex.common.platform.chatModel import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
@ -23,32 +21,44 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
@Composable @Composable
fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>, onTop: Boolean) { fun BoxScope.SelectedItemsCounterToolbar(selectedItems: MutableState<Set<Long>?>, onTop: Boolean, selectAll: (() -> Unit)? = null) {
val onBackClicked = { selectedChatItems.value = null } val onBackClicked = { selectedItems.value = null }
BackHandler(onBack = onBackClicked) BackHandler(onBack = onBackClicked)
val count = selectedChatItems.value?.size ?: 0 val count = selectedItems.value?.size ?: 0
DefaultAppBar( Box(if (onTop) Modifier else Modifier.imePadding()) {
navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) }, DefaultAppBar(
title = { navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) },
Text( title = {
if (count == 0) { Text(
stringResource(MR.strings.selected_chat_items_nothing_selected) if (count == 0) {
} else { stringResource(MR.strings.selected_chat_items_nothing_selected)
stringResource(MR.strings.selected_chat_items_selected_n).format(count) } else {
}, stringResource(MR.strings.selected_chat_items_selected_n).format(count)
fontWeight = FontWeight.SemiBold, },
maxLines = 1, fontWeight = FontWeight.SemiBold,
overflow = TextOverflow.Ellipsis maxLines = 1,
) overflow = TextOverflow.Ellipsis
}, )
onTitleClick = null, },
onTop = onTop, onTitleClick = null,
onSearchValueChanged = {}, onTop = onTop,
) onSearchValueChanged = {},
buttons = if (selectAll != null) { { SelectAllButton(selectAll) } } else {{}}
)
}
} }
@Composable @Composable
fun SelectedItemsBottomToolbar( private fun SelectAllButton(onClick: () -> Unit) {
IconButton(onClick) {
Icon(
painterResource(MR.images.ic_checklist), stringResource(MR.strings.back), Modifier.height(24.dp), tint = MaterialTheme.colors.primary
)
}
}
@Composable
fun SelectedItemsButtonsToolbar(
chatInfo: ChatInfo, chatInfo: ChatInfo,
contentTag: MsgContentTag?, contentTag: MsgContentTag?,
selectedChatItems: MutableState<Set<Long>?>, selectedChatItems: MutableState<Set<Long>?>,
@ -162,4 +172,4 @@ private fun recheckItems(chatInfo: ChatInfo,
} }
private fun possibleToModerate(chatInfo: ChatInfo): Boolean = private fun possibleToModerate(chatInfo: ChatInfo): Boolean =
chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Admin chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator

View file

@ -8,6 +8,8 @@ import SectionItemViewLongClickable
import SectionSpacer import SectionSpacer
import SectionTextFooter import SectionTextFooter
import SectionView import SectionView
import androidx.compose.animation.*
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@ -17,6 +19,7 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@ -37,7 +40,7 @@ import chat.simplex.common.views.usersettings.*
import chat.simplex.common.model.GroupInfo import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.*
import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.database.TtlOptions import chat.simplex.common.views.database.TtlOptions
import chat.simplex.res.MR import chat.simplex.res.MR
@ -49,7 +52,18 @@ val MEMBER_ROW_AVATAR_SIZE = 42.dp
val MEMBER_ROW_VERTICAL_PADDING = 8.dp val MEMBER_ROW_VERTICAL_PADDING = 8.dp
@Composable @Composable
fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, scrollToItemId: MutableState<Long?>, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { fun ModalData.GroupChatInfoView(
rhId: Long?,
chatId: String,
groupLink: String?,
groupLinkMemberRole: GroupMemberRole?,
selectedItems: MutableState<Set<Long>?>,
appBar: MutableState<@Composable (BoxScope.() -> Unit)?>,
scrollToItemId: MutableState<Long?>,
onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit,
close: () -> Unit,
onSearchClicked: () -> Unit
) {
BackHandler(onBack = close) BackHandler(onBack = close)
// TODO derivedStateOf? // TODO derivedStateOf?
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId }
@ -82,12 +96,14 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin
setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) setChatTTLAlert(chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems)
}, },
members = remember { chatModel.groupMembers }.value activeSortedMembers = remember { chatModel.groupMembers }.value
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedByDescending { it.memberRole }, .sortedByDescending { it.memberRole },
developerTools, developerTools,
onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) }, onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) },
groupLink, groupLink,
selectedItems,
appBar,
scrollToItemId, scrollToItemId,
addMembers = { addMembers = {
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
@ -212,21 +228,23 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe
text = generalGetString(messageId), text = generalGetString(messageId),
confirmText = generalGetString(MR.strings.remove_member_confirmation), confirmText = generalGetString(MR.strings.remove_member_confirmation),
onConfirm = { onConfirm = {
withBGApi { removeMembers(rhId, groupInfo, listOf(mem.groupMemberId))
val updatedMembers = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, listOf(mem.groupMemberId)) },
if (updatedMembers != null) { destructive = true,
withChats { )
updatedMembers.forEach { updatedMember -> }
upsertGroupMember(rhId, groupInfo, updatedMember)
} private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, onSuccess: () -> Unit = {}) {
} val messageId = if (groupInfo.businessChat == null)
withReportsChatsIfOpen { MR.strings.members_will_be_removed_from_group_cannot_be_undone
updatedMembers.forEach { updatedMember -> else
upsertGroupMember(rhId, groupInfo, updatedMember) MR.strings.members_will_be_removed_from_chat_cannot_be_undone
} AlertManager.shared.showAlertDialog(
} title = generalGetString(MR.strings.button_remove_members_question),
} text = generalGetString(messageId),
} confirmText = generalGetString(MR.strings.remove_member_confirmation),
onConfirm = {
removeMembers(rhId, groupInfo, memberIds, onSuccess)
}, },
destructive = true, destructive = true,
) )
@ -309,10 +327,12 @@ fun ModalData.GroupChatInfoLayout(
setSendReceipts: (SendReceipts) -> Unit, setSendReceipts: (SendReceipts) -> Unit,
chatItemTTL: MutableState<ChatItemTTL?>, chatItemTTL: MutableState<ChatItemTTL?>,
setChatItemTTL: (ChatItemTTL?) -> Unit, setChatItemTTL: (ChatItemTTL?) -> Unit,
members: List<GroupMember>, activeSortedMembers: List<GroupMember>,
developerTools: Boolean, developerTools: Boolean,
onLocalAliasChanged: (String) -> Unit, onLocalAliasChanged: (String) -> Unit,
groupLink: String?, groupLink: String?,
selectedItems: MutableState<Set<Long>?>,
appBar: MutableState<@Composable (BoxScope.() -> Unit)?>,
scrollToItemId: MutableState<Long?>, scrollToItemId: MutableState<Long?>,
addMembers: () -> Unit, addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit, showMemberInfo: (GroupMember) -> Unit,
@ -333,20 +353,37 @@ fun ModalData.GroupChatInfoLayout(
scope.launch { listState.scrollToItem(0) } scope.launch { listState.scrollToItem(0) }
} }
val searchText = remember { stateGetOrPut("searchText") { TextFieldValue() } } val searchText = remember { stateGetOrPut("searchText") { TextFieldValue() } }
val filteredMembers = remember(members) { val filteredMembers = remember(activeSortedMembers) {
derivedStateOf { derivedStateOf {
val s = searchText.value.text.trim().lowercase() val s = searchText.value.text.trim().lowercase()
if (s.isEmpty()) members else members.filter { m -> m.anyNameContains(s) } if (s.isEmpty()) activeSortedMembers else activeSortedMembers.filter { m -> m.anyNameContains(s) }
} }
} }
Box { Box {
val oneHandUI = remember { appPrefs.oneHandUI.state } val oneHandUI = remember { appPrefs.oneHandUI.state }
val selectedItemsBarHeight = if (selectedItems.value != null) AppBarHeight * fontSizeSqrtMultiplier else 0.dp
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val imePadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding()
LazyColumnWithScrollBar( LazyColumnWithScrollBar(
state = listState, state = listState,
contentPadding = if (oneHandUI.value) { contentPadding = if (oneHandUI.value) {
PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) PaddingValues(
top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp,
bottom = navBarPadding +
imePadding +
selectedItemsBarHeight +
// TODO: that's workaround but works. Actually, something in the codebase doesn't consume padding for AppBar and it produce
// different padding when the user has NavigationBar and doesn't have it with ime shown (developer options helps to test it nav bars)
(if (navBarPadding > 0.dp && imePadding > 0.dp) 0.dp else AppBarHeight * fontSizeSqrtMultiplier)
)
} else { } else {
PaddingValues(top = topPaddingToContent(false)) PaddingValues(
top = topPaddingToContent(false),
bottom = navBarPadding +
imePadding +
selectedItemsBarHeight +
(if (navBarPadding > 0.dp && imePadding > 0.dp) -AppBarHeight * fontSizeSqrtMultiplier else 0.dp)
)
} }
) { ) {
item { item {
@ -401,7 +438,7 @@ fun ModalData.GroupChatInfoLayout(
} }
} }
} }
if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
} else { } else {
SendReceiptsOptionDisabled() SendReceiptsOptionDisabled()
@ -424,7 +461,7 @@ fun ModalData.GroupChatInfoLayout(
ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems) ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems)
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true)
SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) { SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) {
if (groupInfo.canAddMembers) { if (groupInfo.canAddMembers) {
if (groupInfo.businessChat == null) { if (groupInfo.businessChat == null) {
if (groupLink == null) { if (groupLink == null) {
@ -442,7 +479,7 @@ fun ModalData.GroupChatInfoLayout(
} }
AddMembersButton(addMembersTitleId, tint, onAddMembersClick) AddMembersButton(addMembersTitleId, tint, onAddMembersClick)
} }
if (members.size > 8) { if (activeSortedMembers.size > 8) {
SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) { SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText) SearchRowView(searchText)
} }
@ -452,12 +489,34 @@ fun ModalData.GroupChatInfoLayout(
} }
} }
} }
items(filteredMembers.value) { member -> items(filteredMembers.value, key = { it.groupMemberId }) { member ->
Divider() Divider()
val showMenu = remember { mutableStateOf(false) } val showMenu = remember { mutableStateOf(false) }
SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { val canBeSelected = groupInfo.membership.memberRole >= member.memberRole && member.memberRole < GroupMemberRole.Moderator
DropDownMenuForMember(chat.remoteHostId, member, groupInfo, showMenu) SectionItemViewLongClickable(
MemberRow(member) click = {
if (selectedItems.value != null) {
if (canBeSelected) {
toggleItemSelection(member.groupMemberId, selectedItems)
}
} else {
showMemberInfo(member)
}
},
longClick = { showMenu.value = true },
minHeight = 54.dp,
padding = PaddingValues(horizontal = DEFAULT_PADDING)
) {
Box(contentAlignment = Alignment.CenterStart) {
androidx.compose.animation.AnimatedVisibility(selectedItems.value != null, enter = fadeIn(), exit = fadeOut()) {
SelectedListItem(Modifier.alpha(if (canBeSelected) 1f else 0f).padding(start = 2.dp), member.groupMemberId, selectedItems)
}
val selectionOffset by animateDpAsState(if (selectedItems.value != null) 20.dp + 22.dp * fontSizeMultiplier else 0.dp)
DropDownMenuForMember(chat.remoteHostId, member, groupInfo, selectedItems, showMenu)
Box(Modifier.padding(start = selectionOffset)) {
MemberRow(member)
}
}
} }
} }
item { item {
@ -482,12 +541,92 @@ fun ModalData.GroupChatInfoLayout(
} }
} }
SectionBottomSpacer() SectionBottomSpacer()
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
} }
} }
if (!oneHandUI.value) { if (!oneHandUI.value) {
NavigationBarBackground(oneHandUI.value, oneHandUI.value) NavigationBarBackground(oneHandUI.value, oneHandUI.value)
} }
SelectedItemsButtonsToolbar(chat, groupInfo, selectedItems, rememberUpdatedState(activeSortedMembers))
SelectedItemsCounterToolbarSetter(groupInfo, selectedItems, filteredMembers, appBar)
}
}
@Composable
private fun BoxScope.SelectedItemsButtonsToolbar(chat: Chat, groupInfo: GroupInfo, selectedItems: MutableState<Set<Long>?>, activeMembers: State<List<GroupMember>>) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
Column(Modifier.align(Alignment.BottomCenter)) {
AnimatedVisibility(selectedItems.value != null) {
SelectedItemsMembersToolbar(
selectedItems = selectedItems,
activeMembers = activeMembers,
groupInfo = groupInfo,
delete = {
removeMembersAlert(chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) {
selectedItems.value = null
}
},
blockForAll = { block ->
if (block) {
blockForAllAlert(chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) {
selectedItems.value = null
}
} else {
unblockForAllAlert(chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) {
selectedItems.value = null
}
}
},
changeRole = { toRole ->
updateMembersRoleDialog(toRole, groupInfo) {
updateMembersRole(toRole, chat.remoteHostId, groupInfo, selectedItems.value!!.sorted()) {
selectedItems.value = null
}
}
}
)
}
if (oneHandUI.value) {
// That's placeholder to take some space for bottom app bar in oneHandUI
Box(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier))
}
}
}
@Composable
private fun SelectedItemsCounterToolbarSetter(
groupInfo: GroupInfo,
selectedItems: MutableState<Set<Long>?>,
filteredMembers: State<List<GroupMember>>,
appBar: MutableState<@Composable (BoxScope.() -> Unit)?>
) {
LaunchedEffect(
groupInfo,
/* variable, not value - intentionally - to reduce work but handle variable change because it changes in remember(members) { derivedState {} } */
filteredMembers
) {
snapshotFlow { selectedItems.value == null }
.collect { nullItems ->
if (!nullItems) {
appBar.value = {
SelectedItemsCounterToolbar(selectedItems, !remember { appPrefs.oneHandUI.state }.value) {
if (!groupInfo.membership.memberActive) return@SelectedItemsCounterToolbar
val ids: MutableSet<Long> = mutableSetOf()
for (mem in filteredMembers.value) {
if (groupInfo.membership.memberActive && groupInfo.membership.memberRole >= mem.memberRole && mem.memberRole < GroupMemberRole.Moderator) {
ids.add(mem.groupMemberId)
}
}
if (ids.isNotEmpty() && (selectedItems.value ?: setOf()).containsAll(ids)) {
selectedItems.value = (selectedItems.value ?: setOf()).minus(ids)
} else {
selectedItems.value = (selectedItems.value ?: setOf()).union(ids)
}
}
}
} else {
appBar.value = null
}
}
} }
} }
@ -612,7 +751,7 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr
Text(stringResource(MR.strings.member_info_member_blocked), color = MaterialTheme.colors.secondary) Text(stringResource(MR.strings.member_info_member_blocked), color = MaterialTheme.colors.secondary)
} else { } else {
val role = member.memberRole val role = member.memberRole
if (role in listOf(GroupMemberRole.Owner, GroupMemberRole.Admin, GroupMemberRole.Observer)) { if (role in listOf(GroupMemberRole.Owner, GroupMemberRole.Admin, GroupMemberRole.Moderator, GroupMemberRole.Observer)) {
Text(role.text, color = MaterialTheme.colors.secondary) Text(role.text, color = MaterialTheme.colors.secondary)
} }
} }
@ -686,8 +825,8 @@ private fun MemberVerifiedShield() {
} }
@Composable @Composable
private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) { private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, selectedItems: MutableState<Set<Long>?>, showMenu: MutableState<Boolean>) {
if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) { if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
val canBlockForAll = member.canBlockForAll(groupInfo) val canBlockForAll = member.canBlockForAll(groupInfo)
val canRemove = member.canBeRemoved(groupInfo) val canRemove = member.canBeRemoved(groupInfo)
if (canBlockForAll || canRemove) { if (canBlockForAll || canRemove) {
@ -711,6 +850,10 @@ private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: G
showMenu.value = false showMenu.value = false
}) })
} }
if (selectedItems.value == null && member.memberRole < GroupMemberRole.Moderator) {
Divider()
SelectItemAction(showMenu) { toggleItemSelection(member.groupMemberId, selectedItems) }
}
} }
} }
} else if (!member.blockedByAdmin) { } else if (!member.blockedByAdmin) {
@ -819,6 +962,37 @@ private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel)
} }
} }
fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, onSuccess: () -> Unit = {}) {
withBGApi {
val updatedMembers = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds)
if (updatedMembers != null) {
withChats {
updatedMembers.forEach { updatedMember ->
upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
withReportsChatsIfOpen {
updatedMembers.forEach { updatedMember ->
upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
onSuccess()
}
}
}
fun <T> toggleItemSelection(itemId: T, selectedItems: MutableState<Set<T>?>) {
val select = selectedItems.value?.contains(itemId) != true
if (select) {
val sel = selectedItems.value ?: setOf()
selectedItems.value = sel + itemId
} else {
val sel = (selectedItems.value ?: setOf()).toMutableSet()
sel.remove(itemId)
selectedItems.value = sel
}
}
@Preview @Preview
@Composable @Composable
fun PreviewGroupChatInfoLayout() { fun PreviewGroupChatInfoLayout() {
@ -835,10 +1009,12 @@ fun PreviewGroupChatInfoLayout() {
setSendReceipts = {}, setSendReceipts = {},
chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) }, chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) },
setChatItemTTL = {}, setChatItemTTL = {},
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), activeSortedMembers = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false, developerTools = false,
onLocalAliasChanged = {}, onLocalAliasChanged = {},
groupLink = null, groupLink = null,
selectedItems = remember { mutableStateOf(null) },
appBar = remember { mutableStateOf(null) },
scrollToItemId = remember { mutableStateOf(null) }, scrollToItemId = remember { mutableStateOf(null) },
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, deletingItems = remember { mutableStateOf(true) } addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, deletingItems = remember { mutableStateOf(true) }
) )

View file

@ -137,26 +137,10 @@ fun GroupMemberInfoView(
if (it == newRole.value) return@GroupMemberInfoLayout if (it == newRole.value) return@GroupMemberInfoLayout
val prevValue = newRole.value val prevValue = newRole.value
newRole.value = it newRole.value = it
updateMemberRoleDialog(it, groupInfo, member, onDismiss = { updateMemberRoleDialog(it, groupInfo, member.memberCurrent, onDismiss = {
newRole.value = prevValue newRole.value = prevValue
}) { }) {
withBGApi { updateMembersRole(newRole.value, rhId, groupInfo, listOf(member.groupMemberId), onFailure = { newRole.value = prevValue })
kotlin.runCatching {
val members = chatModel.controller.apiMembersRole(rhId, groupInfo.groupId, listOf(member.groupMemberId), it)
withChats {
members.forEach { member ->
upsertGroupMember(rhId, groupInfo, member)
}
}
withReportsChatsIfOpen {
members.forEach { member ->
upsertGroupMember(rhId, groupInfo, member)
}
}
}.onFailure {
newRole.value = prevValue
}
}
} }
}, },
switchMemberAddress = { switchMemberAddress = {
@ -317,7 +301,7 @@ fun GroupMemberInfoLayout(
} }
@Composable @Composable
fun AdminDestructiveSection() { fun ModeratorDestructiveSection() {
val canBlockForAll = member.canBlockForAll(groupInfo) val canBlockForAll = member.canBlockForAll(groupInfo)
val canRemove = member.canBeRemoved(groupInfo) val canRemove = member.canBeRemoved(groupInfo)
if (canBlockForAll || canRemove) { if (canBlockForAll || canRemove) {
@ -494,8 +478,8 @@ fun GroupMemberInfoLayout(
} }
} }
if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) { if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
AdminDestructiveSection() ModeratorDestructiveSection()
} else { } else {
NonAdminBlockSection() NonAdminBlockSection()
} }
@ -709,16 +693,37 @@ fun MemberProfileImage(
) )
} }
private fun updateMemberRoleDialog( fun updateMembersRole(newRole: GroupMemberRole, rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, onFailure: () -> Unit = {}, onSuccess: () -> Unit = {}) {
withBGApi {
kotlin.runCatching {
val members = chatModel.controller.apiMembersRole(rhId, groupInfo.groupId, memberIds, newRole)
withChats {
members.forEach { member ->
upsertGroupMember(rhId, groupInfo, member)
}
}
withReportsChatsIfOpen {
members.forEach { member ->
upsertGroupMember(rhId, groupInfo, member)
}
}
onSuccess()
}.onFailure {
onFailure()
}
}
}
fun updateMemberRoleDialog(
newRole: GroupMemberRole, newRole: GroupMemberRole,
groupInfo: GroupInfo, groupInfo: GroupInfo,
member: GroupMember, memberCurrent: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: () -> Unit onConfirm: () -> Unit
) { ) {
AlertManager.shared.showAlertDialog( AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.change_member_role_question), title = generalGetString(MR.strings.change_member_role_question),
text = if (member.memberCurrent) { text = if (memberCurrent) {
if (groupInfo.businessChat == null) if (groupInfo.businessChat == null)
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text) String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text)
else else
@ -732,6 +737,22 @@ private fun updateMemberRoleDialog(
) )
} }
fun updateMembersRoleDialog(
newRole: GroupMemberRole,
groupInfo: GroupInfo,
onConfirm: () -> Unit
) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.change_member_role_question),
text = if (groupInfo.businessChat == null)
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text)
else
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_chat), newRole.text),
confirmText = generalGetString(MR.strings.change_verb),
onConfirm = onConfirm,
)
}
fun connectViaMemberAddressAlert(rhId: Long?, connReqUri: String) { fun connectViaMemberAddressAlert(rhId: Long?, connReqUri: String) {
try { try {
withBGApi { withBGApi {
@ -793,7 +814,19 @@ fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) {
text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName),
confirmText = generalGetString(MR.strings.block_for_all), confirmText = generalGetString(MR.strings.block_for_all),
onConfirm = { onConfirm = {
blockMemberForAll(rhId, gInfo, mem, true) blockMemberForAll(rhId, gInfo, listOf(mem.groupMemberId), true)
},
destructive = true,
)
}
fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List<Long>, onSuccess: () -> Unit = {}) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.block_members_for_all_question),
text = generalGetString(MR.strings.block_members_desc),
confirmText = generalGetString(MR.strings.block_for_all),
onConfirm = {
blockMemberForAll(rhId, gInfo, memberIds, true, onSuccess)
}, },
destructive = true, destructive = true,
) )
@ -805,14 +838,25 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) {
text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName),
confirmText = generalGetString(MR.strings.unblock_for_all), confirmText = generalGetString(MR.strings.unblock_for_all),
onConfirm = { onConfirm = {
blockMemberForAll(rhId, gInfo, mem, false) blockMemberForAll(rhId, gInfo, listOf(mem.groupMemberId), false)
}, },
) )
} }
fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List<Long>, onSuccess: () -> Unit = {}) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.unblock_members_for_all_question),
text = generalGetString(MR.strings.unblock_members_desc),
confirmText = generalGetString(MR.strings.unblock_for_all),
onConfirm = {
blockMemberForAll(rhId, gInfo, memberIds, false, onSuccess)
},
)
}
fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, memberIds: List<Long>, blocked: Boolean, onSuccess: () -> Unit = {}) {
withBGApi { withBGApi {
val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, listOf(member.groupMemberId), blocked) val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, memberIds, blocked)
withChats { withChats {
updatedMembers.forEach { updatedMember -> updatedMembers.forEach { updatedMember ->
upsertGroupMember(rhId, gInfo, updatedMember) upsertGroupMember(rhId, gInfo, updatedMember)
@ -823,6 +867,7 @@ fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocke
upsertGroupMember(rhId, gInfo, updatedMember) upsertGroupMember(rhId, gInfo, updatedMember)
} }
} }
onSuccess()
} }
} }

View file

@ -0,0 +1,129 @@
package chat.simplex.common.views.chat.group
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.focus.FocusRequester
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.WarningOrange
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun SelectedItemsMembersToolbar(
selectedItems: MutableState<Set<Long>?>,
activeMembers: State<List<GroupMember>>,
groupInfo: GroupInfo,
delete: () -> Unit,
blockForAll: (Boolean) -> Unit, // Boolean - block or unlock
changeRole: (GroupMemberRole) -> Unit,
) {
val deleteEnabled = remember { mutableStateOf(false) }
val blockForAllEnabled = remember { mutableStateOf(false) }
val unblockForAllEnabled = remember { mutableStateOf(false) }
val blockForAllButtonEnabled = remember { derivedStateOf { (blockForAllEnabled.value && !unblockForAllEnabled.value) || (!blockForAllEnabled.value && unblockForAllEnabled.value) } }
val roleToMemberEnabled = remember { mutableStateOf(false) }
val roleToObserverEnabled = remember { mutableStateOf(false) }
val roleButtonEnabled = remember { derivedStateOf { (roleToMemberEnabled.value && !roleToObserverEnabled.value) || (!roleToMemberEnabled.value && roleToObserverEnabled.value) } }
Box(
Modifier
.background(MaterialTheme.colors.background)
.navigationBarsPadding()
.imePadding()
) {
// It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty
Box(Modifier.alpha(0f)) {
ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() })
}
Row(
Modifier
.matchParentSize()
.padding(horizontal = 2.dp)
.height(AppBarHeight * fontSizeSqrtMultiplier)
.pointerInput(Unit) {
detectGesture {
true
}
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(delete, enabled = deleteEnabled.value) {
Icon(
painterResource(MR.images.ic_delete),
null,
Modifier.size(22.dp),
tint = if (!deleteEnabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.error
)
}
IconButton({ blockForAll(blockForAllEnabled.value) }, enabled = blockForAllButtonEnabled.value) {
Icon(
painterResource(if (unblockForAllEnabled.value && blockForAllButtonEnabled.value) MR.images.ic_do_not_touch else MR.images.ic_back_hand),
null,
Modifier.size(22.dp),
tint = if (!blockForAllButtonEnabled.value) MaterialTheme.colors.secondary else if (blockForAllEnabled.value) MaterialTheme.colors.error else WarningOrange
)
}
IconButton({ changeRole(if (roleToMemberEnabled.value) GroupMemberRole.Member else GroupMemberRole.Observer) }, enabled = roleButtonEnabled.value) {
Icon(
painterResource(if (roleToObserverEnabled.value || !roleButtonEnabled.value) MR.images.ic_person else MR.images.ic_person_edit),
null,
Modifier.size(22.dp),
tint = if (!roleButtonEnabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}
Divider(Modifier.align(Alignment.TopStart))
}
LaunchedEffect(groupInfo, activeMembers.value.toList(), selectedItems.value) {
recheckItems(groupInfo, selectedItems, activeMembers.value, deleteEnabled, blockForAllEnabled, unblockForAllEnabled, roleToMemberEnabled, roleToObserverEnabled)
}
}
private fun recheckItems(
groupInfo: GroupInfo,
selectedItems: MutableState<Set<Long>?>,
activeMembers: List<GroupMember>,
deleteEnabled: MutableState<Boolean>,
blockForAllEnabled: MutableState<Boolean>,
unblockForAllEnabled: MutableState<Boolean>,
roleToMemberEnabled: MutableState<Boolean>,
roleToObserverEnabled: MutableState<Boolean>,
) {
val selected = selectedItems.value ?: return
var rDeleteEnabled = true
var rBlockForAllEnabled = true
var rUnblockForAllEnabled = true
var rRoleToMemberEnabled = true
var rRoleToObserverEnabled = true
val rSelectedItems = mutableSetOf<Long>()
for (mem in activeMembers) {
if (selected.contains(mem.groupMemberId) && groupInfo.membership.memberRole >= mem.memberRole && mem.memberRole < GroupMemberRole.Moderator && groupInfo.membership.memberActive) {
rDeleteEnabled = rDeleteEnabled && mem.memberStatus != GroupMemberStatus.MemRemoved && mem.memberStatus != GroupMemberStatus.MemLeft
rBlockForAllEnabled = rBlockForAllEnabled && !mem.blockedByAdmin
rUnblockForAllEnabled = rUnblockForAllEnabled && mem.blockedByAdmin
rRoleToMemberEnabled = rRoleToMemberEnabled && mem.memberRole != GroupMemberRole.Member
rRoleToObserverEnabled = rRoleToObserverEnabled && mem.memberRole != GroupMemberRole.Observer
rSelectedItems.add(mem.groupMemberId) // we are collecting new selected items here to account for any changes in members list
}
}
deleteEnabled.value = rDeleteEnabled
blockForAllEnabled.value = rBlockForAllEnabled
unblockForAllEnabled.value = rUnblockForAllEnabled
roleToMemberEnabled.value = rRoleToMemberEnabled
roleToObserverEnabled.value = rRoleToObserverEnabled
selectedItems.value = rSelectedItems
}

View file

@ -21,6 +21,7 @@ import chat.simplex.res.MR
private val featureRoles: List<Pair<GroupMemberRole?, String>> = listOf( private val featureRoles: List<Pair<GroupMemberRole?, String>> = listOf(
null to generalGetString(MR.strings.feature_roles_all_members), null to generalGetString(MR.strings.feature_roles_all_members),
GroupMemberRole.Moderator to generalGetString(MR.strings.feature_roles_moderators),
GroupMemberRole.Admin to generalGetString(MR.strings.feature_roles_admins), GroupMemberRole.Admin to generalGetString(MR.strings.feature_roles_admins),
GroupMemberRole.Owner to generalGetString(MR.strings.feature_roles_owners) GroupMemberRole.Owner to generalGetString(MR.strings.feature_roles_owners)
) )

View file

@ -865,14 +865,14 @@ fun ModerateItemAction(
@Composable @Composable
fun SelectItemAction( fun SelectItemAction(
showMenu: MutableState<Boolean>, showMenu: MutableState<Boolean>,
selectChatItem: () -> Unit, selectItem: () -> Unit,
) { ) {
ItemAction( ItemAction(
stringResource(MR.strings.select_verb), stringResource(MR.strings.select_verb),
painterResource(MR.images.ic_check_circle), painterResource(MR.images.ic_check_circle),
onClick = { onClick = {
showMenu.value = false showMenu.value = false
selectChatItem() selectItem()
} }
) )
} }

View file

@ -32,6 +32,7 @@ fun ModalView(
searchAlwaysVisible: Boolean = false, searchAlwaysVisible: Boolean = false,
onSearchValueChanged: (String) -> Unit = {}, onSearchValueChanged: (String) -> Unit = {},
endButtons: @Composable RowScope.() -> Unit = {}, endButtons: @Composable RowScope.() -> Unit = {},
appBar: @Composable (BoxScope.() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit,
) { ) {
if (showClose && showAppBar) { if (showClose && showAppBar) {
@ -48,14 +49,20 @@ fun ModalView(
StatusBarBackground() StatusBarBackground()
} }
Box(Modifier.align(if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart)) { Box(Modifier.align(if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart)) {
DefaultAppBar( if (appBar != null) {
navigationButton = if (showClose) {{ NavigationButtonBack(onButtonClicked = if (enableClose) close else null) }} else null, appBar()
onTop = !oneHandUI.value, } else {
showSearch = showSearch, DefaultAppBar(
searchAlwaysVisible = searchAlwaysVisible, navigationButton = if (showClose) {
onSearchValueChanged = onSearchValueChanged, { NavigationButtonBack(onButtonClicked = if (enableClose) close else null) }
buttons = endButtons } else null,
) onTop = !oneHandUI.value,
showSearch = showSearch,
searchAlwaysVisible = searchAlwaysVisible,
onSearchValueChanged = onSearchValueChanged,
buttons = endButtons
)
}
} }
} }
} }

View file

@ -1738,25 +1738,32 @@
<!-- GroupMemberInfoView.kt --> <!-- GroupMemberInfoView.kt -->
<string name="button_remove_member_question">Remove member?</string> <string name="button_remove_member_question">Remove member?</string>
<string name="button_remove_members_question">Remove members?</string>
<string name="button_remove_member">Remove member</string> <string name="button_remove_member">Remove member</string>
<string name="button_send_direct_message">Send direct message</string> <string name="button_send_direct_message">Send direct message</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Member will be removed from group - this cannot be undone!</string> <string name="member_will_be_removed_from_group_cannot_be_undone">Member will be removed from group - this cannot be undone!</string>
<string name="members_will_be_removed_from_group_cannot_be_undone">Members will be removed from group - this cannot be undone!</string>
<string name="member_will_be_removed_from_chat_cannot_be_undone">Member will be removed from chat - this cannot be undone!</string> <string name="member_will_be_removed_from_chat_cannot_be_undone">Member will be removed from chat - this cannot be undone!</string>
<string name="members_will_be_removed_from_chat_cannot_be_undone">Members will be removed from chat - this cannot be undone!</string>
<string name="remove_member_confirmation">Remove</string> <string name="remove_member_confirmation">Remove</string>
<string name="remove_member_button">Remove member</string> <string name="remove_member_button">Remove member</string>
<string name="block_member_question">Block member?</string> <string name="block_member_question">Block member?</string>
<string name="block_member_button">Block member</string> <string name="block_member_button">Block member</string>
<string name="block_member_confirmation">Block</string> <string name="block_member_confirmation">Block</string>
<string name="block_for_all_question">Block member for all?</string> <string name="block_for_all_question">Block member for all?</string>
<string name="block_members_for_all_question">Block members for all?</string>
<string name="block_for_all">Block for all</string> <string name="block_for_all">Block for all</string>
<string name="block_member_desc">All new messages from %s will be hidden!</string> <string name="block_member_desc">All new messages from %s will be hidden!</string>
<string name="block_members_desc">All new messages from these members will be hidden!</string>
<string name="unblock_member_question">Unblock member?</string> <string name="unblock_member_question">Unblock member?</string>
<string name="unblock_member_button">Unblock member</string> <string name="unblock_member_button">Unblock member</string>
<string name="unblock_member_confirmation">Unblock</string> <string name="unblock_member_confirmation">Unblock</string>
<string name="unblock_for_all_question">Unblock member for all?</string> <string name="unblock_for_all_question">Unblock member for all?</string>
<string name="unblock_members_for_all_question">Unblock members for all?</string>
<string name="unblock_for_all">Unblock for all</string> <string name="unblock_for_all">Unblock for all</string>
<string name="unblock_member_desc">Messages from %s will be shown!</string> <string name="unblock_member_desc">Messages from %s will be shown!</string>
<string name="unblock_members_desc">Messages from these members will be shown!</string>
<string name="member_blocked_by_admin">Blocked by admin</string> <string name="member_blocked_by_admin">Blocked by admin</string>
<string name="member_info_member_blocked">blocked</string> <string name="member_info_member_blocked">blocked</string>
<string name="member_info_member_disabled">disabled</string> <string name="member_info_member_disabled">disabled</string>
@ -2122,6 +2129,7 @@
<string name="feature_offered_item_with_param">offered %s: %2s</string> <string name="feature_offered_item_with_param">offered %s: %2s</string>
<string name="feature_cancelled_item">cancelled %s</string> <string name="feature_cancelled_item">cancelled %s</string>
<string name="feature_roles_all_members">all members</string> <string name="feature_roles_all_members">all members</string>
<string name="feature_roles_moderators">moderators</string>
<string name="feature_roles_admins">admins</string> <string name="feature_roles_admins">admins</string>
<string name="feature_roles_owners">owners</string> <string name="feature_roles_owners">owners</string>
<string name="feature_enabled_for">Enabled for</string> <string name="feature_enabled_for">Enabled for</string>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m225.5-216-140-140 40-40.5 100 99 179-179L445-435 225.5-216Zm0-320-140-140 40-40.5 100 99 179-179L445-755 225.5-536ZM521-291.5V-349h354v57.5H521Zm0-320V-669h354v57.5H521Z"/></svg>

After

Width:  |  Height:  |  Size: 295 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M500-224Zm-335.5 57.5v-91q0-37.75 18.75-64.12Q202-348 231.68-361.67 298-391 358.5-406t121.28-15q37.97 0 74.55 6t74.17 17l-45.43 45q-26.57-5.5-51.47-8-24.89-2.5-51.36-2.5-56.74 0-109.74 11.5-53 11.5-116 42-14 7-23.25 21.73T222-257.26V-224h278v57.5H164.5Zm393 41.5v-121.5L778-466q9-8.5 19.75-12.5 10.76-4 21.51-4 11.73 0 22.49 4.25Q852.5-474 861.5-465l37 37q8.76 8.85 12.63 19.68Q915-397.5 915-386.75t-4.38 22.03q-4.38 11.28-13.05 19.74L679-125H557.5Zm299-262-37-37 37 37Zm-240 203h37.76L776.5-307l-17.89-19-18.88-18L616.5-222v38Zm142-142-19-18 37 37-18-19ZM480-480.5q-62 0-104.75-42.75T332.5-628q0-62 42.75-104.75T480-775.5q62 0 104.75 42.75T627.5-628q0 62-42.75 104.75T480-480.5Zm0-57.5q38 0 64-26t26-64q0-38-26-64t-64-26q-38 0-64 26t-26 64q0 38 26 64t64 26Zm0-90Z"/></svg>

After

Width:  |  Height:  |  Size: 889 B