// // GroupChatInfoView.swift // SimpleX (iOS) // // Created by JRoberts on 14.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // // Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 // Spec: spec/client/chat-view.md#GroupChatInfoView struct GroupChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat @Binding var groupInfo: GroupInfo @Binding var scrollToItemId: ChatItem.ID? @Binding var composeState: ComposeState var onSearch: () -> Void @State var localAlias: String @State private var showSharePicker = false @FocusState private var aliasTextFieldFocused: Bool @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: GroupLink? @State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkNavLinkActive: Bool = false @State private var addMembersNavLinkActive: 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 = [] 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)" case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)" case let .error(title, _): return "error \(title)" } } } 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) .frame(height: infoViewActionButtonHeight) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) if groupInfo.useRelays && groupInfo.membership.memberIncognito { Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { HStack { Text("Your random profile") Spacer() Text(groupInfo.membership.chatViewName) .foregroundStyle(.indigo) } } } let showUserSupportChat = groupInfo.membership.memberActive && ((groupInfo.fullGroupPreferences.support.on && groupInfo.membership.memberRole < .moderator) || groupInfo.membership.supportChat != nil) if groupInfo.useRelays { Section { // TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership) if groupInfo.isOwner && groupLink != nil { channelLinkButton() channelSimplexNameButton() } else if let link = groupInfo.groupProfile.publicGroup?.groupLink { SimpleXLinkQRCode(uri: link) Button { showShareSheet(items: [simplexChatLink(link)]) } label: { Label("Share link", systemImage: "square.and.arrow.up") } Button { showSharePicker = true } label: { Label("Share via chat", systemImage: "arrowshape.turn.up.forward") } } if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) { channelMembersButton() } if groupInfo.membership.memberRole >= .moderator { memberSupportButton() } if showUserSupportChat { UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) } } footer: { if !groupInfo.isOwner && groupInfo.groupProfile.publicGroup?.groupLink != nil { Text("You can share a link or a QR code - anybody will be able to join the channel.") .foregroundColor(theme.colors.secondary) } } } else { 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 showUserSupportChat { 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.useRelays ? "Only channel owners can change channel preferences." : groupInfo.businessChat == nil ? "Only group owners can change group preferences." : "Only chat owners can change preferences." ) Text(label) .foregroundColor(theme.colors.secondary) } Section { if !groupInfo.useRelays { 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 && !groupInfo.useRelays { 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 { if groupInfo.useRelays && (groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole == .relay })) { channelRelaysButton() } clearChatButton() if groupInfo.canDelete { deleteGroupButton() } if groupInfo.membership.memberCurrentOrPending { if !groupInfo.useRelays || !groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }) { leaveGroupButton() } } } if groupInfo.useRelays && groupInfo.isOwner { Section(header: Text("Advanced options").foregroundColor(theme.colors.secondary)) { channelWebAccessButton() } } if developerTools { Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { infoRow("Local name", chat.chatInfo.localDisplayName) infoRow("Database ID", "\(chat.chatInfo.apiId)") } } } .modifier(ThemedBackground(grouped: true)) .navigationBarHidden(true) .disabled(progressIndicator) .opacity(progressIndicator ? 0.6 : 1) if progressIndicator { ProgressView().scaleEffect(2) } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .sheet(isPresented: $showSharePicker) { shareChannelPicker(groupInfo: groupInfo, composeState: $composeState) } .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) case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem) case let .error(title, error): return mkAlert(title: title, message: error) } } .onAppear { if let currentUser = chatModel.currentUser { sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups } sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) if !groupInfo.useRelays || groupInfo.isOwner { do { if let gLink = try apiGetGroupLink(groupInfo.groupId) { groupLink = gLink groupLinkMemberRole = gLink.acceptMemberRole } } catch let error { logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") } } } } private func groupInfoHeader() -> some View { VStack { let cInfo = chat.chatInfo // show actual display name, alias can be edited in this view let displayName = (cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName).trimmingCharacters(in: .whitespacesAndNewlines) let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines) ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill)) .padding(.top, 12) .padding() Text(displayName) .font(.largeTitle) .multilineTextAlignment(.center) .lineLimit(4) .padding(.bottom, 2) if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) { Text(cInfo.fullName) .font(.title2) .multilineTextAlignment(.center) .lineLimit(3) .padding(.bottom, 2) } if let descr = cInfo.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" { let r = markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background) msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true) .multilineTextAlignment(.center) .lineLimit(4) .fixedSize(horizontal: false, vertical: true) } if let access = groupInfo.groupProfile.publicGroup?.publicGroupAccess, let domain = access.groupDomainClaim?.shortName, groupInfo.groupDomainVerified != nil || access.groupDomainClaim?.proof != nil { SimplexNameView( simplexName: "#\(domain)", verified: groupInfo.groupDomainVerified, verify: { do { let (gInfo, reason) = try await apiVerifyGroupDomain(groupInfo.groupId) await MainActor.run { chatModel.updateGroup(gInfo) groupInfo = gInfo } return (gInfo.groupDomainVerified, reason) } catch { logger.error("apiVerifyGroupDomain: \(responseError(error))") return nil } } ) } if let webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage, let url = URL(string: webPage) { Link(destination: url) { Text(webPage) .font(.subheadline) .lineLimit(1) .truncationMode(.tail) } } if groupInfo.useRelays, let count = groupInfo.groupSummary.publicMemberCount, count > 0 { Text(subscriberCountStr(count)) .font(.subheadline) .foregroundColor(theme.colors.secondary) .padding(.bottom, 2) } } .frame(maxWidth: .infinity, alignment: .center) } private func localAliasTextEdit() -> some View { TextField("Set chat name…", text: $localAlias) .disableAutocorrection(true) .focused($aliasTextFieldFocused) .submitLabel(.done) .onChange(of: aliasTextFieldFocused) { focused in if !focused { setGroupAlias() } } .onSubmit { setGroupAlias() } .multilineTextAlignment(.center) .foregroundColor(theme.colors.secondary) } private func setGroupAlias() { Task { do { if let gInfo = try await apiSetGroupAlias(groupId: chat.chatInfo.apiId, localAlias: localAlias) { await MainActor.run { chatModel.updateGroup(gInfo) } } } catch { logger.error("setGroupAlias error: \(responseError(error))") } } } func infoActionButtons() -> some View { GeometryReader { g in let buttonWidth = g.size.width / 4 HStack(alignment: .center, spacing: 8) { searchButton(width: buttonWidth) if groupInfo.useRelays && groupInfo.isOwner { channelLinkActionButton(width: buttonWidth) } else if !groupInfo.useRelays && groupInfo.canAddMembers { addMembersActionButton(width: buttonWidth) } if let nextNtfMode = chat.chatInfo.nextNtfMode { muteButton(width: buttonWidth, nextNtfMode: nextNtfMode) } } .frame(maxWidth: .infinity, alignment: .center) } } private func searchButton(width: CGFloat) -> some View { InfoViewButton(image: "magnifyingglass", title: "search", width: width) { dismiss() onSearch() } .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: { EmptyView() } .frame(width: 1, height: 1) .hidden() } else { InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) { addMembersNavLinkActive = true } NavigationLink(isActive: $addMembersNavLinkActive) { addMembersDestinationView() } label: { EmptyView() } .frame(width: 1, height: 1) .hidden() } } .disabled(!groupInfo.ready) } private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { return InfoViewButton( image: nextNtfMode.iconFilled, title: "\(nextNtfMode.text(mentions: true))", width: width ) { toggleNotifications(chat, enableNtfs: nextNtfMode) } .disabled(!groupInfo.ready) } private func channelLinkActionButton(width: CGFloat) -> some View { ZStack { InfoViewButton(image: "link", title: "link", width: width) { groupLinkNavLinkActive = true } NavigationLink(isActive: $groupLinkNavLinkActive) { groupLinkDestinationView() } label: { EmptyView() } .frame(width: 1, height: 1) .hidden() } .disabled(!groupInfo.ready) } 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" } return NavigationLink { addMembersDestinationView() } label: { Label(label, systemImage: "plus") } } private func addMembersDestinationView() -> some View { AddGroupMembersView(chat: chat, groupInfo: groupInfo) .onAppear { searchFocussed = false Task { await chatModel.loadGroupMembers(groupInfo) } } } private struct MemberRowView: View { var chat: Chat var groupInfo: GroupInfo @ObservedObject var groupMember: GMember @Binding var scrollToItemId: ChatItem.ID? @EnvironmentObject var theme: AppTheme var user: Bool = false @Binding var alert: GroupChatInfoViewAlert? var body: some View { let member = groupMember.wrapped let v1 = HStack{ MemberProfileImage(member, size: 38) .padding(.trailing, 2) // TODO server connection status VStack(alignment: .leading) { let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge) .lineLimit(1) (user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member))) .lineLimit(1) .font(.caption) .foregroundColor(theme.colors.secondary) } Spacer() memberInfo(member) } let v = ZStack { if user { v1 } else { NavigationLink { memberInfoView() } label: { EmptyView() } .opacity(0) v1 } } if user { v } else if groupInfo.membership.memberRole >= .moderator { // TODO if there are more actions, refactor with lists of swipeActions let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo) let canRemove = member.canBeRemoved(groupInfo: groupInfo) if canBlockForAll && canRemove { removeSwipe(member, blockForAllSwipe(member, v)) } else if canBlockForAll { blockForAllSwipe(member, v) } else if canRemove { removeSwipe(member, v) } else { v } } else { if !member.blockedByAdmin { blockSwipe(member, v) } else { v } } } private func memberInfoView() -> some View { GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember, scrollToItemId: $scrollToItemId) .navigationBarHidden(false) } private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { if case .failed = member.activeConn?.connStatus { return "failed" } else if member.activeConn?.connDisabled ?? false { return "disabled" } else if member.activeConn?.connInactive ?? false { return "inactive" } else { return member.memberStatus.shortText } } @ViewBuilder private func memberInfo(_ member: GroupMember) -> some View { if member.blocked { Text("blocked") .foregroundColor(theme.colors.secondary) } else { let role = member.memberRole if [.owner, .admin, .moderator, .observer].contains(role) { Text(member.memberRole.text(isChannel: groupInfo.isChannel)) .foregroundColor(theme.colors.secondary) } } } private func blockSwipe(_ member: GroupMember, _ v: V) -> some View { v.swipeActions(edge: .leading) { if member.memberSettings.showMessages { Button { alert = .blockMemberAlert(mem: member) } label: { Label("Block member", systemImage: "hand.raised").foregroundColor(theme.colors.secondary) } } else { Button { alert = .unblockMemberAlert(mem: member) } label: { Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary) } } } } private func blockForAllSwipe(_ member: GroupMember, _ v: V) -> some View { v.swipeActions(edge: .leading) { if member.blockedByAdmin { Button { alert = .unblockForAllAlert(mem: member) } label: { Label("Unblock for all", systemImage: "hand.raised.slash").foregroundColor(theme.colors.primary) } } else { Button { alert = .blockForAllAlert(mem: member) } label: { Label("Block for all", systemImage: "hand.raised").foregroundColor(theme.colors.secondary) } } } } private func removeSwipe(_ member: GroupMember, _ v: V) -> some View { v.swipeActions(edge: .trailing) { Button(role: .destructive) { showRemoveMemberAlert(groupInfo, member) } label: { Label("Remove member", systemImage: "trash") .foregroundColor(Color.red) } } } private var memberVerifiedShield: Text { (Text(Image(systemName: "checkmark.shield")) + textSpace) .font(.caption) .baselineOffset(2) .kerning(-2) .foregroundColor(theme.colors.secondary) } } private func groupLinkButton() -> some View { NavigationLink { groupLinkDestinationView() } label: { if groupLink == nil { Label("Create group link", systemImage: "link.badge.plus") } else { Label("Group link", systemImage: "link") } } } private func channelLinkButton() -> some View { NavigationLink { groupLinkDestinationView() } label: { Label("Channel link", systemImage: "link") } } private func channelWebAccessButton() -> some View { let title: LocalizedStringKey = groupInfo.isChannel ? "Channel webpage" : "Group webpage" return NavigationLink { ChannelWebAccessView(groupInfo: $groupInfo) .navigationBarTitle(title) .navigationBarTitleDisplayMode(.large) } label: { Label(title, systemImage: "globe") } } private func channelSimplexNameButton() -> some View { NavigationLink { let domain = if let d = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName { "#\(d)" } else { "" } SetSimplexDomainView( title: "SimpleX name", footer: "Let people join via name registered with this channel link.", prompt: "#channelname.testing", simplexName: domain, save: { domain in do { var access = groupInfo.groupProfile.publicGroup?.publicGroupAccess ?? PublicGroupAccess() access.groupDomainClaim = domain.map { SimplexDomainClaim(domain: $0) } let gInfo = try await apiSetPublicGroupAccess(groupInfo.groupId, access: access) await MainActor.run { chatModel.updateGroup(gInfo) groupInfo = gInfo } return true } catch { return false } } ) } label: { Label("SimpleX name", systemImage: "number") } } private func groupLinkDestinationView() -> some View { GroupLinkView( groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole, showTitle: false, creatingGroup: false, isChannel: groupInfo.useRelays, groupInfo: groupInfo, composeState: $composeState ) .navigationBarTitle(groupInfo.useRelays ? "Channel link" : "Group link") .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } private func channelMembersButton() -> some View { let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners & contributors" return NavigationLink { ChannelMembersView(chat: chat, groupInfo: groupInfo) .navigationTitle(label) .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } label: { Label(label, systemImage: "person.2") } } private func channelRelaysButton() -> some View { NavigationLink { ChannelRelaysView(chat: chat, groupInfo: groupInfo) .navigationTitle("Chat relays") .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } label: { Label("Chat relays", systemImage: "externaldrive.connected.to.line.below") } } struct UserSupportChatNavLink: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme var groupInfo: GroupInfo @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) { SecondaryChatView( chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), scrollToItemId: $scrollToItemId ) } label: { HStack { Label("Chat with admins", systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag") Spacer() if chat.supportUnreadCount > 0 { UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary) } } } .onChange(of: navLinkActive) { active in if active { ItemsModel.loadSecondaryChat(groupInfo.id, chatFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) } } } } private func memberSupportButton() -> some View { NavigationLink { MemberSupportView(groupInfo: groupInfo, scrollToItemId: $scrollToItemId) .navigationBarTitle("Chats with members") .modifier(ThemedBackground()) .navigationBarTitleDisplayMode(.large) } label: { HStack { Label( "Chats with members", systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag" ) Spacer() if chat.supportUnreadCount > 0 { UnreadBadge(count: chat.supportUnreadCount, color: theme.colors.primary) } } } } struct GroupReportsChatNavLink: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme var groupInfo: GroupInfo @EnvironmentObject var chatModel: ChatModel @Binding var scrollToItemId: ChatItem.ID? @State private var navLinkActive = false var body: some View { NavigationLink(isActive: $navLinkActive) { SecondaryChatView( chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: .reports), chatItems: [], chatStats: ChatStats()), scrollToItemId: $scrollToItemId ) } label: { HStack { Label { Text("Member reports") } icon: { Image(systemName: chat.chatStats.reportsCount > 0 ? "flag.fill" : "flag").foregroundColor(.red) } Spacer() if chat.chatStats.reportsCount > 0 { UnreadBadge(count: chat.chatStats.reportsCount, color: .red) } } } .onChange(of: navLinkActive) { active in if active { ItemsModel.loadSecondaryChat(chat.id, chatFilter: .msgContentTagContext(contentTag: .report)) } } } } private func editGroupButton() -> some View { NavigationLink { GroupProfileView( groupInfo: $groupInfo, groupProfile: groupInfo.groupProfile ) } label: { Label(groupInfo.useRelays ? "Edit channel profile" : "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.useRelays ? "Delete channel" : 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.useRelays ? "Leave channel" : 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.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" return Alert( title: Text(label), message: Text(chat.chatInfo.displayName + "\n\n") + 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(chat.chatInfo.displayName + "\n\n") + 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.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" let messageLabel: LocalizedStringKey = ( groupInfo.useRelays ? "You will stop receiving messages from this channel. Chat history will be preserved." : 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(chat.chatInfo.displayName + "\n\n") + 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) { if mem.memberRole == .relay { let isLastActive = groupInfo.useRelays && mem.memberCurrent && { let activeRelays = ChatModel.shared.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberCurrent } return activeRelays.count <= 1 }() showAlert( NSLocalizedString("Remove relay?", comment: "alert title"), message: isLastActive ? NSLocalizedString("This is the last active relay. Removing it will prevent message delivery to subscribers.", comment: "alert message") : NSLocalizedString("Relay will be removed from channel - this cannot be undone!", comment: "alert message"), actions: {[ UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss) }, cancelAlertAction ]} ) } else if groupInfo.useRelays { showAlert( NSLocalizedString("Remove subscriber?", comment: "alert title"), message: NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message"), actions: {[ UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss) }, UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss) }, cancelAlertAction ]} ) } else { showAlert( NSLocalizedString("Remove member?", comment: "alert title"), message: groupInfo.businessChat == nil ? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message") : NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"), actions: {[ UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss) }, UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss) }, cancelAlertAction ]} ) } } func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) { Task { do { let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId], withMessages) await MainActor.run { ChatModel.shared.updateGroup(updatedGroupInfo) updatedMembers.forEach { updatedMember in _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember) if withMessages { ChatModel.shared.removeMemberItems(updatedMember, byMember: groupInfo.membership, groupInfo) } } dismiss?() } } catch let error { logger.error("apiRemoveMembers error: \(responseError(error))") await MainActor.run { showAlert( NSLocalizedString("Error removing member", comment: "alert title"), message: responseError(error) ) } } } } func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text { groupInfo.useRelays ? ( groupInfo.membership.memberCurrent ? Text("Channel will be deleted for all subscribers - this cannot be undone!") : Text("Channel will be deleted for you - this cannot be undone!") ) : groupInfo.businessChat == nil ? ( groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!") ) : ( groupInfo.membership.memberCurrent ? Text("Chat will be deleted for all members - this cannot be undone!") : Text("Chat will be deleted for you - this cannot be undone!") ) } struct GroupPreferencesButton: View { @Binding var groupInfo: GroupInfo @State var preferences: FullGroupPreferences @State var currentPreferences: FullGroupPreferences var creatingGroup: Bool = false private var label: LocalizedStringKey { groupInfo.useRelays ? "Channel preferences" : 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" : groupInfo.useRelays ? "Save and notify subscribers" : "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!"), message: Text("You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed") ) } 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.") ) } @ViewBuilder func shareChannelPicker(groupInfo: GroupInfo, composeState: Binding? = nil) -> some View { let v = ChatItemForwardingView( title: "Share channel", isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, onSelectChat: { chat in shareChatLink(chat, sourceGroupInfo: groupInfo, composeState: composeState) }, includeLocal: false ) if #available(iOS 16.0, *) { v.presentationDetents([.fraction(0.8)]) } else { v } } func shareChatLink(_ destChat: Chat, sourceGroupInfo: GroupInfo, composeState: Binding? = nil) { let sendAsGroup = if let gInfo = destChat.chatInfo.groupInfo { gInfo.useRelays && gInfo.membership.memberRole >= .owner } else { false } Task { do { let mc = try await apiShareChatMsgContent( shareChatType: .group, shareChatId: Int64(sourceGroupInfo.groupId), toChatType: destChat.chatInfo.chatType, toChatId: destChat.chatInfo.apiId, toScope: destChat.chatInfo.groupChatScope(), sendAsGroup: sendAsGroup ) if case let .chat(_, chatLink, ownerSig) = mc { await MainActor.run { dismissAllSheets { let cs = ComposeState(preview: .chatLinkPreview(chatLink: chatLink, ownerSig: ownerSig)) if let composeState { composeState.wrappedValue = cs } else { ChatModel.shared.draft = cs ChatModel.shared.draftChatId = destChat.id } if destChat.id != ChatModel.shared.chatId { ItemsModel.shared.loadOpenChat(destChat.id) } } } } else { logger.error("shareChatLink: unexpected MsgContent: \(String(describing: mc))") await MainActor.run { showAlert(NSLocalizedString("Error sharing channel", comment: "alert title"), message: String(describing: mc)) } } } catch { logger.error("shareChatLink error: \(error.localizedDescription)") await MainActor.run { showAlert(NSLocalizedString("Error sharing channel", comment: "alert title"), message: error.localizedDescription) } } } } struct GroupChatInfoView_Previews: PreviewProvider { static var previews: some View { GroupChatInfoView( chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: Binding.constant(GroupInfo.sampleData), scrollToItemId: Binding.constant(nil), composeState: Binding.constant(ComposeState()), onSearch: {}, localAlias: "" ) } }