diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 1d86733206..b90df21ff6 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -16,6 +16,7 @@ struct ChatListView: View { @State private var showAddChat = false @State private var userPickerVisible = false @State private var showConnectDesktop = false + @State private var showCreateGroupSheet = false @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false var body: some View { @@ -62,11 +63,7 @@ struct ChatListView: View { private var chatListView: some View { VStack { - if chatModel.chats.count > 0 { - chatList.searchable(text: $searchText) - } else { - chatList - } + chatList.searchable(text: $searchText) } .onDisappear() { withAnimation { userPickerVisible = false } } .refreshable { @@ -88,6 +85,9 @@ struct ChatListView: View { .offset(x: -8) .listStyle(.plain) .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showCreateGroupSheet) { + AddGroupView() + } .toolbar { ToolbarItem(placement: .navigationBarLeading) { let user = chatModel.currentUser ?? User.sampleData @@ -124,7 +124,12 @@ struct ChatListView: View { } ToolbarItem(placement: .navigationBarTrailing) { switch chatModel.chatRunning { - case .some(true): NewChatButton(showAddChat: $showAddChat) + // case .some(true): NewChatButton(showAddChat: $showAddChat) + case .some(true): + HStack { + createGroupButton() + NewChatButton2() + } case .some(false): chatStoppedIcon() case .none: EmptyView() } @@ -132,6 +137,17 @@ struct ChatListView: View { } } + private func createGroupButton() -> some View { + Button { + showCreateGroupSheet = true + } label: { + Image(systemName: "person.2") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + } + } + private func toggleFilterButton() -> some View { Button { showUnreadAndFavorites = !showUnreadAndFavorites diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton2.swift b/apps/ios/Shared/Views/NewChat/NewChatButton2.swift new file mode 100644 index 0000000000..3fde4c8e3f --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/NewChatButton2.swift @@ -0,0 +1,53 @@ +// +// NewChatButton2.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.11.2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +enum NewChatSheet: Identifiable { + case newChat(link: String, connection: PendingContactConnection) + + var id: String { + switch self { + case let .newChat(link, _): return "newChat \(link)" + } + } +} + +struct NewChatButton2: View { + @State private var actionSheet: NewChatSheet? + + var body: some View { + Button { + addContactAction() + } label: { + Image(systemName: "square.and.pencil") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + .sheet(item: $actionSheet) { sheet in + switch sheet { + case let .newChat(link, pcc): + NewChatView(selection: .invite, connReqInvitation: link, contactConnection: pcc) + } + } + } + + func addContactAction() { + Task { + if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { + actionSheet = .newChat(link: connReq, connection: pcc) + } + } + } +} + +//#Preview { +// NewChatButton2() +//} diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift new file mode 100644 index 0000000000..4d5cd9d9dd --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -0,0 +1,431 @@ +// +// NewChatView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.11.2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat +import CodeScanner + +enum NewChatOption: Identifiable { + case invite + case connect + + var id: Self { self } +} + +struct NewChatView: View { + @EnvironmentObject var m: ChatModel + @State var selection: NewChatOption + @State var connReqInvitation: String + @State var contactConnection: PendingContactConnection? + @State private var creatingConnReq = false + + var body: some View { + NavigationView { + VStack(alignment: .leading) { + Text("Start a New Chat") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical) + + Picker("New chat", selection: $selection) { + Label("Invite", systemImage: "link") + .tag(NewChatOption.invite) + Label("Connect", systemImage: "qrcode") + .tag(NewChatOption.connect) + } + .pickerStyle(.segmented) + + switch selection { + case .invite: InviteView( + contactConnection: $contactConnection, + connReqInvitation: connReqInvitation + ) + case .connect: ConnectView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding() + .background(Color(.systemGroupedBackground)) + .onChange(of: selection) { sel in + if case .connect = sel, + connReqInvitation == "" && contactConnection == nil && !creatingConnReq { + createInvitation() + } + } + .onAppear { m.connReqInv = connReqInvitation } + .onDisappear { m.connReqInv = nil } + } + } + + private func createInvitation() { + creatingConnReq = true + Task { + if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { + await MainActor.run { + connReqInvitation = connReq + contactConnection = pcc + m.connReqInv = connReq + } + } else { + await MainActor.run { + creatingConnReq = false + } + } + } + } +} + +struct InviteView: View { + @EnvironmentObject private var chatModel: ChatModel + @Binding var contactConnection: PendingContactConnection? + var connReqInvitation: String + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false + + var body: some View { + GeometryReader { geo in + ScrollView { + VStack(alignment: .leading, spacing: 8) { + if connReqInvitation != "" { + HStack { + Text("Share this unique invite link") + .textCase(.uppercase) + .font(.footnote) + .foregroundColor(.secondary) + Spacer() + Button { + copyLink() + } label: { + Text("Copy") + .font(.footnote) + } + } + .padding(.horizontal) + + Text(simplexChatLink(connReqInvitation)) + .lineLimit(2) + .font(.callout) + .padding(.horizontal) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .systemBackground)) + ) + + Text("Or show this code") + .textCase(.uppercase) + .font(.footnote) + .foregroundColor(.secondary) + .padding(.horizontal) + .padding(.top, 8) + + VStack(alignment: .center) { + SimpleXLinkQRCode(uri: connReqInvitation) + .padding(.horizontal) + .padding(.vertical, 10) + .frame(width: geo.size.width * 0.8) + } + .frame(maxWidth: .infinity, alignment: .center) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .systemBackground)) + ) + + VStack(alignment: .leading) { + IncognitoToggle(incognitoEnabled: $incognitoDefault) + Divider() + shareLinkButton2(connReqInvitation) + } + .padding(.horizontal) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .systemBackground)) + ) + .padding(.top) + + VStack(alignment: .center) { + oneTimeLinkLearnMoreButton2() + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top) + } else { + Text("Creating link…") + .textCase(.uppercase) + .font(.footnote) + .foregroundColor(.secondary) + .padding(.horizontal) + + VStack(alignment: .center) { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(2) + .frame(maxWidth: .infinity) + .padding(.horizontal) + .padding(.vertical, 8) + } + .frame(maxWidth: .infinity, alignment: .center) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .systemBackground)) + ) + } + } + } + .padding(.vertical) + .onAppear { chatModel.connReqInv = connReqInvitation } + .onChange(of: incognitoDefault) { incognito in + Task { + do { + if let contactConn = contactConnection, + let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) { + await MainActor.run { + contactConnection = conn + chatModel.updateContactConnection(conn) + } + } + } catch { + logger.error("apiSetConnectionIncognito error: \(responseError(error))") + } + } + } + } + } + + private func copyLink() { + UIPasteboard.general.string = simplexChatLink(connReqInvitation) + } +} + +struct ConnectView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @State private var connectionLink: String = "" + @State private var alert: PlanAndConnectAlert? + @State private var sheet: PlanAndConnectActionSheet? + @State private var showScanQRCodeSheet = false + @State private var scannedLink: String = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ( + Text("Paste the URL your received from your contact.") + + Text("\n\n") + + Text("You'll be connected to begin a private conversation with them.") + ) + .font(.footnote) + .foregroundColor(.secondary) + .padding(.horizontal) + + HStack { + Text("Unique invite link") + .textCase(.uppercase) + .font(.footnote) + .foregroundColor(.secondary) + Spacer() + if (connectionLink != "") { + Button { + clearLink() + } label: { + Text("Clear") + .font(.footnote) + } + } + } + .padding(.horizontal) + .padding(.top) + + pasteLinkView() + + Text("Or scan QR code") + .textCase(.uppercase) + .font(.footnote) + .foregroundColor(.secondary) + .padding(.horizontal) + .padding(.top, 8) + + VStack(alignment: .center) { + scanQRCodeButton() + } + .padding(.horizontal) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .center) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .systemBackground)) + ) + + VStack(alignment: .center) { + oneTimeLinkLearnMoreButton2() + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top) + } + } + .padding(.vertical) + .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } + .sheet(isPresented: $showScanQRCodeSheet) { + if #available(iOS 16.0, *) { + ScanConnectionCodeView(scannedLink: $scannedLink) + .presentationDetents([.fraction(0.8)]) + } else { + ScanConnectionCodeView(scannedLink: $scannedLink) + } + } + .onChange(of: scannedLink) { link in + connect(link) + } + } + + private func clearLink() { + connectionLink = "" + } + + @ViewBuilder private func pasteLinkView() -> some View { + if connectionLink == "" { + VStack(alignment: .center) { + ZStack { + Text("\n") + .font(.callout) + .padding(.horizontal) + .padding(.vertical, 8) + .opacity(0) + Button { + if let link = UIPasteboard.general.string { + // TODO test pasted text is a link, alert if not + connectionLink = link.trimmingCharacters(in: .whitespaces) + connect(connectionLink) + } + } label: { + Text("Click Here to Paste Link") + .foregroundColor(.accentColor) + .padding(.horizontal) + .padding(.vertical, 8) + } + } + } + .frame(maxWidth: .infinity, alignment: .center) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .systemBackground)) + ) + } else { + VStack() { + ZStack { + Text("\n") + .font(.callout) + .padding(.horizontal) + .padding(.vertical, 8) + .opacity(0) + Text(connectionLink) + .lineLimit(2) + .font(.callout) + .padding(.horizontal) + .padding(.vertical, 8) + } + } + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .systemBackground)) + ) + } + } + + private func connect(_ link: String) { + planAndConnect( + link, + showAlert: { alert = $0 }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: nil + ) + } + + private func scanQRCodeButton() -> some View { + Button { + showScanQRCodeSheet = true + } label: { + settingsRow("qrcode") { + Text("Scan code") + } + } + } +} + +struct ScanConnectionCodeView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @Binding var scannedLink: String + + var body: some View { + VStack(alignment: .leading) { + Text("Scan QR code") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical) + + CodeScannerView(codeTypes: [.qr], completion: processQRCode) + .aspectRatio(1, contentMode: .fit) + .cornerRadius(12) + .padding(.top) + + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") + .padding(.top) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding() + } + + private func processQRCode(_ resp: Result) { + switch resp { + case let .success(r): + scannedLink = r.string + dismiss() + case let .failure(e): + logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") + // TODO alert + dismiss() + } + } +} + +// TODO move IncognitoToggle here + +// TODO move shareLinkButton here +func shareLinkButton2(_ connReqInvitation: String) -> some View { + Button { + showShareSheet(items: [simplexChatLink(connReqInvitation)]) + } label: { + settingsRow("square.and.arrow.up") { + Text("Share link") + } + } +} + +// TODO move oneTimeLinkLearnMoreButton here +func oneTimeLinkLearnMoreButton2() -> some View { + NavigationLink { + AddContactLearnMore() + .navigationTitle("One-time invitation link") + .navigationBarTitleDisplayMode(.large) + } label: { + settingsRow("info.circle") { + Text("Need Guidance?") + .underline() + } + } +} + +// TODO move planAndConnect here + +//#Preview { +// NewChatView() +//} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e799041d04..558adae4f8 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -178,6 +178,8 @@ 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; + 64AEA4ED2B15D2A400334292 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AEA4EC2B15D2A400334292 /* NewChatView.swift */; }; + 64AEA4EF2B15FEE100334292 /* NewChatButton2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AEA4EE2B15FEE100334292 /* NewChatButton2.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; @@ -462,6 +464,8 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; + 64AEA4EC2B15D2A400334292 /* NewChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; + 64AEA4EE2B15FEE100334292 /* NewChatButton2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton2.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; @@ -736,6 +740,8 @@ 5CB2085028DB64CA00D024EC /* CreateLinkView.swift */, 5CB2085228DB7CAF00D024EC /* ConnectViaLinkView.swift */, 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */, + 64AEA4EC2B15D2A400334292 /* NewChatView.swift */, + 64AEA4EE2B15FEE100334292 /* NewChatButton2.swift */, ); path = NewChat; sourceTree = ""; @@ -1115,12 +1121,14 @@ 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */, 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, + 64AEA4EF2B15FEE100334292 /* NewChatButton2.swift in Sources */, 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */, 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */, 5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */, 6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, + 64AEA4ED2B15D2A400334292 /* NewChatView.swift in Sources */, 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */, 5CC036E029C488D500C0EF20 /* HiddenProfileView.swift in Sources */,