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

View file

@ -175,7 +175,7 @@ fun ChatView(
)
}
} else {
SelectedItemsBottomToolbar(
SelectedItemsButtonsToolbar(
contentTag = contentTag,
selectedChatItems = selectedChatItems,
chatInfo = chatInfo,
@ -274,34 +274,46 @@ fun ChatView(
}
if (!isActive) return@launch
ModalManager.end.showModalCloseable(true) { close ->
val chatInfo = remember { activeChatInfo }.value
if (chatInfo is ChatInfo.Direct) {
var contactInfo: Pair<ConnectionStats?, Profile?>? by remember { mutableStateOf(preloadedContactInfo) }
var code: String? by remember { mutableStateOf(preloadedCode) }
KeyChangeEffect(chatInfo.id, ChatModel.networkStatuses.toMap()) {
contactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId)
preloadedContactInfo = contactInfo
code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second
preloadedCode = code
val selectedItems: MutableState<Set<Long>?> = mutableStateOf(null)
ModalManager.end.showCustomModal { close ->
val appBar = remember { mutableStateOf(null as @Composable (BoxScope.() -> Unit)?) }
ModalView(close, appBar = appBar.value) {
val chatInfo = remember { activeChatInfo }.value
if (chatInfo is ChatInfo.Direct) {
var contactInfo: Pair<ConnectionStats?, Profile?>? by remember { mutableStateOf(preloadedContactInfo) }
var code: String? by remember { mutableStateOf(preloadedCode) }
KeyChangeEffect(chatInfo.id, ChatModel.networkStatuses.toMap()) {
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) {
close()
snapshotFlow { activeChatInfo.value?.id }
.drop(1)
.collect {
appBar.value = null
selectedItems.value = null
}
}
}
}
@ -788,7 +800,7 @@ fun ChatLayout(
) {
AnimatedVisibility(selectedChatItems.value != null) {
if (chatInfo != null) {
SelectedItemsBottomToolbar(
SelectedItemsButtonsToolbar(
contentTag = contentTag,
selectedChatItems = selectedChatItems,
chatInfo = chatInfo,
@ -846,7 +858,7 @@ fun ChatLayout(
if (selectedChatItems.value == null) {
GroupReportsAppBar(contentTag, { ModalManager.end.closeModal() }, onSearchValueChanged)
} 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)
}
} else {
SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value)
SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value)
}
}
if (contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) {
@ -1432,7 +1444,7 @@ fun BoxScope.ChatItemsList(
fun Item() {
ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) {
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() }) {
val member = cItem.chatDir.groupMember
@ -1457,7 +1469,7 @@ fun BoxScope.ChatItemsList(
} else {
ChatItemBox {
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(
Modifier
@ -1472,7 +1484,7 @@ fun BoxScope.ChatItemsList(
} else {
ChatItemBox {
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(
Modifier
@ -1487,7 +1499,7 @@ fun BoxScope.ChatItemsList(
} else { // direct message
ChatItemBox {
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(
@ -2296,12 +2308,12 @@ private fun BoxScope.BottomEndFloatingButton(
}
@Composable
private fun SelectedChatItem(
fun SelectedListItem(
modifier: Modifier,
ciId: Long,
selectedChatItems: State<Set<Long>?>,
id: Long,
selectedItems: State<Set<Long>?>,
) {
val checked = remember { derivedStateOf { selectedChatItems.value?.contains(ciId) == true } }
val checked = remember { derivedStateOf { selectedItems.value?.contains(id) == true } }
Icon(
painterResource(if (checked.value) MR.images.ic_check_circle_filled else MR.images.ic_radio_button_unchecked),
null,

View file

@ -9,12 +9,10 @@ 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.text.TextRange
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.model.ChatController.appPrefs
import chat.simplex.common.platform.BackHandler
import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.helpers.*
@ -23,32 +21,44 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>, onTop: Boolean) {
val onBackClicked = { selectedChatItems.value = null }
fun BoxScope.SelectedItemsCounterToolbar(selectedItems: MutableState<Set<Long>?>, onTop: Boolean, selectAll: (() -> Unit)? = null) {
val onBackClicked = { selectedItems.value = null }
BackHandler(onBack = onBackClicked)
val count = selectedChatItems.value?.size ?: 0
DefaultAppBar(
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,
onTop = onTop,
onSearchValueChanged = {},
)
val count = selectedItems.value?.size ?: 0
Box(if (onTop) Modifier else Modifier.imePadding()) {
DefaultAppBar(
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,
onTop = onTop,
onSearchValueChanged = {},
buttons = if (selectAll != null) { { SelectAllButton(selectAll) } } else {{}}
)
}
}
@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,
contentTag: MsgContentTag?,
selectedChatItems: MutableState<Set<Long>?>,
@ -162,4 +172,4 @@ private fun recheckItems(chatInfo: ChatInfo,
}
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 SectionTextFooter
import SectionView
import androidx.compose.animation.*
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
@ -17,6 +19,7 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
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.platform.*
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.database.TtlOptions
import chat.simplex.res.MR
@ -49,7 +52,18 @@ val MEMBER_ROW_AVATAR_SIZE = 42.dp
val MEMBER_ROW_VERTICAL_PADDING = 8.dp
@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)
// TODO derivedStateOf?
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)
},
members = remember { chatModel.groupMembers }.value
activeSortedMembers = remember { chatModel.groupMembers }.value
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedByDescending { it.memberRole },
developerTools,
onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) },
groupLink,
selectedItems,
appBar,
scrollToItemId,
addMembers = {
scope.launch(Dispatchers.Default) {
@ -212,21 +228,23 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe
text = generalGetString(messageId),
confirmText = generalGetString(MR.strings.remove_member_confirmation),
onConfirm = {
withBGApi {
val updatedMembers = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, listOf(mem.groupMemberId))
if (updatedMembers != null) {
withChats {
updatedMembers.forEach { updatedMember ->
upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
withReportsChatsIfOpen {
updatedMembers.forEach { updatedMember ->
upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
}
}
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId))
},
destructive = true,
)
}
private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, onSuccess: () -> Unit = {}) {
val messageId = if (groupInfo.businessChat == null)
MR.strings.members_will_be_removed_from_group_cannot_be_undone
else
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,
)
@ -309,10 +327,12 @@ fun ModalData.GroupChatInfoLayout(
setSendReceipts: (SendReceipts) -> Unit,
chatItemTTL: MutableState<ChatItemTTL?>,
setChatItemTTL: (ChatItemTTL?) -> Unit,
members: List<GroupMember>,
activeSortedMembers: List<GroupMember>,
developerTools: Boolean,
onLocalAliasChanged: (String) -> Unit,
groupLink: String?,
selectedItems: MutableState<Set<Long>?>,
appBar: MutableState<@Composable (BoxScope.() -> Unit)?>,
scrollToItemId: MutableState<Long?>,
addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit,
@ -333,20 +353,37 @@ fun ModalData.GroupChatInfoLayout(
scope.launch { listState.scrollToItem(0) }
}
val searchText = remember { stateGetOrPut("searchText") { TextFieldValue() } }
val filteredMembers = remember(members) {
val filteredMembers = remember(activeSortedMembers) {
derivedStateOf {
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 {
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(
state = listState,
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 {
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 {
@ -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)
} else {
SendReceiptsOptionDisabled()
@ -424,7 +461,7 @@ fun ModalData.GroupChatInfoLayout(
ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems)
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.businessChat == null) {
if (groupLink == null) {
@ -442,7 +479,7 @@ fun ModalData.GroupChatInfoLayout(
}
AddMembersButton(addMembersTitleId, tint, onAddMembersClick)
}
if (members.size > 8) {
if (activeSortedMembers.size > 8) {
SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText)
}
@ -452,12 +489,34 @@ fun ModalData.GroupChatInfoLayout(
}
}
}
items(filteredMembers.value) { member ->
items(filteredMembers.value, key = { it.groupMemberId }) { member ->
Divider()
val showMenu = remember { mutableStateOf(false) }
SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
DropDownMenuForMember(chat.remoteHostId, member, groupInfo, showMenu)
MemberRow(member)
val canBeSelected = groupInfo.membership.memberRole >= member.memberRole && member.memberRole < GroupMemberRole.Moderator
SectionItemViewLongClickable(
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 {
@ -482,12 +541,92 @@ fun ModalData.GroupChatInfoLayout(
}
}
SectionBottomSpacer()
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
if (!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)
} else {
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)
}
}
@ -686,8 +825,8 @@ private fun MemberVerifiedShield() {
}
@Composable
private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) {
if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, selectedItems: MutableState<Set<Long>?>, showMenu: MutableState<Boolean>) {
if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
val canBlockForAll = member.canBlockForAll(groupInfo)
val canRemove = member.canBeRemoved(groupInfo)
if (canBlockForAll || canRemove) {
@ -711,6 +850,10 @@ private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: G
showMenu.value = false
})
}
if (selectedItems.value == null && member.memberRole < GroupMemberRole.Moderator) {
Divider()
SelectItemAction(showMenu) { toggleItemSelection(member.groupMemberId, selectedItems) }
}
}
}
} 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
@Composable
fun PreviewGroupChatInfoLayout() {
@ -835,10 +1009,12 @@ fun PreviewGroupChatInfoLayout() {
setSendReceipts = {},
chatItemTTL = remember { mutableStateOf(ChatItemTTL.fromSeconds(0)) },
setChatItemTTL = {},
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
activeSortedMembers = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
onLocalAliasChanged = {},
groupLink = null,
selectedItems = remember { mutableStateOf(null) },
appBar = remember { mutableStateOf(null) },
scrollToItemId = remember { mutableStateOf(null) },
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
val prevValue = newRole.value
newRole.value = it
updateMemberRoleDialog(it, groupInfo, member, onDismiss = {
updateMemberRoleDialog(it, groupInfo, member.memberCurrent, onDismiss = {
newRole.value = prevValue
}) {
withBGApi {
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
}
}
updateMembersRole(newRole.value, rhId, groupInfo, listOf(member.groupMemberId), onFailure = { newRole.value = prevValue })
}
},
switchMemberAddress = {
@ -317,7 +301,7 @@ fun GroupMemberInfoLayout(
}
@Composable
fun AdminDestructiveSection() {
fun ModeratorDestructiveSection() {
val canBlockForAll = member.canBlockForAll(groupInfo)
val canRemove = member.canBeRemoved(groupInfo)
if (canBlockForAll || canRemove) {
@ -494,8 +478,8 @@ fun GroupMemberInfoLayout(
}
}
if (groupInfo.membership.memberRole >= GroupMemberRole.Admin) {
AdminDestructiveSection()
if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
ModeratorDestructiveSection()
} else {
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,
groupInfo: GroupInfo,
member: GroupMember,
memberCurrent: Boolean,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.change_member_role_question),
text = if (member.memberCurrent) {
text = if (memberCurrent) {
if (groupInfo.businessChat == null)
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text)
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) {
try {
withBGApi {
@ -793,7 +814,19 @@ fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) {
text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName),
confirmText = generalGetString(MR.strings.block_for_all),
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,
)
@ -805,14 +838,25 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) {
text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName),
confirmText = generalGetString(MR.strings.unblock_for_all),
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 {
val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, listOf(member.groupMemberId), blocked)
val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, memberIds, blocked)
withChats {
updatedMembers.forEach { updatedMember ->
upsertGroupMember(rhId, gInfo, updatedMember)
@ -823,6 +867,7 @@ fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocke
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(
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.Owner to generalGetString(MR.strings.feature_roles_owners)
)

View file

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

View file

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

View file

@ -1738,25 +1738,32 @@
<!-- GroupMemberInfoView.kt -->
<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_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="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="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_button">Remove member</string>
<string name="block_member_question">Block member?</string>
<string name="block_member_button">Block member</string>
<string name="block_member_confirmation">Block</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_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_button">Unblock member</string>
<string name="unblock_member_confirmation">Unblock</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_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_info_member_blocked">blocked</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_cancelled_item">cancelled %s</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_owners">owners</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