From 6e6afdbd25cb58e540b088104889c059c0f8dcca Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 31 Jul 2024 23:00:14 +0900 Subject: [PATCH] ios: multiple messages deletion (#4535) * ios: multiple messages deletion * changes * layout * fix * changes in design and UX * fixes * padding * paddings * refactor * changes * gray circles, separator, optimize * titles * disable moderation for own single message --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Views/Chat/ChatView.swift | 437 +++++++++++++----- .../Chat/SelectableChatItemToolbars.swift | 130 ++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/ChatTypes.swift | 9 +- 4 files changed, 461 insertions(+), 119 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5e2825d30c..aa033203dc 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -43,6 +43,9 @@ struct ChatView: View { @State private var showGroupLinkSheet: Bool = false @State private var groupLink: String? @State private var groupLinkMemberRole: GroupMemberRole = .member + @State private var selectedChatItems: Set? = nil + @State private var showDeleteSelectedMessages: Bool = false + @State private var allowToDeleteSelectedMessagesForAll: Bool = false var body: some View { if #available(iOS 16.0, *) { @@ -80,25 +83,58 @@ struct ChatView: View { floatingButtons(counts: floatingButtonModel.unreadChatItemCounts) } connectingText() - ComposeView( - chat: chat, - composeState: $composeState, - keyboardVisible: $keyboardVisible - ) - .disabled(!cInfo.sendMsgEnabled) + if selectedChatItems == nil { + ComposeView( + chat: chat, + composeState: $composeState, + keyboardVisible: $keyboardVisible + ) + .disabled(!cInfo.sendMsgEnabled) + } else { + SelectedItemsBottomToolbar( + chatItems: ItemsModel.shared.reversedChatItems, + selectedChatItems: $selectedChatItems, + chatInfo: chat.chatInfo, + deleteItems: { forAll in + allowToDeleteSelectedMessagesForAll = forAll + showDeleteSelectedMessages = true + }, + moderateItems: { + if case let .group(groupInfo) = chat.chatInfo { + showModerateSelectedMessagesAlert(groupInfo) + } + } + ) + } } .navigationTitle(cInfo.chatViewName) .background(theme.colors.background) .navigationBarTitleDisplayMode(.inline) .environmentObject(theme) + .confirmationDialog(selectedChatItems?.count == 1 ? "Delete message?" : "Delete \((selectedChatItems?.count ?? 0)) messages?", isPresented: $showDeleteSelectedMessages, titleVisibility: .visible) { + Button("Delete for me", role: .destructive) { + if let selected = selectedChatItems { + deleteMessages(chat, selected.sorted(), .cidmInternal, moderate: false, deletedSelectedMessages) } + } + if allowToDeleteSelectedMessagesForAll { + Button(broadcastDeleteButtonText(chat), role: .destructive) { + if let selected = selectedChatItems { + allowToDeleteSelectedMessagesForAll = false + deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: false, deletedSelectedMessages) + } + } + } + } .onAppear { loadChat(chat: chat) initChatView() + selectedChatItems = nil } .onChange(of: chatModel.chatId) { cId in showChatInfoSheet = false stopAudioPlayer() if let cId { + selectedChatItems = nil if let c = chatModel.getChat(cId) { chat = c } @@ -138,7 +174,9 @@ struct ChatView: View { } .toolbar { ToolbarItem(placement: .principal) { - if case let .direct(contact) = cInfo { + if selectedChatItems != nil { + SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) + } else if case let .direct(contact) = cInfo { Button { Task { do { @@ -192,66 +230,76 @@ struct ChatView: View { } } ToolbarItem(placement: .navigationBarTrailing) { - switch cInfo { - case let .direct(contact): - HStack { - let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser - if callsPrefEnabled { - if chatModel.activeCall == nil { - callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready || !contact.active) - } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { - endCallButton(call) - } + if selectedChatItems != nil { + Button { + withAnimation { + selectedChatItems = nil } - Menu { - if callsPrefEnabled && chatModel.activeCall == nil { - Button { - CallController.shared.startCall(contact, .video) - } label: { - Label("Video call", systemImage: "video") + } label: { + Text("Cancel") + } + } else { + switch cInfo { + case let .direct(contact): + HStack { + let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser + if callsPrefEnabled { + if chatModel.activeCall == nil { + callButton(contact, .audio, imageName: "phone") + .disabled(!contact.ready || !contact.active) + } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { + endCallButton(call) } - .disabled(!contact.ready || !contact.active) } - searchButton() - ToggleNtfsButton(chat: chat) - .disabled(!contact.ready || !contact.active) - } label: { - Image(systemName: "ellipsis") - } - } - case let .group(groupInfo): - HStack { - if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { - groupLinkButton() - .appSheet(isPresented: $showGroupLinkSheet) { - GroupLinkView( - groupId: groupInfo.groupId, - groupLink: $groupLink, - groupLinkMemberRole: $groupLinkMemberRole, - showTitle: true, - creatingGroup: false - ) - } - } else { - addMembersButton() - .appSheet(isPresented: $showAddMembersSheet) { - AddGroupMembersView(chat: chat, groupInfo: groupInfo) + Menu { + if callsPrefEnabled && chatModel.activeCall == nil { + Button { + CallController.shared.startCall(contact, .video) + } label: { + Label("Video call", systemImage: "video") } + .disabled(!contact.ready || !contact.active) + } + searchButton() + ToggleNtfsButton(chat: chat) + .disabled(!contact.ready || !contact.active) + } label: { + Image(systemName: "ellipsis") } } - Menu { - searchButton() - ToggleNtfsButton(chat: chat) - } label: { - Image(systemName: "ellipsis") + case let .group(groupInfo): + HStack { + if groupInfo.canAddMembers { + if (chat.chatInfo.incognito) { + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } + } else { + addMembersButton() + .appSheet(isPresented: $showAddMembersSheet) { + AddGroupMembersView(chat: chat, groupInfo: groupInfo) + } + } + } + Menu { + searchButton() + ToggleNtfsButton(chat: chat) + } label: { + Image(systemName: "ellipsis") + } } + case .local: + searchButton() + default: + EmptyView() } - case .local: - searchButton() - default: - EmptyView() } } } @@ -553,6 +601,33 @@ struct ChatView: View { } } + private func showModerateSelectedMessagesAlert(_ groupInfo: GroupInfo) { + guard let count = selectedChatItems?.count, count > 0 else { return } + + AlertManager.shared.showAlert(Alert( + title: Text(count == 1 ? "Delete member message?" : "Delete \(count) messages of members?"), + message: Text( + groupInfo.fullGroupPreferences.fullDelete.on + ? (count == 1 ? "The message will be deleted for all members." : "The messages will be deleted for all members.") + : (count == 1 ? "The message will be marked as moderated for all members." : "The messages will be marked as moderated for all members.") + ), + primaryButton: .destructive(Text("Delete")) { + if let selected = selectedChatItems { + deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: true, deletedSelectedMessages) + } + }, + secondaryButton: .cancel() + )) + } + + private func deletedSelectedMessages() async { + await MainActor.run { + withAnimation { + selectedChatItems = nil + } + } + } + private func loadChatItems(_ cInfo: ChatInfo) { Task { if loadingItems || firstPage { return } @@ -604,7 +679,8 @@ struct ChatView: View { maxWidth: maxWidth, composeState: $composeState, selectedMember: $selectedMember, - revealedChatItem: $revealedChatItem + revealedChatItem: $revealedChatItem, + selectedChatItems: $selectedChatItems ) } @@ -626,6 +702,8 @@ struct ChatView: View { @State private var chatItemInfo: ChatItemInfo? @State private var showForwardingSheet: Bool = false + @Binding var selectedChatItems: Set? + @State private var allowMenu: Bool = true var revealed: Bool { chatItem == revealedChatItem } @@ -642,9 +720,29 @@ struct ChatView: View { ForEach(items, id: \.1.viewId) { (i, ci) in let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1] chatItemView(ci, nil, prev) + .overlay { + if let selected = selectedChatItems, ci.canBeDeletedForSelf { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + let checked = selected.contains(ci.id) + selectUnselectChatItem(select: !checked, ci) + } + } + } } } else { chatItemView(chatItem, range, prevItem) + .overlay { + if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + let checked = selected.contains(chatItem.id) + selectUnselectChatItem(select: !checked, chatItem) + } + } + } } } .onAppear { @@ -689,11 +787,11 @@ struct ChatView: View { if case let .groupRcv(member) = ci.chatDir, case let .group(groupInfo) = chat.chatInfo { let (prevMember, memCount): (GroupMember?, Int) = - if let range = range { - m.getPrevHiddenMember(member, range) - } else { - (nil, 1) - } + if let range = range { + m.getPrevHiddenMember(member, range) + } else { + (nil, 1) + } if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil { VStack(alignment: .leading, spacing: 4) { if ci.content.showMemberName { @@ -706,41 +804,64 @@ struct ChatView: View { .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) - .padding(.leading, memberImageSize + 14) + .padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0)) .padding(.top, 7) } - HStack(alignment: .top, spacing: 8) { - ProfileImage(imageStr: member.memberProfile.image, size: memberImageSize, backgroundColor: theme.colors.background) - .onTapGesture { - if m.membersLoaded { - selectedMember = m.getGroupMember(member.groupMemberId) - } else { - Task { - await m.loadGroupMembers(groupInfo) { - selectedMember = m.getGroupMember(member.groupMemberId) + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.trailing, 12) + } + HStack(alignment: .top, spacing: 8) { + ProfileImage(imageStr: member.memberProfile.image, size: memberImageSize, backgroundColor: theme.colors.background) + .onTapGesture { + if m.membersLoaded { + selectedMember = m.getGroupMember(member.groupMemberId) + } else { + Task { + await m.loadGroupMembers(groupInfo) { + selectedMember = m.getGroupMember(member.groupMemberId) + } } } } - } - .appSheet(item: $selectedMember) { member in - GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true) - } - chatItemWithMenu(ci, range, maxWidth) + .appSheet(item: $selectedMember) { member in + GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true) + } + chatItemWithMenu(ci, range, maxWidth) + } } } .padding(.bottom, 5) .padding(.trailing) .padding(.leading, 12) } else { - chatItemWithMenu(ci, range, maxWidth) - .padding(.bottom, 5) - .padding(.trailing) - .padding(.leading, memberImageSize + 8 + 12) + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.leading, 12) + } + chatItemWithMenu(ci, range, maxWidth) + .padding(.trailing) + .padding(.leading, memberImageSize + 8 + 12) + } + .padding(.bottom, 5) } } else { - chatItemWithMenu(ci, range, maxWidth) - .padding(.horizontal) - .padding(.bottom, 5) + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + if chat.chatInfo.chatType == .group { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.leading, 12) + } else { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.leading) + } + } + chatItemWithMenu(ci, range, maxWidth) + .padding(.horizontal) + } + .padding(.bottom, 5) } } @@ -775,17 +896,17 @@ struct ChatView: View { } .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { - deleteMessage(.cidmInternal) + deleteMessage(.cidmInternal, moderate: false) } if let di = deletingItem, di.meta.deletable && !di.localNote { - Button(broadcastDeleteButtonText, role: .destructive) { - deleteMessage(.cidmBroadcast) + Button(broadcastDeleteButtonText(chat), role: .destructive) { + deleteMessage(.cidmBroadcast, moderate: false) } } } .confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { - deleteMessages() + deleteMessages(chat, deletingItems, moderate: false) } } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) @@ -894,7 +1015,7 @@ struct ChatView: View { if !live || !ci.meta.isLive { deleteButton(ci) } - if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { + if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo), ci.chatDir != .groupSnd { moderateButton(ci, groupInfo) } } else if ci.meta.itemDeleted != nil { @@ -918,6 +1039,10 @@ struct ChatView: View { } else { EmptyView() } + if selectedChatItems == nil && ci.canBeDeletedForSelf { + Divider() + selectButton(ci) + } } var replyButton: Button { @@ -1090,6 +1215,21 @@ struct ChatView: View { } } + private func selectButton(_ ci: ChatItem) -> Button { + Button { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation { + selectUnselectChatItem(select: true, ci) + } + } + } label: { + Label( + NSLocalizedString("Select", comment: "chat item action"), + systemImage: "checkmark.circle" + ) + } + } + private func viewInfoButton(_ ci: ChatItem) -> Button { Button { Task { @@ -1200,7 +1340,7 @@ struct ChatView: View { ), primaryButton: .destructive(Text("Delete")) { deletingItem = ci - deleteMessage(.cidmBroadcast) + deleteMessage(.cidmBroadcast, moderate: true) }, secondaryButton: .cancel() )) @@ -1251,47 +1391,46 @@ struct ChatView: View { } } - private var broadcastDeleteButtonText: LocalizedStringKey { - chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" - } - var deleteMessagesTitle: LocalizedStringKey { let n = deletingItems.count return n == 1 ? "Delete message?" : "Delete \(n) messages?" } - private func deleteMessages() { - let itemIds = deletingItems - if itemIds.count > 0 { - let chatInfo = chat.chatInfo - Task { - do { - let deletedItems = try await apiDeleteChatItems( - type: chatInfo.chatType, - id: chatInfo.apiId, - itemIds: itemIds, - mode: .cidmInternal - ) - await MainActor.run { - for di in deletedItems { - m.removeChatItem(chatInfo, di.deletedChatItem.chatItem) - } - } - } catch { - logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + private func selectUnselectChatItem(select: Bool, _ ci: ChatItem) { + selectedChatItems = selectedChatItems ?? [] + var itemIds: [Int64] = [] + if !revealed, + let currIndex = m.getChatItemIndex(ci), + let ciCategory = ci.mergeCategory { + let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory) + if let range = itemsRange(currIndex, prevHidden) { + for i in range { + itemIds.append(ItemsModel.shared.reversedChatItems[i].id) } + } else { + itemIds.append(ci.id) } + } else { + itemIds.append(ci.id) + } + if select { + if let sel = selectedChatItems { + selectedChatItems = sel.union(itemIds) + } + } else { + itemIds.forEach { selectedChatItems?.remove($0) } } } - private func deleteMessage(_ mode: CIDeleteMode) { + private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) { logger.debug("ChatView deleteMessage") Task { logger.debug("ChatView deleteMessage: in Task") do { if let di = deletingItem { let r = if case .cidmBroadcast = mode, - let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) { + moderate, + let (groupInfo, _) = di.memberToModerate(chat.chatInfo) { try await apiDeleteMemberChatItems( groupId: groupInfo.apiId, itemIds: [di.id] @@ -1320,6 +1459,68 @@ struct ChatView: View { } } } + + private struct SelectedChatItem: View { + @EnvironmentObject var theme: AppTheme + var ciId: Int64 + @Binding var selectedChatItems: Set? + @State var checked: Bool = false + var body: some View { + Image(systemName: checked ? "checkmark.circle.fill" : "circle") + .resizable() + .foregroundColor(checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel)) + .frame(width: 24, height: 24) + .onAppear { + checked = selectedChatItems?.contains(ciId) == true + } + .onChange(of: selectedChatItems) { selected in + checked = selected?.contains(ciId) == true + } + } + } + } +} + +private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { + chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" +} + +private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDeleteMode = .cidmInternal, moderate: Bool, _ onSuccess: @escaping () async -> Void = {}) { + let itemIds = deletingItems + if itemIds.count > 0 { + let chatInfo = chat.chatInfo + Task { + do { + let deletedItems = if case .cidmBroadcast = mode, + moderate, + case .group = chat.chatInfo { + try await apiDeleteMemberChatItems( + groupId: chatInfo.apiId, + itemIds: itemIds + ) + } else { + try await apiDeleteChatItems( + type: chatInfo.chatType, + id: chatInfo.apiId, + itemIds: itemIds, + mode: mode + ) + } + + await MainActor.run { + for di in deletedItems { + if let toItem = di.toChatItem { + _ = ChatModel.shared.upsertChatItem(chat.chatInfo, toItem.chatItem) + } else { + ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem) + } + } + } + await onSuccess() + } catch { + logger.error("ChatView.deleteMessages error: \(error.localizedDescription)") + } + } } } diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift new file mode 100644 index 0000000000..497a1bf5b5 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -0,0 +1,130 @@ +// +// SelectableChatItemToolbars.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 30.07.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SelectedItemsTopToolbar: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + @Binding var selectedChatItems: Set? + + var body: some View { + let count = selectedChatItems?.count ?? 0 + return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline) + .foregroundColor(theme.colors.onBackground) + .frame(width: 220) + } +} + +struct SelectedItemsBottomToolbar: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + let chatItems: [ChatItem] + @Binding var selectedChatItems: Set? + var chatInfo: ChatInfo + // Bool - delete for everyone is possible + var deleteItems: (Bool) -> Void + var moderateItems: () -> Void + //var shareItems: () -> Void + @State var deleteEnabled: Bool = false + @State var deleteForEveryoneEnabled: Bool = false + + @State var canModerate: Bool = false + @State var moderateEnabled: Bool = false + + @State var allButtonsDisabled = false + + var body: some View { + VStack(spacing: 0) { + Divider() + + HStack(alignment: .center) { + Button { + deleteItems(deleteForEveryoneEnabled) + } label: { + Image(systemName: "trash") + .resizable() + .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red) + } + .disabled(!deleteEnabled || allButtonsDisabled) + + Spacer() + Button { + moderateItems() + } label: { + Image(systemName: "flag") + .resizable() + .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red) + } + .disabled(!moderateEnabled || allButtonsDisabled) + .opacity(canModerate ? 1 : 0) + + + Spacer() + Button { + //shareItems() + } label: { + Image(systemName: "square.and.arrow.up") + .resizable() + .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary) + } + .disabled(allButtonsDisabled) + .opacity(0) + } + .frame(maxHeight: .infinity) + .padding([.leading, .trailing], 12) + } + .onAppear { + recheckItems(chatInfo, chatItems, selectedChatItems) + } + .onChange(of: chatInfo) { info in + recheckItems(info, chatItems, selectedChatItems) + } + .onChange(of: chatItems) { items in + recheckItems(chatInfo, items, selectedChatItems) + } + .onChange(of: selectedChatItems) { selected in + recheckItems(chatInfo, chatItems, selected) + } + .frame(height: 55.5) + .background(.thinMaterial) + } + + private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set?) { + let count = selectedItems?.count ?? 0 + allButtonsDisabled = count == 0 || count > 20 + canModerate = possibleToModerate(chatInfo) + if let selected = selectedItems { + (deleteEnabled, deleteForEveryoneEnabled, moderateEnabled, _, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in + if selected.contains(ci.id) { + var (de, dee, me, onlyOwnGroupItems, sel) = r + de = de && ci.canBeDeletedForSelf + dee = dee && ci.meta.deletable && !ci.localNote + onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd + me = me && !onlyOwnGroupItems && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil + sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list + return (de, dee, me, onlyOwnGroupItems, sel) + } else { + return r + } + } + } + } + + private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool { + return switch chatInfo { + case let .group(groupInfo): + groupInfo.membership.memberRole >= .admin + default: false + } + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 4a3be1e473..20c242c51f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -193,6 +193,7 @@ 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; }; 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; + 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; }; CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; }; CE3097FB2C4C0C9F00180898 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */; }; @@ -529,6 +530,7 @@ 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = ""; }; 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; + 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = ""; }; CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = ""; }; CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = ""; }; @@ -716,6 +718,7 @@ 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */, 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, + 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */, ); path = Chat; sourceTree = ""; @@ -1426,6 +1429,7 @@ 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, + 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */, 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */, 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index d173f2fad8..f9eb0ed39e 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2454,13 +2454,16 @@ public struct ChatItem: Identifiable, Decodable, Hashable { } } - public func memberToModerate(_ chatInfo: ChatInfo) -> (GroupInfo, GroupMember)? { + public func memberToModerate(_ chatInfo: ChatInfo) -> (GroupInfo, GroupMember?)? { switch (chatInfo, chatDir) { case let (.group(groupInfo), .groupRcv(groupMember)): let m = groupInfo.membership return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil ? (groupInfo, groupMember) : nil + case let (.group(groupInfo), .groupSnd): + let m = groupInfo.membership + return m.memberRole >= .admin ? (groupInfo, nil) : nil default: return nil } } @@ -2475,6 +2478,10 @@ public struct ChatItem: Identifiable, Decodable, Hashable { } } + public var canBeDeletedForSelf: Bool { + (content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete + } + public static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, deletable: Bool = true, editable: Bool = true) -> ChatItem { ChatItem( chatDir: dir,