diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 96b5e2898a..841af60b14 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -26,35 +26,48 @@ struct GroupChatInfoView: View { @State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkNavLinkActive: Bool = false @State private var addMembersNavLinkActive: Bool = false + @State private var settingsNavLinkActive: Bool = false @State private var connectionStats: ConnectionStats? @State private var connectionCode: String? @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true @State private var progressIndicator = false - @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var searchText: String = "" @FocusState private var searchFocussed @State private var showSecrets: Set = [] - + @State private var selectedTab: GroupInfoTab = .members + + enum GroupInfoTab: CaseIterable { + case members + case images + case videos + case files + case links + case voices + + var imageName: String { + switch self { + case .members: return "person.2.fill" + case .images: return "photo.fill" + case .videos: return "video.fill" + case .files: return "doc.fill" + case .links: return "link" + case .voices: return "mic.fill" + } + } + } + enum GroupChatInfoViewAlert: Identifiable { - case deleteGroupAlert - case clearChatAlert - case leaveGroupAlert case cantInviteIncognitoAlert - case largeGroupReceiptsDisabled case blockMemberAlert(mem: GroupMember) case unblockMemberAlert(mem: GroupMember) case blockForAllAlert(mem: GroupMember) case unblockForAllAlert(mem: GroupMember) case error(title: LocalizedStringKey, error: LocalizedStringKey?) - + var id: String { switch self { - case .deleteGroupAlert: return "deleteGroupAlert" - case .clearChatAlert: return "clearChatAlert" - case .leaveGroupAlert: return "leaveGroupAlert" case .cantInviteIncognitoAlert: return "cantInviteIncognitoAlert" - case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled" case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)" case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)" @@ -63,23 +76,23 @@ struct GroupChatInfoView: View { } } } - + var body: some View { NavigationView { let members = chatModel.groupMembers .filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved } .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } - + ZStack { List { groupInfoHeader() .listRowBackground(Color.clear) - + localAliasTextEdit() .listRowBackground(Color.clear) .listRowSeparator(.hidden) .padding(.bottom, 18) - + infoActionButtons() .padding(.horizontal) .frame(maxWidth: .infinity) @@ -87,112 +100,26 @@ struct GroupChatInfoView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - - Section { - if groupInfo.canAddMembers && groupInfo.businessChat == nil { - groupLinkButton() - } - if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { - memberSupportButton() - } - if groupInfo.canModerate { - GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) - } - if groupInfo.membership.memberActive - && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { - UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) - } - } header: { - Text("") - } - - Section { - if groupInfo.isOwner && groupInfo.businessChat == nil { - editGroupButton() - } - if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { - addOrEditWelcomeMessage() - } - GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) - } footer: { - let label: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Only group owners can change group preferences." - : "Only chat owners can change preferences." - ) - Text(label) - .foregroundColor(theme.colors.secondary) - } - - Section { - if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { - sendReceiptsOption() - } else { - sendReceiptsOptionDisabled() - } - NavigationLink { - ChatWallpaperEditorSheet(chat: chat) - } label: { - Label("Chat theme", systemImage: "photo") - } - ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) - } footer: { - Text("Delete chat messages from your device.") - } - - if !groupInfo.nextConnectPrepared { - Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { - if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { - Label("Invite members", systemImage: "plus") - .foregroundColor(Color(uiColor: .tertiaryLabel)) - .onTapGesture { alert = .cantInviteIncognitoAlert } - } else { - addMembersButton() - } - } - searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) - .padding(.leading, 8) - let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - let filteredMembers = s == "" - ? members - : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } - MemberRowView( - chat: chat, - groupInfo: groupInfo, - groupMember: GMember(groupInfo.membership), - scrollToItemId: $scrollToItemId, - user: true, - alert: $alert - ) - ForEach(filteredMembers) { member in - MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, scrollToItemId: $scrollToItemId, alert: $alert) - } - } - } - - Section { - clearChatButton() - if groupInfo.canDelete { - deleteGroupButton() - } - if groupInfo.membership.memberCurrentOrPending { - leaveGroupButton() - } - } - - if developerTools { - Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { - infoRow("Local name", chat.chatInfo.localDisplayName) - infoRow("Database ID", "\(chat.chatInfo.apiId)") - } + .padding(.bottom, 18) + + segmentedControl() + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .clipShape(Rectangle()) + + if selectedTab == .members { + membersTabContent(members: members) + } else { + //TODO: After adding media API calls, add exact UI elements + noDataAvailableView() } } .modifier(ThemedBackground(grouped: true)) .navigationBarHidden(true) .disabled(progressIndicator) .opacity(progressIndicator ? 0.6 : 1) - + if progressIndicator { ProgressView().scaleEffect(2) } @@ -201,11 +128,7 @@ struct GroupChatInfoView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .alert(item: $alert) { alertItem in switch(alertItem) { - case .deleteGroupAlert: return deleteGroupAlert() - case .clearChatAlert: return clearChatAlert() - case .leaveGroupAlert: return leaveGroupAlert() case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert() - case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert() case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem) case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem) @@ -228,7 +151,7 @@ struct GroupChatInfoView: View { } } } - + private func groupInfoHeader() -> some View { VStack { let cInfo = chat.chatInfo @@ -260,7 +183,7 @@ struct GroupChatInfoView: View { } .frame(maxWidth: .infinity, alignment: .center) } - + private func localAliasTextEdit() -> some View { TextField("Set chat name…", text: $localAlias) .disableAutocorrection(true) @@ -277,7 +200,7 @@ struct GroupChatInfoView: View { .multilineTextAlignment(.center) .foregroundColor(theme.colors.secondary) } - + private func setGroupAlias() { Task { do { @@ -291,11 +214,16 @@ struct GroupChatInfoView: View { } } } - + func infoActionButtons() -> some View { GeometryReader { g in - let buttonWidth = g.size.width / 4 - HStack(alignment: .center, spacing: 8) { + let spacing: CGFloat = 8 + let horizontalPadding: CGFloat = 32 + let availableWidth = g.size.width - horizontalPadding + let totalSpacing: CGFloat = spacing * 3 + let buttonWidth = (availableWidth - totalSpacing) / 4 + + HStack(alignment: .center, spacing: spacing) { searchButton(width: buttonWidth) if groupInfo.canAddMembers { addMembersActionButton(width: buttonWidth) @@ -303,11 +231,12 @@ struct GroupChatInfoView: View { if let nextNtfMode = chat.chatInfo.nextNtfMode { muteButton(width: buttonWidth, nextNtfMode: nextNtfMode) } + settingsButton(width: buttonWidth) } .frame(maxWidth: .infinity, alignment: .center) } } - + private func searchButton(width: CGFloat) -> some View { InfoViewButton(image: "magnifyingglass", title: "search", width: width) { dismiss() @@ -315,14 +244,14 @@ struct GroupChatInfoView: View { } .disabled(!groupInfo.ready || chat.chatItems.isEmpty) } - + private func addMembersActionButton(width: CGFloat) -> some View { ZStack { if chat.chatInfo.incognito { InfoViewButton(image: "link.badge.plus", title: "invite", width: width) { groupLinkNavLinkActive = true } - + NavigationLink(isActive: $groupLinkNavLinkActive) { groupLinkDestinationView() } label: { @@ -334,7 +263,7 @@ struct GroupChatInfoView: View { InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) { addMembersNavLinkActive = true } - + NavigationLink(isActive: $addMembersNavLinkActive) { addMembersDestinationView() } label: { @@ -346,7 +275,7 @@ struct GroupChatInfoView: View { } .disabled(!groupInfo.ready) } - + private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { return InfoViewButton( image: nextNtfMode.iconFilled, @@ -357,12 +286,144 @@ struct GroupChatInfoView: View { } .disabled(!groupInfo.ready) } - + + private func settingsButton(width: CGFloat) -> some View { + let isOwner = groupInfo.isOwner + let image = isOwner ? "pencil" : "info.circle" + let title: LocalizedStringKey = isOwner ? "edit" : "info" + + return ZStack { + InfoViewButton(image: image, title: title, width: width) { + settingsNavLinkActive = true + } + + NavigationLink(isActive: $settingsNavLinkActive) { + settingsDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + .disabled(!groupInfo.ready) + } + + private func settingsDestinationView() -> some View { + GroupSettingsView( + chat: chat, + groupInfo: $groupInfo, + sendReceipts: $sendReceipts, + sendReceiptsUserDefault: sendReceiptsUserDefault, + progressIndicator: $progressIndicator, + setSendReceipts: setSendReceipts, + dismiss: dismiss + ) + } + + private func segmentedControl() -> some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + ForEach(GroupInfoTab.allCases, id: \.self) { tab in + Button { + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + selectedTab = tab + } + } label: { + VStack(spacing: 4) { + Image(systemName: tab.imageName) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(selectedTab == tab ? theme.colors.primary : Color(.secondaryLabel)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + + Rectangle() + .fill(selectedTab == tab ? theme.colors.primary : Color.clear) + .frame(height: 3) + } + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity) + } + .padding(.top, 12) + .padding(.bottom, 8) + } + + private func membersTabContent(members: [GMember]) -> some View { + Group { + Section { + if groupInfo.canAddMembers && groupInfo.businessChat == nil { + groupLinkButton() + } + if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { + memberSupportButton() + } + if groupInfo.canModerate { + GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } + if groupInfo.membership.memberActive + && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { + UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } + } + + if !groupInfo.nextConnectPrepared { + Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { + if groupInfo.canAddMembers { + if (chat.chatInfo.incognito) { + Label("Invite members", systemImage: "plus") + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .onTapGesture { alert = .cantInviteIncognitoAlert } + } else { + addMembersButton() + } + } + searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) + .padding(.leading, 8) + let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + let filteredMembers = s == "" + ? members + : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } + MemberRowView( + chat: chat, + groupInfo: groupInfo, + groupMember: GMember(groupInfo.membership), + scrollToItemId: $scrollToItemId, + user: true, + alert: $alert + ) + ForEach(filteredMembers) { member in + MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, scrollToItemId: $scrollToItemId, alert: $alert) + } + } + } + } + } + + private func noDataAvailableView() -> some View { + Section { + HStack { + Spacer() + Text("No data available") + .foregroundColor(theme.colors.secondary) + .padding(.vertical, 40) + Spacer() + } + } + } + + private func setSendReceipts() { + var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults + chatSettings.sendRcpts = sendReceipts.bool() + updateChatSettings(chat, chatSettings: chatSettings) + } + private func addMembersButton() -> some View { let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType { - case .customer: "Add team members" - case .business: "Add friends" - case .none: "Invite members" + case .customer: "Add team members" + case .business: "Add friends" + case .none: "Invite members" } return NavigationLink { addMembersDestinationView() @@ -370,7 +431,7 @@ struct GroupChatInfoView: View { Label(label, systemImage: "plus") } } - + private func addMembersDestinationView() -> some View { AddGroupMembersView(chat: chat, groupInfo: groupInfo) .onAppear { @@ -380,7 +441,7 @@ struct GroupChatInfoView: View { } } } - + private struct MemberRowView: View { var chat: Chat var groupInfo: GroupInfo @@ -389,7 +450,7 @@ struct GroupChatInfoView: View { @EnvironmentObject var theme: AppTheme var user: Bool = false @Binding var alert: GroupChatInfoViewAlert? - + var body: some View { let member = groupMember.wrapped let v1 = HStack{ @@ -408,7 +469,7 @@ struct GroupChatInfoView: View { Spacer() memberInfo(member) } - + let v = ZStack { if user { v1 @@ -422,7 +483,7 @@ struct GroupChatInfoView: View { v1 } } - + if user { v } else if groupInfo.membership.memberRole >= .moderator { @@ -446,12 +507,12 @@ struct GroupChatInfoView: View { } } } - + private func memberInfoView() -> some View { GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember, scrollToItemId: $scrollToItemId) .navigationBarHidden(false) } - + private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { if member.activeConn?.connDisabled ?? false { return "disabled" @@ -461,7 +522,7 @@ struct GroupChatInfoView: View { return member.memberStatus.shortText } } - + @ViewBuilder private func memberInfo(_ member: GroupMember) -> some View { if member.blocked { Text("blocked") @@ -474,7 +535,7 @@ struct GroupChatInfoView: View { } } } - + private func blockSwipe(_ member: GroupMember, _ v: V) -> some View { v.swipeActions(edge: .leading) { if member.memberSettings.showMessages { @@ -492,7 +553,7 @@ struct GroupChatInfoView: View { } } } - + private func blockForAllSwipe(_ member: GroupMember, _ v: V) -> some View { v.swipeActions(edge: .leading) { if member.blockedByAdmin { @@ -510,7 +571,7 @@ struct GroupChatInfoView: View { } } } - + private func removeSwipe(_ member: GroupMember, _ v: V) -> some View { v.swipeActions(edge: .trailing) { Button(role: .destructive) { @@ -521,7 +582,7 @@ struct GroupChatInfoView: View { } } } - + private var memberVerifiedShield: Text { (Text(Image(systemName: "checkmark.shield")) + textSpace) .font(.caption) @@ -530,7 +591,7 @@ struct GroupChatInfoView: View { .foregroundColor(theme.colors.secondary) } } - + private func groupLinkButton() -> some View { NavigationLink { groupLinkDestinationView() @@ -542,7 +603,7 @@ struct GroupChatInfoView: View { } } } - + private func groupLinkDestinationView() -> some View { GroupLinkView( groupId: groupInfo.groupId, @@ -555,7 +616,7 @@ struct GroupChatInfoView: View { .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } - + struct UserSupportChatNavLink: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme @@ -563,7 +624,7 @@ struct GroupChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @Binding var scrollToItemId: ChatItem.ID? @State private var navLinkActive = false - + var body: some View { let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) NavigationLink(isActive: $navLinkActive) { @@ -587,7 +648,7 @@ struct GroupChatInfoView: View { } } } - + private func memberSupportButton() -> some View { NavigationLink { MemberSupportView(groupInfo: groupInfo, scrollToItemId: $scrollToItemId) @@ -607,7 +668,7 @@ struct GroupChatInfoView: View { } } } - + struct GroupReportsChatNavLink: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme @@ -615,7 +676,7 @@ struct GroupChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @Binding var scrollToItemId: ChatItem.ID? @State private var navLinkActive = false - + var body: some View { NavigationLink(isActive: $navLinkActive) { SecondaryChatView( @@ -642,152 +703,6 @@ struct GroupChatInfoView: View { } } } - - private func editGroupButton() -> some View { - NavigationLink { - GroupProfileView( - groupInfo: $groupInfo, - groupProfile: groupInfo.groupProfile - ) - } label: { - Label("Edit group profile", systemImage: "pencil") - } - } - - private func addOrEditWelcomeMessage() -> some View { - NavigationLink { - GroupWelcomeView( - groupInfo: $groupInfo, - groupProfile: groupInfo.groupProfile, - welcomeText: groupInfo.groupProfile.description ?? "" - ) - .navigationTitle("Welcome message") - .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) - } label: { - groupInfo.groupProfile.description == nil - ? Label("Add welcome message", systemImage: "plus.message") - : Label("Welcome message", systemImage: "message") - } - } - - @ViewBuilder private func deleteGroupButton() -> some View { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat" - Button(role: .destructive) { - alert = .deleteGroupAlert - } label: { - Label(label, systemImage: "trash") - .foregroundColor(Color.red) - } - } - - private func clearChatButton() -> some View { - Button() { - alert = .clearChatAlert - } label: { - Label("Clear conversation", systemImage: "gobackward") - .foregroundColor(Color.orange) - } - } - - private func leaveGroupButton() -> some View { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat" - return Button(role: .destructive) { - alert = .leaveGroupAlert - } label: { - Label(label, systemImage: "rectangle.portrait.and.arrow.right") - .foregroundColor(Color.red) - } - } - - // TODO reuse this and clearChatAlert with ChatInfoView - private func deleteGroupAlert() -> Alert { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" - return Alert( - title: Text(label), - message: deleteGroupAlertMessage(groupInfo), - primaryButton: .destructive(Text("Delete")) { - Task { - do { - try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) - await MainActor.run { - dismiss() - chatModel.chatId = nil - chatModel.removeChat(chat.chatInfo.id) - } - } catch let error { - logger.error("deleteGroupAlert apiDeleteChat error: \(error.localizedDescription)") - } - } - }, - secondaryButton: .cancel() - ) - } - - private func clearChatAlert() -> Alert { - Alert( - title: Text("Clear conversation?"), - message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), - primaryButton: .destructive(Text("Clear")) { - Task { - await clearChat(chat) - await MainActor.run { dismiss() } - } - }, - secondaryButton: .cancel() - ) - } - - private func leaveGroupAlert() -> Alert { - let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" - let messageLabel: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "You will stop receiving messages from this group. Chat history will be preserved." - : "You will stop receiving messages from this chat. Chat history will be preserved." - ) - return Alert( - title: Text(titleLabel), - message: Text(messageLabel), - primaryButton: .destructive(Text("Leave")) { - Task { - await leaveGroup(chat.chatInfo.apiId) - await MainActor.run { dismiss() } - } - }, - secondaryButton: .cancel() - ) - } - - private func sendReceiptsOption() -> some View { - WrappedPicker(selection: $sendReceipts) { - ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in - Text(opt.text) - } - } label: { - Label("Send receipts", systemImage: "checkmark.message") - } - .onChange(of: sendReceipts) { _ in - setSendReceipts() - } - } - - private func setSendReceipts() { - var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults - chatSettings.sendRcpts = sendReceipts.bool() - updateChatSettings(chat, chatSettings: chatSettings) - } - - private func sendReceiptsOptionDisabled() -> some View { - HStack { - Label("Send receipts", systemImage: "checkmark.message") - Spacer() - Text("disabled") - .foregroundStyle(.secondary) - } - .onTapGesture { - alert = .largeGroupReceiptsDisabled - } - } } func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { @@ -843,71 +758,6 @@ func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text { ) } -struct GroupPreferencesButton: View { - @Binding var groupInfo: GroupInfo - @State var preferences: FullGroupPreferences - @State var currentPreferences: FullGroupPreferences - var creatingGroup: Bool = false - - private var label: LocalizedStringKey { - groupInfo.businessChat == nil ? "Group preferences" : "Chat preferences" - } - - var body: some View { - NavigationLink { - GroupPreferencesView( - groupInfo: $groupInfo, - preferences: $preferences, - currentPreferences: currentPreferences, - creatingGroup: creatingGroup, - savePreferences: savePreferences - ) - .navigationBarTitle(label) - .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) - .onDisappear { - let saveText = NSLocalizedString( - creatingGroup ? "Save" : "Save and notify group members", - comment: "alert button" - ) - - if groupInfo.fullGroupPreferences != preferences { - showAlert( - title: NSLocalizedString("Save preferences?", comment: "alert title"), - buttonTitle: saveText, - buttonAction: { savePreferences() }, - cancelButton: true - ) - } - } - } label: { - if creatingGroup { - Text("Set group preferences") - } else { - Label(label, systemImage: "switch.2") - } - } - } - - private func savePreferences() { - Task { - do { - var gp = groupInfo.groupProfile - gp.groupPreferences = toGroupPreferences(preferences) - let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp) - await MainActor.run { - groupInfo = gInfo - ChatModel.shared.updateGroup(gInfo) - currentPreferences = preferences - } - } catch { - logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))") - } - } - } -} - - func cantInviteIncognitoAlert() -> Alert { Alert( title: Text("Can't invite contacts!"), @@ -915,13 +765,6 @@ func cantInviteIncognitoAlert() -> Alert { ) } -func largeGroupReceiptsDisabledAlert() -> Alert { - Alert( - title: Text("Receipts are disabled"), - message: Text("This group has over \(SMALL_GROUPS_RCPS_MEM_LIMIT) members, delivery receipts are not sent.") - ) -} - struct GroupChatInfoView_Previews: PreviewProvider { static var previews: some View { GroupChatInfoView( diff --git a/apps/ios/Shared/Views/Chat/Group/GroupSettingsView.swift b/apps/ios/Shared/Views/Chat/Group/GroupSettingsView.swift new file mode 100644 index 0000000000..8cd9f97cfb --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/GroupSettingsView.swift @@ -0,0 +1,319 @@ +// +// GroupSettingsView.swift +// SimpleX +// +// Created by Suren Poghosyan on 12.01.26. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct GroupSettingsView: View { + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @ObservedObject var chat: Chat + @Binding var groupInfo: GroupInfo + @Binding var sendReceipts: SendReceipts + var sendReceiptsUserDefault: Bool + @Binding var progressIndicator: Bool + var setSendReceipts: () -> Void + var dismiss: DismissAction + @State private var alert: GroupSettingsViewAlert? = nil + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + + enum GroupSettingsViewAlert: Identifiable { + case deleteGroupAlert + case clearChatAlert + case leaveGroupAlert + case largeGroupReceiptsDisabled + + var id: String { + switch self { + case .deleteGroupAlert: return "deleteGroupAlert" + case .clearChatAlert: return "clearChatAlert" + case .leaveGroupAlert: return "leaveGroupAlert" + case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled" + } + } + } + + var body: some View { + let members = chatModel.groupMembers + .filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved } + + List { + Section { + if groupInfo.isOwner && groupInfo.businessChat == nil { + editGroupButton() + } + if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { + addOrEditWelcomeMessage() + } + GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) + } footer: { + let label: LocalizedStringKey = ( + groupInfo.businessChat == nil + ? "Only group owners can change group preferences." + : "Only chat owners can change preferences." + ) + Text(label) + .foregroundColor(theme.colors.secondary) + } + + Section { + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + sendReceiptsOption() + } else { + sendReceiptsOptionDisabled() + } + NavigationLink { + ChatWallpaperEditorSheet(chat: chat) + } label: { + Label("Chat theme", systemImage: "photo") + } + ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) + } footer: { + Text("Delete chat messages from your device.") + } + + Section { + clearChatButton() + if groupInfo.canDelete { + deleteGroupButton() + } + if groupInfo.membership.memberCurrentOrPending { + leaveGroupButton() + } + } + + if developerTools { + Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { + infoRow("Local name", chat.chatInfo.localDisplayName) + infoRow("Database ID", "\(chat.chatInfo.apiId)") + } + } + } + .navigationTitle("Chat Settings") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + .alert(item: $alert) { alertItem in + switch(alertItem) { + case .deleteGroupAlert: return deleteGroupAlert() + case .clearChatAlert: return clearChatAlert() + case .leaveGroupAlert: return leaveGroupAlert() + case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert() + } + } + } + + private func editGroupButton() -> some View { + NavigationLink { + GroupProfileView( + groupInfo: $groupInfo, + groupProfile: groupInfo.groupProfile + ) + } label: { + Label("Edit group profile", systemImage: "pencil") + } + } + + private func addOrEditWelcomeMessage() -> some View { + NavigationLink { + GroupWelcomeView( + groupInfo: $groupInfo, + groupProfile: groupInfo.groupProfile, + welcomeText: groupInfo.groupProfile.description ?? "" + ) + .navigationTitle("Welcome message") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + groupInfo.groupProfile.description == nil + ? Label("Add welcome message", systemImage: "plus.message") + : Label("Welcome message", systemImage: "message") + } + } + + private func sendReceiptsOption() -> some View { + WrappedPicker(selection: $sendReceipts) { + ForEach([.yes, .no, .userDefault(sendReceiptsUserDefault)]) { (opt: SendReceipts) in + Text(opt.text) + } + } label: { + Label("Send receipts", systemImage: "checkmark.message") + } + .onChange(of: sendReceipts) { _ in + setSendReceipts() + } + } + + private func sendReceiptsOptionDisabled() -> some View { + HStack { + Label("Send receipts", systemImage: "checkmark.message") + Spacer() + Text("disabled") + .foregroundStyle(.secondary) + } + .onTapGesture { + alert = .largeGroupReceiptsDisabled + } + } + + @ViewBuilder private func deleteGroupButton() -> some View { + let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat" + Button(role: .destructive) { + alert = .deleteGroupAlert + } label: { + Label(label, systemImage: "trash") + .foregroundColor(Color.red) + } + } + + private func clearChatButton() -> some View { + Button() { + alert = .clearChatAlert + } label: { + Label("Clear conversation", systemImage: "gobackward") + .foregroundColor(Color.orange) + } + } + + private func leaveGroupButton() -> some View { + let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat" + return Button(role: .destructive) { + alert = .leaveGroupAlert + } label: { + Label(label, systemImage: "rectangle.portrait.and.arrow.right") + .foregroundColor(Color.red) + } + } + + private func deleteGroupAlert() -> Alert { + let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" + return Alert( + title: Text(label), + message: deleteGroupAlertMessage(groupInfo), + primaryButton: .destructive(Text("Delete")) { + Task { + do { + try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) + await MainActor.run { + dismiss() + chatModel.chatId = nil + chatModel.removeChat(chat.chatInfo.id) + } + } catch let error { + logger.error("deleteGroupAlert apiDeleteChat error: \(error.localizedDescription)") + } + } + }, + secondaryButton: .cancel() + ) + } + + private func clearChatAlert() -> Alert { + Alert( + title: Text("Clear conversation?"), + message: Text("All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you."), + primaryButton: .destructive(Text("Clear")) { + Task { + await clearChat(chat) + await MainActor.run { dismiss() } + } + }, + secondaryButton: .cancel() + ) + } + + private func leaveGroupAlert() -> Alert { + let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" + let messageLabel: LocalizedStringKey = ( + groupInfo.businessChat == nil + ? "You will stop receiving messages from this group. Chat history will be preserved." + : "You will stop receiving messages from this chat. Chat history will be preserved." + ) + return Alert( + title: Text(titleLabel), + message: Text(messageLabel), + primaryButton: .destructive(Text("Leave")) { + Task { + await leaveGroup(chat.chatInfo.apiId) + await MainActor.run { dismiss() } + } + }, + secondaryButton: .cancel() + ) + } + + private func largeGroupReceiptsDisabledAlert() -> Alert { + Alert( + title: Text("Receipts are disabled"), + message: Text("This group has over \(SMALL_GROUPS_RCPS_MEM_LIMIT) members, delivery receipts are not sent.") + ) + } +} + +struct GroupPreferencesButton: View { + @Binding var groupInfo: GroupInfo + @State var preferences: FullGroupPreferences + @State var currentPreferences: FullGroupPreferences + var creatingGroup: Bool = false + + private var label: LocalizedStringKey { + groupInfo.businessChat == nil ? "Group preferences" : "Chat preferences" + } + + var body: some View { + NavigationLink { + GroupPreferencesView( + groupInfo: $groupInfo, + preferences: $preferences, + currentPreferences: currentPreferences, + creatingGroup: creatingGroup, + savePreferences: savePreferences + ) + .navigationBarTitle(label) + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + .onDisappear { + let saveText = NSLocalizedString( + creatingGroup ? "Save" : "Save and notify group members", + comment: "alert button" + ) + + if groupInfo.fullGroupPreferences != preferences { + showAlert( + title: NSLocalizedString("Save preferences?", comment: "alert title"), + buttonTitle: saveText, + buttonAction: { savePreferences() }, + cancelButton: true + ) + } + } + } label: { + if creatingGroup { + Text("Set group preferences") + } else { + Label(label, systemImage: "switch.2") + } + } + } + + private func savePreferences() { + Task { + do { + var gp = groupInfo.groupProfile + gp.groupPreferences = toGroupPreferences(preferences) + let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp) + await MainActor.run { + groupInfo = gInfo + ChatModel.shared.updateGroup(gInfo) + currentPreferences = preferences + } + } catch { + logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))") + } + } + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index fa9a4efdf7..4fa6887aca 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415323A4082FC92887F906 /* WebRTCClient.swift */; }; 18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */; }; 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */; }; + 33AC96122F15068300C672B9 /* GroupSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33AC96112F15068200C672B9 /* GroupSettingsView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00168028C4FE760094D739 /* KeyChain.swift */; }; @@ -335,6 +336,7 @@ 18415B08031E8FB0F7FC27F9 /* CallViewRenderers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallViewRenderers.swift; sourceTree = ""; }; 18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = ""; }; + 33AC96112F15068200C672B9 /* GroupSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupSettingsView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; 5C00168028C4FE760094D739 /* KeyChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyChain.swift; sourceTree = ""; }; @@ -1132,6 +1134,7 @@ 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */, 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */, 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */, + 33AC96112F15068200C672B9 /* GroupSettingsView.swift */, 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */, 5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */, 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */, @@ -1565,6 +1568,7 @@ 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */, + 33AC96122F15068300C672B9 /* GroupSettingsView.swift in Sources */, 8CAD466F2D15A8100078D18F /* ChatScrollHelpers.swift in Sources */, 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,