From 7102723c23ed73c668f717f60e45cbf2354098d8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 26 Oct 2023 18:51:45 +0400 Subject: [PATCH] ios: create new group with incognito membership (#3284) * ios: create new group with incognito membership * layout * fix button * update layout * layout * layout --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- apps/ios/Shared/Views/Chat/ChatView.swift | 38 +++- .../Views/Chat/Group/GroupChatInfoView.swift | 12 +- .../Views/Chat/Group/GroupLinkView.swift | 40 +++- .../Shared/Views/NewChat/AddGroupView.swift | 171 +++++++++++------- .../Views/NewChat/PasteToConnectView.swift | 8 +- .../Views/NewChat/ScanToConnectView.swift | 6 +- apps/ios/SimpleXChat/APITypes.swift | 4 +- 8 files changed, 191 insertions(+), 92 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 680a7132d..de09853e1 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1017,9 +1017,9 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { throw r } -func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { +func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo { let userId = try currentUserId("apiNewGroup") - let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p)) + let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile)) if case let .groupCreated(_, groupInfo) = r { return groupInfo } throw r } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 21af0ebe1..5679b451a 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -37,6 +37,10 @@ struct ChatView: View { @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon @State private var selectedMember: GroupMember? = nil + // opening GroupLinkView on link button (incognito) + @State private var showGroupLinkSheet: Bool = false + @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { if #available(iOS 16.0, *) { @@ -173,9 +177,16 @@ struct ChatView: View { HStack { if groupInfo.canAddMembers { if (chat.chatInfo.incognito) { - Image(systemName: "person.crop.circle.badge.plus") - .foregroundColor(Color(uiColor: .tertiaryLabel)) - .onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) } + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } } else { addMembersButton() .appSheet(isPresented: $showAddMembersSheet) { @@ -417,7 +428,26 @@ struct ChatView: View { Image(systemName: "person.crop.circle.badge.plus") } } - + + private func groupLinkButton() -> some View { + Button { + if case let .group(gInfo) = chat.chatInfo { + Task { + do { + if let link = try apiGetGroupLink(gInfo.groupId) { + (groupLink, groupLinkMemberRole) = link + } + } catch let error { + logger.error("ChatView apiGetGroupLink: \(responseError(error))") + } + showGroupLinkSheet = true + } + } + } label: { + Image(systemName: "link.badge.plus") + } + } + private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) { if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id { if loadingItems || firstPage { return } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 3b9ef347e..dd2392b6d 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -225,9 +225,15 @@ struct GroupChatInfoView: View { private func groupLinkButton() -> some View { NavigationLink { - GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole) - .navigationBarTitle("Group link") - .navigationBarTitleDisplayMode(.large) + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: false, + creatingGroup: false + ) + .navigationBarTitle("Group link") + .navigationBarTitleDisplayMode(.large) } label: { if groupLink == nil { Label("Create group link", systemImage: "link.badge.plus") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 781870bf5..bf2179bea 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -13,6 +13,9 @@ struct GroupLinkView: View { var groupId: Int64 @Binding var groupLink: String? @Binding var groupLinkMemberRole: GroupMemberRole + var showTitle: Bool = false + var creatingGroup: Bool = false + var linkCreatedCb: (() -> Void)? = nil @State private var creatingLink = false @State private var alert: GroupLinkAlert? @@ -29,10 +32,35 @@ struct GroupLinkView: View { } var body: some View { + if creatingGroup { + NavigationView { + groupLinkView() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button ("Continue") { linkCreatedCb?() } + } + } + } + } else { + groupLinkView() + } + } + + private func groupLinkView() -> some View { List { - Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + Group { + if showTitle { + Text("Group link") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + } + Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + Section { if let groupLink = groupLink { Picker("Initial role", selection: $groupLinkMemberRole) { @@ -48,8 +76,10 @@ struct GroupLinkView: View { Label("Share link", systemImage: "square.and.arrow.up") } - Button(role: .destructive) { alert = .deleteLink } label: { - Label("Delete link", systemImage: "trash") + if !creatingGroup { + Button(role: .destructive) { alert = .deleteLink } label: { + Label("Delete link", systemImage: "trash") + } } } else { Button(action: createGroupLink) { diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 186a24e99..22bf1c409 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct AddGroupView: View { @EnvironmentObject var m: ChatModel @Environment(\.dismiss) var dismiss: DismissAction + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @State private var chat: Chat? @State private var groupInfo: GroupInfo? @State private var profile = GroupProfile(displayName: "", fullName: "") @@ -21,18 +22,35 @@ struct AddGroupView: View { @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil @State private var showInvalidNameAlert = false + @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { if let chat = chat, let groupInfo = groupInfo { - AddGroupMembersViewCommon( - chat: chat, - groupInfo: groupInfo, - creatingGroup: true, - showFooterCounter: false - ) { _ in - dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - m.chatId = groupInfo.id + if !groupInfo.membership.memberIncognito { + AddGroupMembersViewCommon( + chat: chat, + groupInfo: groupInfo, + creatingGroup: true, + showFooterCounter: false + ) { _ in + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + m.chatId = groupInfo.id + } + } + } else { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: true + ) { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + m.chatId = groupInfo.id + } } } } else { @@ -41,77 +59,62 @@ struct AddGroupView: View { } func createGroupView() -> some View { - VStack(alignment: .leading) { - Text("Create secret group") - .font(.largeTitle) - .padding(.vertical, 4) - Text("The group is fully decentralized – it is visible only to the members.") - .padding(.bottom, 4) + List { + Group { + Text("Create secret group") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 24) + .onTapGesture(perform: hideKeyboard) - HStack { - Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) - Spacer().frame(width: 8) - Text("Your chat profile will be sent to group members").font(.footnote) - } - .padding(.bottom) - - ZStack(alignment: .center) { - ZStack(alignment: .topTrailing) { - profileImageView(profile.image) - if profile.image != nil { - Button { - profile.image = nil - } label: { - Image(systemName: "multiply") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profile.image, color: Color(uiColor: .secondarySystemGroupedBackground)) + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: 128, maxHeight: 128) + if profile.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } } } + + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable } - - editImageButton { showChooseSource = true } + .frame(maxWidth: .infinity, alignment: .center) } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 4) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - ZStack(alignment: .topLeading) { - let name = profile.displayName.trimmingCharacters(in: .whitespaces) - if name != mkValidName(name) { - Button { - showInvalidNameAlert = true - } label: { - Image(systemName: "exclamationmark.circle").foregroundColor(.red) - } - } else { - Image(systemName: "exclamationmark.circle").foregroundColor(.clear) + Section { + groupNameTextField() + Button(action: createGroup) { + settingsRow("checkmark", color: .accentColor) { Text("Create group") } } - textField("Enter group name…", text: $profile.displayName) - .focused($focusDisplayName) - .submitLabel(.go) - .onSubmit { - if canCreateProfile() { createGroup() } - } + .disabled(!canCreateProfile()) + IncognitoToggle(incognitoEnabled: $incognitoDefault) + } footer: { + VStack(alignment: .leading, spacing: 4) { + sharedGroupProfileInfo(incognitoDefault) + Text("Fully decentralized – visible only to members.") + } + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture(perform: hideKeyboard) } - .padding(.bottom) - - Spacer() - - Button { - createGroup() - } label: { - Text("Create") - Image(systemName: "greaterthan") - } - .disabled(!canCreateProfile()) - .frame(maxWidth: .infinity, alignment: .trailing) } .onAppear() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { focusDisplayName = true } } - .padding() .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true @@ -141,20 +144,48 @@ struct AddGroupView: View { profile.image = nil } } - .contentShape(Rectangle()) - .onTapGesture { hideKeyboard() } + } + + func groupNameTextField() -> some View { + ZStack(alignment: .leading) { + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + if name != mkValidName(name) { + Button { + showInvalidNameAlert = true + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "pencil").foregroundColor(.secondary) + } + textField("Enter group name…", text: $profile.displayName) + .focused($focusDisplayName) + .submitLabel(.continue) + .onSubmit { + if canCreateProfile() { createGroup() } + } + } } func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { TextField(placeholder, text: text) - .padding(.leading, 32) + .padding(.leading, 36) + } + + func sharedGroupProfileInfo(_ incognito: Bool) -> Text { + let name = ChatModel.shared.currentUser?.displayName ?? "" + return Text( + incognito + ? "A new random profile will be shared." + : "Your profile **\(name)** will be shared." + ) } func createGroup() { hideKeyboard() do { profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) - let gInfo = try apiNewGroup(profile) + let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile) Task { let groupMembers = await apiListMembers(gInfo.groupId) await MainActor.run { diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index af84f19fe..7c272fb63 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -54,9 +54,11 @@ struct PasteToConnectView: View { IncognitoToggle(incognitoEnabled: $incognitoDefault) } footer: { - sharedProfileInfo(incognitoDefault) - + Text(String("\n\n")) - + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + VStack(alignment: .leading, spacing: 4) { + sharedProfileInfo(incognitoDefault) + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + } + .frame(maxWidth: .infinity, alignment: .leading) } } .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index c55ba1502..9a11eee92 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -38,11 +38,11 @@ struct ScanToConnectView: View { ) .padding(.top) - Group { + VStack(alignment: .leading, spacing: 4) { sharedProfileInfo(incognitoDefault) - + Text(String("\n\n")) - + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") } + .frame(maxWidth: .infinity, alignment: .leading) .font(.footnote) .foregroundColor(.secondary) .padding(.horizontal) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5c7220f37..e27067478 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -50,7 +50,7 @@ public enum ChatCommand { case apiVerifyToken(token: DeviceToken, nonce: String, code: String) case apiDeleteToken(token: DeviceToken) case apiGetNtfMessage(nonce: String, encNtfInfo: String) - case apiNewGroup(userId: Int64, groupProfile: GroupProfile) + case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole) @@ -175,7 +175,7 @@ public enum ChatCommand { case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)" - case let .apiNewGroup(userId, groupProfile): return "/_group \(userId) \(encodeJSON(groupProfile))" + case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)"