diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoTabs.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoTabs.kt new file mode 100644 index 0000000000..cf577c144a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoTabs.kt @@ -0,0 +1,396 @@ +package chat.simplex.common.views.chat.group + +import SectionDividerSpaced +import SectionItemView +import SectionItemViewLongClickable +import SectionSpacer +import SectionView +import androidx.compose.animation.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.* +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.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.* +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.chatModel +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.SelectedListItem +import chat.simplex.common.views.chat.SendReceipts +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chat.item.SelectItemAction +import chat.simplex.common.views.chat.group.showGroupReportsView +import chat.simplex.common.views.chatlist.cantInviteIncognitoAlert +import chat.simplex.common.views.chatlist.openChat +import chat.simplex.common.views.chatlist.UnreadBadge +import chat.simplex.common.views.chatlist.unreadCountStr +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.common.views.usersettings.SettingsActionItemWithContent +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.launch + +enum class GroupInfoTab { + Members, + Images, + Videos, + Links, + Files, + Voices +} + +fun LazyListScope.GroupChatInfoTabs( + groupInfo: GroupInfo, + activeSortedMembers: List, + filteredMembers: State>, + searchText: MutableState, + selectedItems: MutableState?>, + scrollToItemId: MutableState, + addMembers: () -> Unit, + showMemberInfo: (GroupMember) -> Unit, + selectedTab: MutableState, + filteredChatItems: State>, + chat: Chat, + groupLink: GroupLink?, + manageGroupLink: () -> Unit, + openMemberSupport: () -> Unit +) { + + item { + SectionSpacer() + + val scrollState = rememberScrollState() + Column { + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.Start + ) { + GroupInfoTab.values().forEach { tab -> + Tab( + selected = selectedTab.value == tab, + onClick = { selectedTab.value = tab }, + text = { Text(tabTitle(tab), fontSize = 13.sp) }, + selectedContentColor = MaterialTheme.colors.primary, + unselectedContentColor = MaterialTheme.colors.secondary, + ) + } + } + // Simple indicator line + Box( + Modifier + .fillMaxWidth() + .height(2.dp) + .background(MaterialTheme.colors.surface) + ) + } + Divider() + + SectionSpacer() + } + + when (selectedTab.value) { + GroupInfoTab.Members -> { + MembersTabContent( + groupInfo = groupInfo, + activeSortedMembers = activeSortedMembers, + filteredMembers = filteredMembers, + searchText = searchText, + selectedItems = selectedItems, + showMemberInfo = showMemberInfo, + addMembers = addMembers, + chat = chat, + groupLink = groupLink, + manageGroupLink = manageGroupLink, + openMemberSupport = openMemberSupport, + scrollToItemId = scrollToItemId + ) + } + GroupInfoTab.Images, + GroupInfoTab.Videos, + GroupInfoTab.Links, + GroupInfoTab.Files, + GroupInfoTab.Voices -> { + ContentItemsTab( + filteredChatItems = filteredChatItems, + scrollToItemId = scrollToItemId + ) + } + } +} + +private fun LazyListScope.MembersTabContent( + groupInfo: GroupInfo, + activeSortedMembers: List, + filteredMembers: State>, + searchText: MutableState, + selectedItems: MutableState?>, + showMemberInfo: (GroupMember) -> Unit, + addMembers: () -> Unit, + chat: Chat, + groupLink: GroupLink?, + manageGroupLink: () -> Unit, + openMemberSupport: () -> Unit, + scrollToItemId: MutableState +) { + + item { + val scope = rememberCoroutineScope() + var anyTopSectionRowShow = false + SectionView { + if (groupInfo.canAddMembers && groupInfo.businessChat == null) { + anyTopSectionRowShow = true + if (groupLink == null) { + CreateGroupLinkButton(manageGroupLink) + } else { + GroupLinkButton(manageGroupLink) + } + } + if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + anyTopSectionRowShow = true + MemberSupportButton(chat, openMemberSupport) + } + if (groupInfo.canModerate) { + anyTopSectionRowShow = true + GroupReportsButton(chat) { + scope.launch { + showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + } + } + } + if ( + groupInfo.membership.memberActive && + (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) + ) { + anyTopSectionRowShow = true + UserSupportChatButton(chat, groupInfo, scrollToItemId) + } + } + if (anyTopSectionRowShow) { + SectionDividerSpaced(maxBottomPadding = false) + } + } + + if (!groupInfo.nextConnectPrepared) { + item { + SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { + if (groupInfo.canAddMembers) { + val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers + val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + val addMembersTitleId = when (groupInfo.businessChat?.chatType) { + BusinessChatType.Customer -> MR.strings.button_add_team_members + BusinessChatType.Business -> MR.strings.button_add_friends + null -> MR.strings.button_add_members + } + AddMembersButton(addMembersTitleId, tint, onAddMembersClick) + } + if (activeSortedMembers.size > 8) { + SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) { + MemberListSearchRowView(searchText) + } + } + SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + MemberRow(groupInfo.membership, user = true) + } + } + } + } + if (!groupInfo.nextConnectPrepared) { + items(filteredMembers.value, key = { it.groupMemberId }) { member -> + Divider() + val showMenu = remember { mutableStateOf(false) } + 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) { + this@SectionItemViewLongClickable.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) + } + } + } + } + } +} + +private fun LazyListScope.ContentItemsTab( + filteredChatItems: State>, + scrollToItemId: MutableState +) { + if (filteredChatItems.value.isEmpty()) { + item { + Box( + Modifier + .fillMaxWidth() + .padding(vertical = DEFAULT_PADDING * 2), + contentAlignment = Alignment.Center + ) { + //TODO: this is just a temporary UI, if no item, tab will not be shown + Text( + "No items found", + color = MaterialTheme.colors.secondary + ) + } + } + } else { + items(filteredChatItems.value, key = { it.id }) { chatItem -> + Divider() + SectionItemView( + click = { scrollToItemId.value = chatItem.id }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + GroupChatItemRow(chatItem) + } + } + } +} + + +@Composable +private fun tabTitle(tab: GroupInfoTab): String { + return when (tab) { + GroupInfoTab.Members -> stringResource(MR.strings.group_info_tab_members) + GroupInfoTab.Images -> stringResource(MR.strings.group_info_tab_images) + GroupInfoTab.Videos -> stringResource(MR.strings.group_info_tab_videos) + GroupInfoTab.Links -> stringResource(MR.strings.group_info_tab_links) + GroupInfoTab.Files -> stringResource(MR.strings.group_info_tab_files) + GroupInfoTab.Voices -> stringResource(MR.strings.group_info_tab_voices) + } +} + + +@Composable +private fun GroupChatItemRow(chatItem: ChatItem) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + when (val content = chatItem.content.msgContent) { + is MsgContent.MCImage -> { + Icon(painterResource(MR.images.ic_image), null, Modifier.size(24.dp), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(DEFAULT_PADDING)) + Column(Modifier.weight(1f)) { + //TODO, This is just a temporary UI, needs to be adjusted + Text( + content.text.ifEmpty { "Image" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + getTimestampText(chatItem.meta.itemTs), + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + } + } + is MsgContent.MCVideo -> { + Icon(painterResource(MR.images.ic_videocam), null, Modifier.size(24.dp), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(DEFAULT_PADDING)) + Column(Modifier.weight(1f)) { + //TODO, This is just a temporary UI, needs to be adjusted + Text( + content.text.ifEmpty { "Video" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + getTimestampText(chatItem.meta.itemTs), + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + } + } + is MsgContent.MCLink -> { + Icon(painterResource(MR.images.ic_link), null, Modifier.size(24.dp), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(DEFAULT_PADDING)) + Column(Modifier.weight(1f)) { + //TODO, This is just a temporary UI, needs to be adjusted + Text( + content.preview.uri.ifEmpty { content.text }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + getTimestampText(chatItem.meta.itemTs), + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + } + } + is MsgContent.MCFile -> { + Icon(painterResource(MR.images.ic_draft_filled), null, Modifier.size(24.dp), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(DEFAULT_PADDING)) + Column(Modifier.weight(1f)) { + //TODO, This is just a temporary UI, needs to be adjusted + Text( + chatItem.file?.fileName ?: content.text.ifEmpty { "File" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + getTimestampText(chatItem.meta.itemTs), + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + } + } + is MsgContent.MCVoice -> { + Icon(painterResource(MR.images.ic_mic_filled), null, Modifier.size(24.dp), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(DEFAULT_PADDING)) + Column(Modifier.weight(1f)) { + //TODO, This is just a temporary UI, needs to be adjusted + Text( + content.text.ifEmpty { generalGetString(MR.strings.voice_message) }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + getTimestampText(chatItem.meta.itemTs), + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + } + } + else -> { + Text( + generalGetString(MR.strings.unknown_message_format), + color = MaterialTheme.colors.secondary + ) + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 3f80361249..2b641ae8ef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -9,6 +9,7 @@ import SectionSpacer import SectionTextFooter import SectionView import androidx.compose.animation.* +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.combinedClickable @@ -161,11 +162,21 @@ fun ModalData.GroupChatInfoView( ) } }, + openSettings = { + ModalManager.end.showCustomModal { close -> + GroupSettingsView( + chatModel, + rhId, + chat.id, + close + ) + } + }, deleteGroup = { deleteGroupDialog(chat, groupInfo, chatModel, close) }, clearChat = { clearChatDialog(chat, close) }, leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated) } + ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated) } }, onSearchClicked = onSearchClicked, deletingItems = deletingItems @@ -228,7 +239,7 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl ) } -private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) { +fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) { val messageId = if (groupInfo.businessChat == null) MR.strings.member_will_be_removed_from_group_cannot_be_undone else @@ -326,7 +337,7 @@ fun MuteButton( InfoViewActionButton( modifier = modifier, - icon = painterResource(nextNotificationMode.icon), + icon = painterResource(nextNotificationMode.icon), title = generalGetString(nextNotificationMode.text(true)), disabled = !groupInfo.ready, disabledLook = !groupInfo.ready, @@ -336,6 +347,22 @@ fun MuteButton( ) } +@Composable +fun SettingsButton( + modifier: Modifier, + groupInfo: GroupInfo, + onClick: () -> Unit +) { + InfoViewActionButton( + modifier = modifier, + icon = painterResource(MR.images.ic_settings), + title = generalGetString(MR.strings.icon_descr_settings), + disabled = !groupInfo.ready, + disabledLook = !groupInfo.ready, + onClick = onClick + ) +} + @Composable fun AddGroupMembersButton( modifier: Modifier, @@ -344,7 +371,7 @@ fun AddGroupMembersButton( ) { InfoViewActionButton( modifier = modifier, - icon = if (groupInfo.incognito) painterResource(MR.images.ic_add_link) else painterResource(MR.images.ic_person_add_500), + icon = if (groupInfo.incognito) painterResource(MR.images.ic_add_link) else painterResource(MR.images.ic_person_add_500), title = stringResource(MR.strings.action_button_add_members), disabled = !groupInfo.ready, disabledLook = !groupInfo.ready, @@ -414,11 +441,12 @@ fun ModalData.GroupChatInfoLayout( addOrEditWelcomeMessage: () -> Unit, openMemberSupport: () -> Unit, openPreferences: () -> Unit, + openSettings: () -> Unit, deleteGroup: () -> Unit, clearChat: () -> Unit, leaveGroup: () -> Unit, manageGroupLink: () -> Unit, - close: () -> Unit = { ModalManager.closeAllModalsEverywhere()}, + close: () -> Unit = { ModalManager.closeAllModalsEverywhere() }, onSearchClicked: () -> Unit, deletingItems: State ) { @@ -434,219 +462,124 @@ fun ModalData.GroupChatInfoLayout( if (s.isEmpty()) activeSortedMembers else activeSortedMembers.filter { m -> m.anyNameContains(s) } } } + val selectedTab = rememberSaveable { mutableStateOf(GroupInfoTab.Members) } + val filteredChatItems = remember(chat.chatItems, selectedTab.value) { + derivedStateOf { + when (selectedTab.value) { + GroupInfoTab.Members -> emptyList() + GroupInfoTab.Images -> chat.chatItems.filter { + it.content.msgContent is MsgContent.MCImage && it.meta.itemDeleted == null + } + GroupInfoTab.Videos -> chat.chatItems.filter { + it.content.msgContent is MsgContent.MCVideo && it.meta.itemDeleted == null + } + GroupInfoTab.Links -> chat.chatItems.filter { + it.content.msgContent is MsgContent.MCLink && it.meta.itemDeleted == null + } + GroupInfoTab.Files -> chat.chatItems.filter { + it.content.msgContent is MsgContent.MCFile && it.meta.itemDeleted == null + } + GroupInfoTab.Voices -> chat.chatItems.filter { + it.content.msgContent is MsgContent.MCVoice && it.meta.itemDeleted == null + } + } + } + } 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 = 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), - bottom = if (imePadding > 0.dp) { - imePadding + selectedItemsBarHeight - } else { - navBarPadding + selectedItemsBarHeight - } - ) - } - ) { - item { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - GroupChatInfoHeader(chat.chatInfo, groupInfo) + LazyColumnWithScrollBar( + state = listState, + contentPadding = if (oneHandUI.value) { + 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), + bottom = if (imePadding > 0.dp) { + imePadding + selectedItemsBarHeight + } else { + navBarPadding + selectedItemsBarHeight + } + ) } - - LocalAliasEditor(chat.id, groupInfo.localAlias, isContact = false, updateValue = onLocalAliasChanged) - - SectionSpacer() - - Box( - Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { + ) { + item { Row( - Modifier - .widthIn(max = if (groupInfo.canAddMembers) 320.dp else 230.dp) - .padding(horizontal = DEFAULT_PADDING), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center ) { - if (groupInfo.canAddMembers) { - SearchButton(modifier = Modifier.fillMaxWidth(0.33f), chat, groupInfo, close, onSearchClicked) - AddGroupMembersButton(modifier = Modifier.fillMaxWidth(0.5f), chat, groupInfo) - MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, groupInfo) - } else { - SearchButton(modifier = Modifier.fillMaxWidth(0.5f), chat, groupInfo, close, onSearchClicked) - MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, groupInfo) - } + GroupChatInfoHeader(chat.chatInfo, groupInfo) } - } - SectionSpacer() + LocalAliasEditor(chat.id, groupInfo.localAlias, isContact = false, updateValue = onLocalAliasChanged) - var anyTopSectionRowShow = false - SectionView { - if (groupInfo.canAddMembers && groupInfo.businessChat == null) { - anyTopSectionRowShow = true - if (groupLink == null) { - CreateGroupLinkButton(manageGroupLink) - } else { - GroupLinkButton(manageGroupLink) - } - } - if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { - anyTopSectionRowShow = true - MemberSupportButton(chat, openMemberSupport) - } - if (groupInfo.canModerate) { - anyTopSectionRowShow = true - GroupReportsButton(chat) { - scope.launch { - showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) - } - } - } - if ( - groupInfo.membership.memberActive && - (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) + SectionSpacer() + + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center ) { - anyTopSectionRowShow = true - UserSupportChatButton(chat, groupInfo, scrollToItemId) - } - } - if (anyTopSectionRowShow) { - SectionDividerSpaced(maxBottomPadding = false) - } - - SectionView { - if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { - EditGroupProfileButton(editGroupProfile) - } - if (groupInfo.groupProfile.description != null || (groupInfo.isOwner && groupInfo.businessChat?.chatType == null)) { - AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage) - } - val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences - GroupPreferencesButton(prefsTitleId, openPreferences) - } - val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs - SectionTextFooter(stringResource(footerId)) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) - - SectionView { - if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { - SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) - } else { - SendReceiptsOptionDisabled() - } - WallpaperButton { - ModalManager.end.showModal { - val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } } - val c = chat.value - if (c != null) { - ChatWallpaperEditorModal(c) - } - } - } - ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) - SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) - } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) - - if (!groupInfo.nextConnectPrepared) { - SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { - if (groupInfo.canAddMembers) { - val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers - val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary - val addMembersTitleId = when (groupInfo.businessChat?.chatType) { - BusinessChatType.Customer -> MR.strings.button_add_team_members - BusinessChatType.Business -> MR.strings.button_add_friends - null -> MR.strings.button_add_members - } - AddMembersButton(addMembersTitleId, tint, onAddMembersClick) - } - if (activeSortedMembers.size > 8) { - SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) { - MemberListSearchRowView(searchText) - } - } - SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { - MemberRow(groupInfo.membership, user = true) - } - } - } - } - if (!groupInfo.nextConnectPrepared) { - items(filteredMembers.value, key = { it.groupMemberId }) { member -> - Divider() - val showMenu = remember { mutableStateOf(false) } - val canBeSelected = groupInfo.membership.memberRole >= member.memberRole && member.memberRole < GroupMemberRole.Moderator - SectionItemViewLongClickable( - click = { - if (selectedItems.value != null) { - if (canBeSelected) { - toggleItemSelection(member.groupMemberId, selectedItems) - } + Row( + Modifier + .widthIn(max = if (groupInfo.canAddMembers) 480.dp else 320.dp) + .padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + if (groupInfo.canAddMembers) { + SearchButton(modifier = Modifier.weight(1f), chat, groupInfo, close, onSearchClicked) + AddGroupMembersButton(modifier = Modifier.weight(1f), chat, groupInfo) + MuteButton(modifier = Modifier.weight(1f), chat, groupInfo) + SettingsButton(modifier = Modifier.weight(1f), groupInfo, openSettings) } 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) + SearchButton(modifier = Modifier.weight(1f), chat, groupInfo, close, onSearchClicked) + MuteButton(modifier = Modifier.weight(1f), chat, groupInfo) + SettingsButton(modifier = Modifier.weight(1f), groupInfo, openSettings) } } } - } - } - item { - if (!groupInfo.nextConnectPrepared) { - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) - } - SectionView { - ClearChatButton(clearChat) - if (groupInfo.canDelete) { - val titleId = if (groupInfo.businessChat == null) MR.strings.button_delete_group else MR.strings.button_delete_chat - DeleteGroupButton(titleId, deleteGroup) - } - if (groupInfo.membership.memberCurrentOrPending) { - val titleId = if (groupInfo.businessChat == null) MR.strings.button_leave_group else MR.strings.button_leave_chat - LeaveGroupButton(titleId, leaveGroup) - } + + SectionSpacer() } - if (developerTools) { - SectionDividerSpaced() - SectionView(title = stringResource(MR.strings.section_title_for_console)) { - InfoRow(stringResource(MR.strings.info_row_local_name), groupInfo.localDisplayName) - InfoRow(stringResource(MR.strings.info_row_database_id), groupInfo.apiId.toString()) + GroupChatInfoTabs( + groupInfo = groupInfo, + activeSortedMembers = activeSortedMembers, + filteredMembers = filteredMembers, + searchText = searchText, + selectedItems = selectedItems, + scrollToItemId = scrollToItemId, + addMembers = addMembers, + showMemberInfo = showMemberInfo, + selectedTab = selectedTab, + filteredChatItems = filteredChatItems, + chat = chat, + groupLink = groupLink, + manageGroupLink = manageGroupLink, + openMemberSupport = openMemberSupport + ) + + item { + if (developerTools) { + SectionDividerSpaced() + SectionView(title = stringResource(MR.strings.section_title_for_console)) { + InfoRow(stringResource(MR.strings.info_row_local_name), groupInfo.localDisplayName) + InfoRow(stringResource(MR.strings.info_row_database_id), groupInfo.apiId.toString()) + } } + SectionBottomSpacer() } - SectionBottomSpacer() } - } if (!oneHandUI.value) { NavigationBarBackground(oneHandUI.value, oneHandUI.value) } @@ -779,7 +712,7 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { } @Composable -private fun MemberSupportButton(chat: Chat, onClick: () -> Unit) { +fun MemberSupportButton(chat: Chat, onClick: () -> Unit) { SettingsActionItemWithContent( painterResource(if (chat.supportUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.member_support), @@ -796,7 +729,7 @@ private fun MemberSupportButton(chat: Chat, onClick: () -> Unit) { } @Composable -private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) { +fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_toggle_on), stringResource(titleId), @@ -805,7 +738,7 @@ private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) } @Composable -private fun GroupReportsButton(chat: Chat, onClick: () -> Unit) { +fun GroupReportsButton(chat: Chat, onClick: () -> Unit) { SettingsActionItemWithContent( painterResource(if (chat.chatStats.reportsCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.group_reports_member_reports), @@ -822,7 +755,7 @@ private fun GroupReportsButton(chat: Chat, onClick: () -> Unit) { } @Composable -private fun SendReceiptsOption(currentUser: User, state: State, onSelected: (SendReceipts) -> Unit) { +fun SendReceiptsOption(currentUser: User, state: State, onSelected: (SendReceipts) -> Unit) { val values = remember { mutableListOf(SendReceipts.Yes, SendReceipts.No, SendReceipts.UserDefault(currentUser.sendRcptsSmallGroups)).map { it to it.text } } @@ -853,7 +786,7 @@ fun SendReceiptsOptionDisabled() { } @Composable -private fun AddMembersButton(titleId: StringResource, tint: Color = MaterialTheme.colors.primary, onClick: () -> Unit) { +fun AddMembersButton(titleId: StringResource, tint: Color = MaterialTheme.colors.primary, onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_add), stringResource(titleId), @@ -945,7 +878,7 @@ fun MemberVerifiedShield() { } @Composable -private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, selectedItems: MutableState?>, showMenu: MutableState) { +fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, selectedItems: MutableState?>, showMenu: MutableState) { if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { val canBlockForAll = member.canBlockForAll(groupInfo) val canRemove = member.canBeRemoved(groupInfo) @@ -994,7 +927,7 @@ private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: G } @Composable -private fun GroupLinkButton(onClick: () -> Unit) { +fun GroupLinkButton(onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_link), stringResource(MR.strings.group_link), @@ -1004,7 +937,7 @@ private fun GroupLinkButton(onClick: () -> Unit) { } @Composable -private fun CreateGroupLinkButton(onClick: () -> Unit) { +fun CreateGroupLinkButton(onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_add_link), stringResource(MR.strings.create_group_link), @@ -1024,7 +957,7 @@ fun EditGroupProfileButton(onClick: () -> Unit) { } @Composable -private fun AddOrEditWelcomeMessage(welcomeMessage: String?, onClick: () -> Unit) { +fun AddOrEditWelcomeMessage(welcomeMessage: String?, onClick: () -> Unit) { val text = if (welcomeMessage == null) { stringResource(MR.strings.button_add_welcome_message) } else { @@ -1039,7 +972,7 @@ private fun AddOrEditWelcomeMessage(welcomeMessage: String?, onClick: () -> Unit } @Composable -private fun LeaveGroupButton(titleId: StringResource, onClick: () -> Unit) { +fun LeaveGroupButton(titleId: StringResource, onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_logout), stringResource(titleId), @@ -1050,7 +983,7 @@ private fun LeaveGroupButton(titleId: StringResource, onClick: () -> Unit) { } @Composable -private fun DeleteGroupButton(titleId: StringResource, onClick: () -> Unit) { +fun DeleteGroupButton(titleId: StringResource, onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_delete), stringResource(titleId), @@ -1060,6 +993,7 @@ private fun DeleteGroupButton(titleId: StringResource, onClick: () -> Unit) { ) } + @Composable fun MemberListSearchRowView( searchText: MutableState = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } @@ -1155,7 +1089,9 @@ fun PreviewGroupChatInfoLayout() { leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, - deletingItems = remember { mutableStateOf(true) } + deletingItems = remember { mutableStateOf(true) }, + openSettings = { }, + close = { } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupSettingsView.kt new file mode 100644 index 0000000000..94e927a4c3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupSettingsView.kt @@ -0,0 +1,186 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionTextFooter +import SectionView +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.updateChatSettings +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.common.views.usersettings.SettingsActionItemWithContent +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.* + +@Composable +fun GroupSettingsView( + m: ChatModel, + rhId: Long?, + chatId: String, + close: () -> Unit +) { + val chat = remember { derivedStateOf { m.getChat(chatId) } } + val c = chat.value + if (c == null || c.chatInfo !is ChatInfo.Group) return + + val groupInfo = c.chatInfo.groupInfo + val currentUser = m.currentUser.value ?: return + + val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) } + val chatItemTTL = rememberSaveable(groupInfo.id) { mutableStateOf(if (groupInfo.chatItemTTL != null) ChatItemTTL.fromSeconds(groupInfo.chatItemTTL) else null) } + val deletingItems = rememberSaveable(groupInfo.id) { mutableStateOf(false) } + + fun setSendReceipts(sendRcpts: SendReceipts) { + val chatSettings = (c.chatInfo.chatSettings ?: ChatSettings.defaults).copy(sendRcpts = sendRcpts.bool) + updateChatSettings(c.remoteHostId, c.chatInfo, chatSettings, m) + sendReceipts.value = sendRcpts + } + + fun setChatItemTTL(ttl: ChatItemTTL?) { + if (ttl == chatItemTTL.value) return + val previousChatTTL = chatItemTTL.value + chatItemTTL.value = ttl + setChatTTLAlert(m.chatsContext, c.remoteHostId, c.chatInfo, chatItemTTL, previousChatTTL, deletingItems) + } + + ModalView(close = close) { + GroupSettingsLayout( + chat = c, + groupInfo = groupInfo, + currentUser = currentUser, + sendReceipts = sendReceipts, + setSendReceipts = ::setSendReceipts, + chatItemTTL = chatItemTTL, + setChatItemTTL = ::setChatItemTTL, + deletingItems = deletingItems, + editGroupProfile = { + ModalManager.end.showCustomModal { closeModal -> + GroupProfileView(rhId, groupInfo, m, closeModal) + } + }, + addOrEditWelcomeMessage = { + ModalManager.end.showCustomModal { closeModal -> + GroupWelcomeView(m, rhId, groupInfo, closeModal) + } + }, + openPreferences = { + ModalManager.end.showCustomModal { closeModal -> + GroupPreferencesView(m, rhId, chatId, closeModal) + } + }, + clearChat = { clearChatDialog(c, close) }, + deleteGroup = { deleteGroupDialog(c, groupInfo, m, close) }, + leaveGroup = { leaveGroupDialog(rhId, groupInfo, m, close) }, + close = close + ) + } +} + +@Composable +private fun GroupSettingsLayout( + chat: Chat, + groupInfo: GroupInfo, + currentUser: User, + sendReceipts: MutableState, + setSendReceipts: (SendReceipts) -> Unit, + chatItemTTL: MutableState, + setChatItemTTL: (ChatItemTTL?) -> Unit, + deletingItems: MutableState, + editGroupProfile: () -> Unit, + addOrEditWelcomeMessage: () -> Unit, + openPreferences: () -> Unit, + clearChat: () -> Unit, + deleteGroup: () -> Unit, + leaveGroup: () -> Unit, + close: () -> Unit +) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.icon_descr_settings)) + + SectionView { + if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { + EditGroupProfileButton(editGroupProfile) + } + if (groupInfo.groupProfile.description != null || (groupInfo.isOwner && groupInfo.businessChat?.chatType == null)) { + AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage) + } + val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences + GroupPreferencesButton(prefsTitleId, openPreferences) + } + val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs + SectionTextFooter(stringResource(footerId)) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + + SectionView { + val activeSortedMembers = remember { chatModel.groupMembers }.value + .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } + + if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { + SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) + } else { + SendReceiptsOptionDisabled() + } + WallpaperButton { + ModalManager.end.showModal { + val chatState = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } } + val currentChat = chatState.value + if (currentChat != null) { + ChatWallpaperEditorModal(currentChat) + } + } + } + } + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + + SectionView { + ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) + } + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + + SectionView { + ClearChatButton(clearChat) + if (groupInfo.canDelete) { + val titleId = if (groupInfo.businessChat == null) MR.strings.button_delete_group else MR.strings.button_delete_chat + DeleteGroupButton(titleId, deleteGroup) + } + if (groupInfo.membership.memberCurrentOrPending) { + val titleId = if (groupInfo.businessChat == null) MR.strings.button_leave_group else MR.strings.button_leave_chat + LeaveGroupButton(titleId, leaveGroup) + } + } + + SectionBottomSpacer() + } +} + +@Composable +private fun WallpaperButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_image), + stringResource(MR.strings.settings_section_title_chat_theme), + click = onClick + ) +} + +@Composable +private fun ClearChatButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_settings_backup_restore), + stringResource(MR.strings.clear_chat_button), + click = onClick, + textColor = WarningOrange, + iconColor = WarningOrange, + ) +} + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 371f0e076f..d47e9174e4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1789,6 +1789,12 @@ Add team members Add friends %1$s MEMBERS + MEMBERS + IMAGES + VIDEOS + LINKS + FILES + VOICES you: %1$s Delete group Delete chat