mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-14 04:59:56 +00:00
Correctness. The one-time link picker's profile list was keyed on the active user as well as the count. That defeats the "don't change order after a user was selected" behaviour its own comment protects - changeActiveUser_ sets currentUser first and only then reloads chats, so the list visibly re-sorted and stayed re-sorted for the whole of getUserChatData - and it was redundant: the count alone covers a profile being created from the picker. iOS, compose picker: the old-core fallback relied on the user switch tearing this view down, which only happens when the switch succeeded. If it threw, the form was left open over a profile that already existed, and the only way out was to swipe it away. It is now dismissed unconditionally. iOS: alerts raised right after a sheet is dismissed are presented on a controller that is going away and are dropped. The 0.5s wait was applied only to the old-core path, while the ordinary reassignment failures - the ones that actually happen - went unguarded. alertAfterDismissal now covers all of them, in one place. iOS: dropped the chatId write that Kotlin removed for the same reason two commits ago. It is a no-op on the happy path, but not if something legitimately closed the chat meanwhile - SimpleX lock re-auth clears it, and this would reopen the chat behind the lock screen. iOS: the create-profile row is now gated on profileChangeProhibited, as every other row on both platforms already was. Surface. apiCreateActiveUser takes keepActiveUser as a defaulted argument, exactly as it already takes pastTimestamp, instead of a public wrapper plus a private helper. Two symbols fewer on each platform, and every existing call site passes its arguments by name, so none of them changes. Reverted the activeOrder sort flip in the compose picker: it changes the order of an existing screen for every user, is not needed for this feature, and with active_order 0 would push a newly created profile to the bottom. It deserves its own commit if wanted. Also: apps/ios/spec/api.md tracks the command enum and had gone stale; the plan records the two limits of the flag the review surfaced - it is ignored when there is no active user, and active_order 0 ties rather than sorts last on migrated databases.
1911 lines
80 KiB
Swift
1911 lines
80 KiB
Swift
//
|
|
// NewChatView.swift
|
|
// SimpleX (iOS)
|
|
//
|
|
// Created by spaced4ndy on 28.11.2023.
|
|
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
|
//
|
|
// Spec: spec/client/navigation.md
|
|
|
|
import SwiftUI
|
|
import SimpleXChat
|
|
import CodeScanner
|
|
import AVFoundation
|
|
import SimpleXChat
|
|
|
|
struct SomeAlert: Identifiable {
|
|
var alert: Alert
|
|
var id: String
|
|
}
|
|
|
|
struct SomeActionSheet: Identifiable {
|
|
var actionSheet: ActionSheet
|
|
var id: String
|
|
}
|
|
|
|
struct SomeSheet<Content: View>: Identifiable {
|
|
@ViewBuilder var content: Content
|
|
var id: String
|
|
var fraction = 0.4
|
|
}
|
|
|
|
private enum NewChatViewAlert: Identifiable {
|
|
case newChatSomeAlert(alert: SomeAlert)
|
|
var id: String {
|
|
switch self {
|
|
case let .newChatSomeAlert(alert): return "newChatSomeAlert \(alert.id)"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum NewChatOption: Identifiable {
|
|
case invite
|
|
case connect
|
|
|
|
var id: Self { self }
|
|
}
|
|
|
|
func showKeepInvitationAlert() {
|
|
if let showingInvitation = ChatModel.shared.showingInvitation,
|
|
!showingInvitation.connChatUsed {
|
|
showAlert(
|
|
NSLocalizedString("Keep unused invitation?", comment: "alert title"),
|
|
message: NSLocalizedString("You can view invitation link again in connection details.", comment: "alert message"),
|
|
actions: {[
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Keep", comment: "alert action"),
|
|
style: .default
|
|
),
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Delete", comment: "alert action"),
|
|
style: .destructive,
|
|
handler: { _ in
|
|
Task {
|
|
await deleteChat(Chat(
|
|
chatInfo: .contactConnection(contactConnection: showingInvitation.pcc),
|
|
chatItems: []
|
|
))
|
|
}
|
|
}
|
|
)
|
|
]}
|
|
)
|
|
}
|
|
ChatModel.shared.showingInvitation = nil
|
|
}
|
|
|
|
// Spec: spec/client/navigation.md#NewChatView
|
|
struct NewChatView: View {
|
|
@EnvironmentObject var m: ChatModel
|
|
@EnvironmentObject var theme: AppTheme
|
|
@State var selection: NewChatOption
|
|
@State var showQRCodeScanner = false
|
|
var onboarding: Bool = false
|
|
@State private var invitationUsed: Bool = false
|
|
@State private var connLinkInvitation: CreatedConnLink = CreatedConnLink(connFullLink: "", connShortLink: nil)
|
|
@State private var showShortLink = true
|
|
@State private var creatingConnReq = false
|
|
@State var choosingProfile = false
|
|
@State private var pastedLink: String = ""
|
|
@State private var alert: NewChatViewAlert?
|
|
@State private var contactConnection: PendingContactConnection? = nil
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading) {
|
|
if !onboarding {
|
|
Picker("New chat", selection: $selection) {
|
|
Label("1-time link", systemImage: "link")
|
|
.tag(NewChatOption.invite)
|
|
Label("Connect via link", systemImage: "qrcode")
|
|
.tag(NewChatOption.connect)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding()
|
|
.onChange(of: $selection.wrappedValue) { opt in
|
|
if opt == NewChatOption.connect {
|
|
showQRCodeScanner = true
|
|
}
|
|
}
|
|
}
|
|
|
|
VStack {
|
|
// it seems there's a bug in iOS 15 if several views in switch (or if-else) statement have different transitions
|
|
// https://developer.apple.com/forums/thread/714977?answerId=731615022#731615022
|
|
if case .invite = selection {
|
|
prepareAndInviteView()
|
|
.transition(.move(edge: .leading))
|
|
.onAppear {
|
|
createInvitation()
|
|
}
|
|
}
|
|
if case .connect = selection {
|
|
ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert, onboarding: onboarding)
|
|
.transition(.move(edge: .trailing))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.modifier(ThemedBackground(grouped: true))
|
|
.background(
|
|
// Rectangle is needed for swipe gesture to work on mostly empty views (creatingLinkProgressView and retryButton)
|
|
Rectangle()
|
|
.fill(theme.base == DefaultTheme.LIGHT ? theme.colors.background.asGroupedBackground(theme.base.mode) : theme.colors.background)
|
|
)
|
|
.animation(.easeInOut(duration: 0.3333), value: selection)
|
|
.gesture(DragGesture(minimumDistance: 20.0, coordinateSpace: .local)
|
|
.onChanged { value in
|
|
switch(value.translation.width, value.translation.height) {
|
|
case (...0, -30...30): // left swipe
|
|
if selection == .invite {
|
|
selection = .connect
|
|
}
|
|
case (0..., -30...30): // right swipe
|
|
if selection == .connect {
|
|
selection = .invite
|
|
}
|
|
default: ()
|
|
}
|
|
},
|
|
including: onboarding ? .subviews : .all
|
|
)
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
if !onboarding {
|
|
InfoSheetButton {
|
|
AddContactLearnMore(showTitle: true)
|
|
}
|
|
} else {
|
|
Image(systemName: "info.circle").opacity(0)
|
|
}
|
|
}
|
|
}
|
|
.if(onboarding) { $0.navigationBarTitleDisplayMode(.inline) }
|
|
.modifier(ThemedBackground(grouped: true))
|
|
.onChange(of: invitationUsed) { used in
|
|
if used && !(m.showingInvitation?.connChatUsed ?? true) {
|
|
m.markShowingInvitationUsed()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
if !choosingProfile {
|
|
showKeepInvitationAlert()
|
|
contactConnection = nil
|
|
}
|
|
}
|
|
.alert(item: $alert) { a in
|
|
switch(a) {
|
|
case let .newChatSomeAlert(a):
|
|
return a.alert
|
|
}
|
|
}
|
|
}
|
|
|
|
private func prepareAndInviteView() -> some View {
|
|
ZStack { // ZStack is needed for views to not make transitions between each other
|
|
if connLinkInvitation.connFullLink != "" {
|
|
InviteView(
|
|
invitationUsed: $invitationUsed,
|
|
contactConnection: $contactConnection,
|
|
connLinkInvitation: $connLinkInvitation,
|
|
showShortLink: $showShortLink,
|
|
choosingProfile: $choosingProfile,
|
|
onboarding: onboarding
|
|
)
|
|
} else if creatingConnReq {
|
|
creatingLinkProgressView()
|
|
} else {
|
|
retryButton()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func createInvitation() {
|
|
if connLinkInvitation.connFullLink == "" && contactConnection == nil && !creatingConnReq {
|
|
creatingConnReq = true
|
|
Task {
|
|
_ = try? await Task.sleep(nanoseconds: 250_000000)
|
|
if let (connLink, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) {
|
|
await MainActor.run {
|
|
m.updateContactConnection(pcc)
|
|
m.showingInvitation = ShowingInvitation(pcc: pcc, connChatUsed: false)
|
|
connLinkInvitation = connLink
|
|
contactConnection = pcc
|
|
}
|
|
} else {
|
|
await MainActor.run {
|
|
creatingConnReq = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rectangle here and in retryButton are needed for gesture to work
|
|
private func creatingLinkProgressView() -> some View {
|
|
ProgressView("Creating link…")
|
|
.progressViewStyle(.circular)
|
|
}
|
|
|
|
private func retryButton() -> some View {
|
|
Button(action: createInvitation) {
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "arrow.counterclockwise")
|
|
Text("Retry")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func incognitoProfileImage() -> some View {
|
|
Image(systemName: "theatermasks.fill")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 30)
|
|
.foregroundColor(.indigo)
|
|
}
|
|
|
|
private struct InviteView: View {
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@EnvironmentObject var chatModel: ChatModel
|
|
@EnvironmentObject var theme: AppTheme
|
|
@Binding var invitationUsed: Bool
|
|
@Binding var contactConnection: PendingContactConnection?
|
|
@Binding var connLinkInvitation: CreatedConnLink
|
|
@Binding var showShortLink: Bool
|
|
@Binding var choosingProfile: Bool
|
|
var onboarding: Bool = false
|
|
|
|
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
|
|
|
var body: some View {
|
|
List {
|
|
Section(header: sectionHeader) {
|
|
shareLinkView()
|
|
}
|
|
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
|
|
|
|
qrCodeView()
|
|
if !onboarding, let selectedProfile = chatModel.currentUser {
|
|
Section {
|
|
NavigationLink {
|
|
ActiveProfilePicker(
|
|
contactConnection: $contactConnection,
|
|
connLinkInvitation: $connLinkInvitation,
|
|
incognitoEnabled: $incognitoDefault,
|
|
choosingProfile: $choosingProfile,
|
|
selectedProfile: selectedProfile
|
|
)
|
|
} label: {
|
|
HStack {
|
|
if incognitoDefault {
|
|
incognitoProfileImage()
|
|
Text("Incognito")
|
|
} else {
|
|
ProfileImage(imageStr: chatModel.currentUser?.image, size: 30)
|
|
Text(chatModel.currentUser?.chatViewName ?? "")
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Share profile").foregroundColor(theme.colors.secondary)
|
|
} footer: {
|
|
if incognitoDefault {
|
|
Text("A new random profile will be shared.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: incognitoDefault) { incognito in
|
|
setInvitationUsed()
|
|
}
|
|
.onChange(of: chatModel.currentUser) { u in
|
|
setInvitationUsed()
|
|
}
|
|
}
|
|
|
|
private var sectionHeader: some View {
|
|
#if SIMPLEX_ASSETS
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Image(colorScheme == .light
|
|
? (onboarding ? "one-time-link" : "one-time-link-small")
|
|
: (onboarding ? "one-time-link-light" : "one-time-link-small-light"))
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxWidth: .infinity)
|
|
sectionHeaderText
|
|
}
|
|
.padding(.bottom, 6)
|
|
#else
|
|
sectionHeaderText
|
|
.if(onboarding) { $0.padding(.bottom, 6) }
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder private var sectionHeaderText: some View {
|
|
if onboarding {
|
|
Text("Send the link via any messenger - it's secure. Ask to paste into SimpleX.")
|
|
.font(.body).foregroundColor(theme.colors.onBackground).textCase(nil)
|
|
} else {
|
|
Text("Share this 1-time invite link").foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
|
|
private func shareLinkView() -> some View {
|
|
HStack(spacing: 8) {
|
|
let link = connLinkInvitation.simplexChatUri(short: showShortLink)
|
|
linkTextView(link)
|
|
Button {
|
|
showShareSheet(items: [link])
|
|
setInvitationUsed()
|
|
} label: {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.padding(.top, -7)
|
|
.padding(.horizontal, 8)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private func qrCodeView() -> some View {
|
|
Section {
|
|
SimpleXCreatedLinkQRCode(link: connLinkInvitation, short: $showShortLink, onShare: setInvitationUsed)
|
|
.id("simplex-qrcode-view-for-\(connLinkInvitation.simplexChatUri(short: showShortLink))")
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(Color(uiColor: .secondarySystemGroupedBackground))
|
|
)
|
|
.padding(.horizontal)
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
|
} header: {
|
|
if onboarding {
|
|
Text("Or show QR in person or via video call.").font(.body).foregroundColor(theme.colors.onBackground).textCase(nil)
|
|
} else {
|
|
ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setInvitationUsed() {
|
|
if !invitationUsed {
|
|
invitationUsed = true
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum ProfileSwitchStatus {
|
|
case switchingUser
|
|
case switchingIncognito
|
|
case idle
|
|
}
|
|
|
|
private struct ActiveProfilePicker: View {
|
|
@Environment(\.dismiss) var dismiss
|
|
@EnvironmentObject var chatModel: ChatModel
|
|
@EnvironmentObject var theme: AppTheme
|
|
@Binding var contactConnection: PendingContactConnection?
|
|
@Binding var connLinkInvitation: CreatedConnLink
|
|
@Binding var incognitoEnabled: Bool
|
|
@Binding var choosingProfile: Bool
|
|
@State private var alert: SomeAlert?
|
|
@State private var profileSwitchStatus: ProfileSwitchStatus = .idle
|
|
@State private var switchingProfileByTimeout = false
|
|
@State private var lastSwitchingProfileByTimeoutCall: Double?
|
|
@State private var profiles: [User] = []
|
|
@State private var searchTextOrPassword = ""
|
|
@State private var showIncognitoSheet = false
|
|
@State private var incognitoFirst: Bool = false
|
|
@State private var showAddProfile = false
|
|
@State private var creatingProfile = false
|
|
@State var selectedProfile: User
|
|
var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)}
|
|
|
|
var body: some View {
|
|
viewBody()
|
|
.navigationTitle("Select chat profile")
|
|
.searchable(text: $searchTextOrPassword, placement: .navigationBarDrawer(displayMode: .always))
|
|
.autocorrectionDisabled(true)
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.onAppear {
|
|
profiles = chatModel.users
|
|
.map { $0.user }
|
|
}
|
|
.onChange(of: incognitoEnabled) { incognito in
|
|
if profileSwitchStatus != .switchingIncognito {
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
if let contactConn = contactConnection,
|
|
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
|
|
await MainActor.run {
|
|
contactConnection = conn
|
|
chatModel.updateContactConnection(conn)
|
|
profileSwitchStatus = .idle
|
|
dismiss()
|
|
}
|
|
} else {
|
|
// Nothing changed, so just release the status, which otherwise
|
|
// latches the picker behind a spinner with hit testing off.
|
|
// incognitoEnabled is the app-wide default: writing it here
|
|
// would mark the invitation as used.
|
|
await MainActor.run { profileSwitchStatus = .idle }
|
|
}
|
|
} catch {
|
|
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
|
|
await MainActor.run {
|
|
profileSwitchStatus = .idle
|
|
incognitoEnabled = !incognito
|
|
showErrorAlert(error, NSLocalizedString("Error changing to incognito!", comment: ""))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: profileSwitchStatus) { sp in
|
|
if sp != .idle {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
switchingProfileByTimeout = profileSwitchStatus != .idle
|
|
}
|
|
} else {
|
|
switchingProfileByTimeout = false
|
|
}
|
|
}
|
|
.onChange(of: selectedProfile) { profile in
|
|
if (profileSwitchStatus != .switchingUser) {
|
|
return
|
|
}
|
|
Task {
|
|
do {
|
|
if let contactConn = contactConnection,
|
|
let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) {
|
|
await MainActor.run {
|
|
contactConnection = conn
|
|
connLinkInvitation = conn.connLinkInv ?? CreatedConnLink(connFullLink: "", connShortLink: nil)
|
|
incognitoEnabled = false
|
|
chatModel.updateContactConnection(conn)
|
|
}
|
|
do {
|
|
try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil)
|
|
await MainActor.run {
|
|
profileSwitchStatus = .idle
|
|
dismiss()
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
profileSwitchStatus = .idle
|
|
alert = SomeAlert(
|
|
alert: Alert(
|
|
title: Text("Error switching profile"),
|
|
message: Text("Your connection was moved to \(profile.chatViewName) but an error happened when switching profile.")
|
|
),
|
|
id: "switchingProfileError"
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
// Nothing moved, so don't switch or dismiss. Reset the status,
|
|
// and the selection as the catch below does - otherwise the
|
|
// profile keeps the checkmark and tapping it does nothing.
|
|
await MainActor.run {
|
|
profileSwitchStatus = .idle
|
|
if let currentUser = chatModel.currentUser {
|
|
selectedProfile = currentUser
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
profileSwitchStatus = .idle
|
|
if let currentUser = chatModel.currentUser {
|
|
selectedProfile = currentUser
|
|
}
|
|
showErrorAlert(error, NSLocalizedString("Error changing connection profile", comment: ""))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.alert(item: $alert) { a in
|
|
a.alert
|
|
}
|
|
.onAppear {
|
|
incognitoFirst = incognitoEnabled
|
|
choosingProfile = true
|
|
}
|
|
.onDisappear {
|
|
choosingProfile = false
|
|
}
|
|
.sheet(isPresented: $showIncognitoSheet) {
|
|
IncognitoHelp()
|
|
}
|
|
}
|
|
|
|
|
|
// switchingProfileByTimeout latches half a second late, to avoid spinner flicker, but
|
|
// input must be blocked from the start or a second tap starts a competing switch.
|
|
private var busy: Bool { creatingProfile || switchingProfileByTimeout || profileSwitchStatus != .idle }
|
|
|
|
@ViewBuilder private func viewBody() -> some View {
|
|
profilePicker()
|
|
.allowsHitTesting(!busy)
|
|
.modifier(ThemedBackground(grouped: true))
|
|
.overlay {
|
|
if switchingProfileByTimeout {
|
|
ProgressView()
|
|
.scaleEffect(2)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func filteredProfiles() -> [User] {
|
|
let s = trimmedSearchTextOrPassword
|
|
let lower = s.localizedLowercase
|
|
|
|
return profiles.filter { u in
|
|
if (u.activeUser || !u.hidden) && (s == "" || u.chatViewName.localizedLowercase.contains(lower)) {
|
|
return true
|
|
}
|
|
return correctPassword(u, s)
|
|
}
|
|
}
|
|
|
|
private func profilerPickerUserOption(_ user: User) -> some View {
|
|
Button {
|
|
if selectedProfile == user && incognitoEnabled {
|
|
incognitoEnabled = false
|
|
profileSwitchStatus = .switchingIncognito
|
|
} else if selectedProfile != user {
|
|
selectedProfile = user
|
|
profileSwitchStatus = .switchingUser
|
|
}
|
|
} label: {
|
|
HStack {
|
|
ProfileImage(imageStr: user.image, size: 30)
|
|
.padding(.trailing, 2)
|
|
NameWithBadge(
|
|
Text(user.chatViewName).foregroundColor(theme.colors.onBackground),
|
|
user.profile.localBadge
|
|
)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
if selectedProfile == user, !incognitoEnabled {
|
|
Image(systemName: "checkmark")
|
|
.resizable().scaledToFit().frame(width: 16)
|
|
.foregroundColor(theme.colors.primary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var addProfileOption: some View {
|
|
Button {
|
|
showAddProfile = true
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "person.crop.circle.badge.plus")
|
|
.resizable().scaledToFit().frame(width: 38, height: 38)
|
|
.foregroundColor(theme.colors.primary)
|
|
Text("Add profile")
|
|
.foregroundColor(theme.colors.primary)
|
|
Spacer()
|
|
}
|
|
}
|
|
.disabled(busy)
|
|
}
|
|
|
|
// Creates a profile for this invitation without activating it, then routes through
|
|
// the same selectedProfile path as picking an existing profile, so the connection
|
|
// change and the switch cannot drift apart.
|
|
private func createProfileForConnection(_ displayName: String, _ shortDescr: String?, _ image: String?) async throws {
|
|
// Atomic check-and-set on the main actor: a plain check-then-set leaves a window
|
|
// where two submits both pass, and @State must not be read off the main actor.
|
|
let alreadyCreating = await MainActor.run { () -> Bool in
|
|
if creatingProfile { return true }
|
|
creatingProfile = true
|
|
return false
|
|
}
|
|
if alreadyCreating { return }
|
|
defer { Task { @MainActor in creatingProfile = false } }
|
|
let profile = Profile(displayName: displayName, fullName: "", shortDescr: shortDescr, image: image)
|
|
let newUser = try apiCreateActiveUser(profile, keepActiveUser: true)
|
|
let updatedUsers = try? listUsers()
|
|
await MainActor.run {
|
|
if let updatedUsers = updatedUsers { chatModel.users = updatedUsers }
|
|
profiles = chatModel.users.map { $0.user }
|
|
}
|
|
if newUser.activeUser {
|
|
// An older core ignored keepActiveUser and switched instead, so the connection
|
|
// change would fail. Not rethrown, or the form reports it as a creation failure.
|
|
do {
|
|
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
|
|
} catch {
|
|
logger.error("changeActiveUserAsync_ error: \(responseError(error))")
|
|
}
|
|
// Dismiss the form, as the compose picker does on the same failure: the app is
|
|
// now showing a different profile and the connection stayed with the previous
|
|
// one, so there is nothing left to do here and a second attempt would fail the
|
|
// same way.
|
|
await MainActor.run {
|
|
showAddProfile = false
|
|
// Make the picker agree with the profile that is now active rather than
|
|
// leaving the checkmark on one that is not.
|
|
profileSwitchStatus = .idle
|
|
selectedProfile = newUser
|
|
}
|
|
alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title"))
|
|
return
|
|
}
|
|
await MainActor.run {
|
|
showAddProfile = false
|
|
selectedProfile = newUser
|
|
profileSwitchStatus = .switchingUser
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func profilePicker() -> some View {
|
|
let incognitoOption = Button {
|
|
if !incognitoEnabled {
|
|
incognitoEnabled = true
|
|
profileSwitchStatus = .switchingIncognito
|
|
}
|
|
} label : {
|
|
HStack {
|
|
incognitoProfileImage()
|
|
Text("Incognito")
|
|
.foregroundColor(theme.colors.onBackground)
|
|
Image(systemName: "info.circle")
|
|
.foregroundColor(theme.colors.primary)
|
|
.font(.system(size: 14))
|
|
.onTapGesture {
|
|
showIncognitoSheet = true
|
|
}
|
|
Spacer()
|
|
if incognitoEnabled {
|
|
Image(systemName: "checkmark")
|
|
.resizable().scaledToFit().frame(width: 16)
|
|
.foregroundColor(theme.colors.primary)
|
|
}
|
|
}
|
|
}
|
|
|
|
List {
|
|
let filteredProfiles = filteredProfiles()
|
|
let activeProfile = filteredProfiles.first { u in u.activeUser }
|
|
|
|
if let selectedProfile = activeProfile {
|
|
let otherProfiles = filteredProfiles
|
|
.filter { u in u.userId != activeProfile?.userId }
|
|
.sorted(using: KeyPathComparator<User>(\.activeOrder, order: .reverse))
|
|
|
|
if incognitoFirst {
|
|
incognitoOption
|
|
profilerPickerUserOption(selectedProfile)
|
|
} else {
|
|
profilerPickerUserOption(selectedProfile)
|
|
incognitoOption
|
|
}
|
|
|
|
ForEach(otherProfiles) { p in
|
|
profilerPickerUserOption(p)
|
|
}
|
|
} else {
|
|
incognitoOption
|
|
ForEach(filteredProfiles) { p in
|
|
profilerPickerUserOption(p)
|
|
}
|
|
}
|
|
|
|
// Outside the branch above: inside it the row would disappear whenever the
|
|
// active profile is filtered out by the search text. Only offered when there
|
|
// is a connection to move to the new profile.
|
|
if contactConnection != nil {
|
|
addProfileOption
|
|
}
|
|
}
|
|
.opacity(switchingProfileByTimeout ? 0.4 : 1)
|
|
// Attached to the picker, not to the row: the row lives in a lazy container that
|
|
// may dispose it, taking the presented sheet with it. Unlike the compose picker
|
|
// this view is never replaced, so the picker is a stable enough owner.
|
|
.sheet(isPresented: $showAddProfile) {
|
|
NavigationView {
|
|
CreateProfile(onSubmit: { displayName, shortDescr, image in
|
|
try await createProfileForConnection(displayName, shortDescr, image)
|
|
})
|
|
}
|
|
// The submit runs in an unstructured Task that SwiftUI does not cancel, so a
|
|
// swipe-to-dismiss mid-create would still create the profile and switch to it
|
|
// while every state write landed on a dismissed view.
|
|
.interactiveDismissDisabled(creatingProfile)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ConnectView: View {
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@StateObject private var connectProgressManager = ConnectProgressManager.shared
|
|
@Environment(\.dismiss) var dismiss: DismissAction
|
|
@EnvironmentObject var theme: AppTheme
|
|
@Binding var showQRCodeScanner: Bool
|
|
@Binding var pastedLink: String
|
|
@Binding var alert: NewChatViewAlert?
|
|
var onboarding: Bool = false
|
|
@State var scannerPaused: Bool = false
|
|
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
|
|
|
|
var body: some View {
|
|
List {
|
|
Section(header: connectSectionHeader) {
|
|
pasteLinkView()
|
|
}
|
|
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
|
|
|
Section(header: Text("Or scan QR code").foregroundColor(theme.colors.secondary)) {
|
|
ScannerInView(showQRCodeScanner: $showQRCodeScanner, scannerPaused: $scannerPaused, processQRCode: processQRCode)
|
|
}
|
|
}
|
|
.onDisappear {
|
|
connectProgressManager.cancelConnectProgress()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func pasteLinkView() -> some View {
|
|
if pastedLink == "" {
|
|
ZStack(alignment: .trailing) {
|
|
Button {
|
|
if let str = UIPasteboard.general.string {
|
|
switch strConnectTarget(str.trimmingCharacters(in: .whitespaces)) {
|
|
case let .link(text, _, _):
|
|
pastedLink = text
|
|
connect(pastedLink)
|
|
case let .name(text, _):
|
|
pastedLink = text
|
|
connect(pastedLink)
|
|
case .none:
|
|
alert = .newChatSomeAlert(alert: SomeAlert(
|
|
alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."),
|
|
id: "pasteLinkView: code is not a SimpleX link"
|
|
))
|
|
}
|
|
}
|
|
} label: {
|
|
Text("Tap to paste link").foregroundColor(theme.colors.primary)
|
|
}
|
|
.disabled(!pasteboardHasStrings)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
if connectProgressManager.showConnectProgress != nil {
|
|
ProgressView()
|
|
}
|
|
}
|
|
} else {
|
|
HStack {
|
|
linkTextView(pastedLink)
|
|
if connectProgressManager.showConnectProgress != nil {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
|
switch resp {
|
|
case let .success(r):
|
|
let link = r.string
|
|
if strIsSimplexLink(r.string) {
|
|
connect(link)
|
|
} else {
|
|
alert = .newChatSomeAlert(alert: SomeAlert(
|
|
alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."),
|
|
id: "processQRCode: code is not a SimpleX link"
|
|
))
|
|
}
|
|
case let .failure(e):
|
|
logger.error("processQRCode QR code error: \(e.localizedDescription)")
|
|
alert = .newChatSomeAlert(alert: SomeAlert(
|
|
alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"),
|
|
id: "processQRCode: failure"
|
|
))
|
|
}
|
|
}
|
|
|
|
private var connectSectionHeader: some View {
|
|
#if SIMPLEX_ASSETS
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Image(colorScheme == .light
|
|
? (onboarding ? "connect-via-link" : "connect-via-link-small")
|
|
: (onboarding ? "connect-via-link-light" : "connect-via-link-small-light"))
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxWidth: .infinity)
|
|
Text("Paste the link you received").foregroundColor(theme.colors.secondary)
|
|
}
|
|
.padding(.bottom, 4)
|
|
#else
|
|
Text("Paste the link you received").foregroundColor(theme.colors.secondary)
|
|
#endif
|
|
}
|
|
|
|
private func connect(_ link: String) {
|
|
scannerPaused = true
|
|
planAndConnect(
|
|
link,
|
|
theme: theme,
|
|
dismiss: true,
|
|
cleanup: {
|
|
pastedLink = ""
|
|
scannerPaused = false
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
struct ScannerInView: View {
|
|
@Binding var showQRCodeScanner: Bool
|
|
var scannerPaused: Binding<Bool>? = nil
|
|
let processQRCode: (_ resp: Result<ScanResult, ScanError>) -> Void
|
|
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
|
|
var scanMode: ScanMode = .continuous
|
|
|
|
var body: some View {
|
|
Group {
|
|
if showQRCodeScanner, case .authorized = cameraAuthorizationStatus {
|
|
CodeScannerView(codeTypes: [.qr], scanMode: scanMode, isPaused: scannerPaused?.wrappedValue ?? false, completion: processQRCode)
|
|
.aspectRatio(1, contentMode: .fit)
|
|
.cornerRadius(12)
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
|
.padding(.horizontal)
|
|
} else {
|
|
Button {
|
|
switch cameraAuthorizationStatus {
|
|
case .notDetermined: askCameraAuthorization { showQRCodeScanner = true }
|
|
case .restricted: ()
|
|
case .denied: UIApplication.shared.open(appSettingsURL)
|
|
case .authorized: showQRCodeScanner = true
|
|
default: askCameraAuthorization { showQRCodeScanner = true }
|
|
}
|
|
} label: {
|
|
ZStack {
|
|
Rectangle()
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.foregroundColor(Color.clear)
|
|
switch cameraAuthorizationStatus {
|
|
case .authorized, nil: EmptyView()
|
|
case .restricted: Text("Camera not available")
|
|
case .denied: Label("Enable camera access", systemImage: "camera")
|
|
default: Label("Tap to scan", systemImage: "qrcode")
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(Color(uiColor: .secondarySystemGroupedBackground))
|
|
)
|
|
.padding(.horizontal)
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
|
.disabled(cameraAuthorizationStatus == .restricted)
|
|
}
|
|
}
|
|
.task {
|
|
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
|
cameraAuthorizationStatus = status
|
|
if showQRCodeScanner {
|
|
switch status {
|
|
case .notDetermined: await askCameraAuthorizationAsync()
|
|
case .restricted: showQRCodeScanner = false
|
|
case .denied: showQRCodeScanner = false
|
|
case .authorized: ()
|
|
@unknown default: await askCameraAuthorizationAsync()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func askCameraAuthorizationAsync() async {
|
|
await AVCaptureDevice.requestAccess(for: .video)
|
|
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
|
}
|
|
|
|
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
|
|
AVCaptureDevice.requestAccess(for: .video) { allowed in
|
|
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
|
if allowed { cb?() }
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
func linkTextView(_ link: String) -> some View {
|
|
Text(link)
|
|
.lineLimit(1)
|
|
.font(.caption)
|
|
.truncationMode(.middle)
|
|
}
|
|
|
|
struct InfoSheetButton<Content: View>: View {
|
|
@ViewBuilder let content: Content
|
|
@State private var showInfoSheet = false
|
|
|
|
var body: some View {
|
|
Button {
|
|
showInfoSheet = true
|
|
} label: {
|
|
Image(systemName: "info.circle")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 24, height: 24)
|
|
}
|
|
.sheet(isPresented: $showInfoSheet) {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
func strIsSimplexLink(_ str: String) -> Bool {
|
|
if let parsedMd = parseSimpleXMarkdown(str),
|
|
parsedMd.count == 1,
|
|
case .simplexLink = parsedMd[0].format {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
enum ConnectTarget {
|
|
case link(text: String, linkType: SimplexLinkType, linkText: String)
|
|
case name(text: String, nameInfo: SimplexNameInfo)
|
|
}
|
|
|
|
func strConnectTarget(_ str: String) -> ConnectTarget? {
|
|
let parsedMd = parseSimpleXMarkdown(str)
|
|
let links = parsedMd?.filter { $0.format?.isSimplexLink ?? false } ?? []
|
|
return if links.count == 1, case let .simplexLink(_, linkType, _, smpHosts) = links[0].format {
|
|
.link(text: links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts))
|
|
} else if links.isEmpty,
|
|
let nameFt = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } }),
|
|
case let .simplexName(nameInfo) = nameFt.format {
|
|
.name(text: nameFt.text, nameInfo: nameInfo)
|
|
} else {
|
|
nil
|
|
}
|
|
}
|
|
|
|
struct IncognitoToggle: View {
|
|
@EnvironmentObject var theme: AppTheme
|
|
@Binding var incognitoEnabled: Bool
|
|
@State private var showIncognitoSheet = false
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .leading) {
|
|
Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks")
|
|
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
|
.foregroundColor(incognitoEnabled ? Color.indigo : theme.colors.secondary)
|
|
.font(.system(size: 14))
|
|
Toggle(isOn: $incognitoEnabled) {
|
|
HStack(spacing: 6) {
|
|
Text("Incognito")
|
|
Image(systemName: "info.circle")
|
|
.foregroundColor(theme.colors.primary)
|
|
.font(.system(size: 14))
|
|
}
|
|
.onTapGesture {
|
|
showIncognitoSheet = true
|
|
}
|
|
}
|
|
.padding(.leading, 36)
|
|
}
|
|
.sheet(isPresented: $showIncognitoSheet) {
|
|
IncognitoHelp()
|
|
}
|
|
}
|
|
}
|
|
|
|
func sharedProfileInfo(_ 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."
|
|
)
|
|
}
|
|
|
|
private func showInvitationLinkConnectingAlert(cleanup: (() -> Void)?) {
|
|
showAlert(
|
|
NSLocalizedString("Already connecting!", comment: "new chat sheet title"),
|
|
message: NSLocalizedString("You are already connecting via this one-time link!", comment: "new chat sheet message"),
|
|
actions: {[
|
|
okCleanupAlertAction(cleanup: cleanup)
|
|
]}
|
|
)
|
|
}
|
|
|
|
private func showGroupLinkConnectingAlert(groupInfo: GroupInfo?, cleanup: (() -> Void)?) {
|
|
if let groupInfo = groupInfo {
|
|
if groupInfo.businessChat == nil {
|
|
showAlert(
|
|
NSLocalizedString("Group already exists!", comment: "new chat sheet title"),
|
|
message:
|
|
String.localizedStringWithFormat(
|
|
NSLocalizedString("You are already joining the group %@.", comment: "new chat sheet message"),
|
|
groupInfo.displayName
|
|
),
|
|
actions: {[
|
|
okCleanupAlertAction(cleanup: cleanup)
|
|
]}
|
|
)
|
|
} else {
|
|
showAlert(
|
|
NSLocalizedString("Chat already exists!", comment: "new chat sheet title"),
|
|
message:
|
|
String.localizedStringWithFormat(
|
|
NSLocalizedString("You are already connecting to %@.", comment: "new chat sheet message"),
|
|
groupInfo.displayName
|
|
),
|
|
actions: {[
|
|
okCleanupAlertAction(cleanup: cleanup)
|
|
]}
|
|
)
|
|
}
|
|
} else {
|
|
showAlert(
|
|
NSLocalizedString("Already joining the group!", comment: "new chat sheet title"),
|
|
message: NSLocalizedString("You are already joining the group via this link.", comment: "new chat sheet message"),
|
|
actions: {[
|
|
okCleanupAlertAction(cleanup: cleanup)
|
|
]}
|
|
)
|
|
}
|
|
}
|
|
|
|
private func okCleanupAlertAction(cleanup: (() -> Void)?) -> UIAlertAction {
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Ok", comment: "new chat action"),
|
|
style: .default,
|
|
handler: { _ in
|
|
cleanup?()
|
|
}
|
|
)
|
|
}
|
|
|
|
private func showAskCurrentOrIncognitoProfileSheet(
|
|
title: String,
|
|
actionStyle: UIAlertAction.Style = .default,
|
|
connectionLink: CreatedConnLink,
|
|
connectionPlan: ConnectionPlan?,
|
|
ownerVerification: OwnerVerification? = nil,
|
|
dismiss: Bool,
|
|
cleanup: (() -> Void)?
|
|
) {
|
|
showSheet(
|
|
title,
|
|
message: ownerVerificationMessage(ownerVerification),
|
|
actions: {[
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Use current profile", comment: "new chat action"),
|
|
style: actionStyle,
|
|
handler: { _ in
|
|
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup)
|
|
}
|
|
),
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Use new incognito profile", comment: "new chat action"),
|
|
style: actionStyle,
|
|
handler: { _ in
|
|
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup)
|
|
}
|
|
),
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Cancel", comment: "new chat action"),
|
|
style: .default,
|
|
handler: { _ in
|
|
cleanup?()
|
|
}
|
|
)
|
|
]}
|
|
)
|
|
}
|
|
|
|
private func showAskCurrentOrIncognitoProfileConnectContactViaAddressSheet(
|
|
contact: Contact,
|
|
dismiss: Bool,
|
|
cleanup: (() -> Void)?
|
|
) {
|
|
showSheet(
|
|
String.localizedStringWithFormat(
|
|
NSLocalizedString("Connect with %@", comment: "new chat action"),
|
|
contact.chatViewName
|
|
),
|
|
actions: {[
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Use current profile", comment: "new chat action"),
|
|
style: .default,
|
|
handler: { _ in
|
|
connectContactViaAddress_(contact, dismiss: dismiss, incognito: false, cleanup: cleanup)
|
|
}
|
|
),
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Use new incognito profile", comment: "new chat action"),
|
|
style: .default,
|
|
handler: { _ in
|
|
connectContactViaAddress_(contact, dismiss: dismiss, incognito: true, cleanup: cleanup)
|
|
}
|
|
),
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Cancel", comment: "new chat action"),
|
|
style: .default,
|
|
handler: { _ in
|
|
cleanup?()
|
|
}
|
|
)
|
|
]}
|
|
)
|
|
}
|
|
|
|
private func showOwnGroupLinkConfirmConnectSheet(
|
|
groupInfo: GroupInfo,
|
|
connectionLink: CreatedConnLink,
|
|
connectionPlan: ConnectionPlan?,
|
|
dismiss: Bool,
|
|
cleanup: (() -> Void)?
|
|
) {
|
|
if groupInfo.useRelays {
|
|
showSheet(
|
|
String.localizedStringWithFormat(
|
|
NSLocalizedString("This is your link for channel %@!", comment: "new chat action"),
|
|
groupInfo.displayName
|
|
),
|
|
actions: {[
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Open channel", comment: "new chat action"),
|
|
style: .default,
|
|
handler: { _ in
|
|
openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup)
|
|
}
|
|
),
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Cancel", comment: "new chat action"),
|
|
style: .default,
|
|
handler: { _ in
|
|
cleanup?()
|
|
}
|
|
)
|
|
]}
|
|
)
|
|
} else {
|
|
showSheet(
|
|
String.localizedStringWithFormat(
|
|
NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"),
|
|
groupInfo.displayName
|
|
),
|
|
actions: {[
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Open group", comment: "new chat action"),
|
|
style: .default,
|
|
handler: { _ in
|
|
openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup)
|
|
}
|
|
),
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Use current profile", comment: "new chat action"),
|
|
style: .destructive,
|
|
handler: { _ in
|
|
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup)
|
|
}
|
|
),
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Use new incognito profile", comment: "new chat action"),
|
|
style: .destructive,
|
|
handler: { _ in
|
|
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup)
|
|
}
|
|
),
|
|
UIAlertAction(
|
|
title: NSLocalizedString("Cancel", comment: "new chat action"),
|
|
style: .default,
|
|
handler: { _ in
|
|
cleanup?()
|
|
}
|
|
)
|
|
]}
|
|
)
|
|
}
|
|
}
|
|
|
|
private func showPrepareContactAlert(
|
|
connectionLink: CreatedConnLink,
|
|
contactShortLinkData: ContactShortLinkData,
|
|
ownerVerification: OwnerVerification? = nil,
|
|
verifiedDomain: SimplexDomain? = nil,
|
|
connectOtherButton: String? = nil,
|
|
connectOtherLink: String? = nil,
|
|
theme: AppTheme,
|
|
dismiss: Bool,
|
|
cleanup: (() -> Void)?
|
|
) {
|
|
showOpenChatAlert(
|
|
profileName: contactShortLinkData.profile.displayName,
|
|
profileFullName: contactShortLinkData.profile.fullName,
|
|
profileImage:
|
|
ProfileImage(
|
|
imageStr: contactShortLinkData.profile.image,
|
|
iconName: contactShortLinkData.business
|
|
? "briefcase.circle.fill"
|
|
: contactShortLinkData.profile.peerType == .bot
|
|
? "cube.fill"
|
|
: "person.crop.circle.fill",
|
|
size: alertProfileImageSize
|
|
),
|
|
profileBadge: contactShortLinkData.localBadge,
|
|
theme: theme,
|
|
information: ownerVerificationMessage(ownerVerification),
|
|
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
|
|
confirmTitle: NSLocalizedString("Open new chat", comment: "new chat action"),
|
|
secondTitle: connectOtherButton,
|
|
onCancel: { cleanup?() },
|
|
onConfirm: {
|
|
Task {
|
|
do {
|
|
let chat = try await apiPrepareContact(connLink: connectionLink, contactShortLinkData: contactShortLinkData, verifiedDomain: verifiedDomain)
|
|
await MainActor.run {
|
|
ChatModel.shared.addChat(Chat(chat))
|
|
openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup)
|
|
}
|
|
} catch let error {
|
|
logger.error("showPrepareContactAlert apiPrepareContact error: \(error.localizedDescription)")
|
|
showAlert(NSLocalizedString("Error opening chat", comment: ""), message: responseError(error))
|
|
await MainActor.run {
|
|
cleanup?()
|
|
}
|
|
}
|
|
}
|
|
},
|
|
onSecond: connectOtherLink.map { link in
|
|
{ planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) }
|
|
}
|
|
)
|
|
}
|
|
|
|
private func showPrepareGroupAlert(
|
|
connectionLink: CreatedConnLink,
|
|
groupShortLinkInfo: GroupShortLinkInfo?,
|
|
groupShortLinkData: GroupShortLinkData,
|
|
ownerVerification: OwnerVerification? = nil,
|
|
verifiedDomain: SimplexDomain? = nil,
|
|
connectOtherButton: String? = nil,
|
|
connectOtherLink: String? = nil,
|
|
theme: AppTheme,
|
|
dismiss: Bool,
|
|
cleanup: (() -> Void)?
|
|
) {
|
|
let isChannel = !(groupShortLinkInfo?.direct ?? true)
|
|
let subscriberCount = groupShortLinkData.publicGroupData.map { "\($0.publicMemberCount) subscribers" }
|
|
showOpenChatAlert(
|
|
profileName: groupShortLinkData.groupProfile.displayName,
|
|
profileFullName: groupShortLinkData.groupProfile.fullName,
|
|
profileImage:
|
|
ProfileImage(
|
|
imageStr: groupShortLinkData.groupProfile.image,
|
|
iconName: isChannel
|
|
? "antenna.radiowaves.left.and.right.circle.fill"
|
|
: "person.2.circle.fill",
|
|
size: alertProfileImageSize
|
|
),
|
|
theme: theme,
|
|
subtitle: isChannel ? subscriberCount : nil,
|
|
information: ownerVerificationMessage(ownerVerification),
|
|
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
|
|
confirmTitle: isChannel
|
|
? NSLocalizedString("Open new channel", comment: "new chat action")
|
|
: NSLocalizedString("Open new group", comment: "new chat action"),
|
|
secondTitle: connectOtherButton,
|
|
onCancel: { cleanup?() },
|
|
onConfirm: {
|
|
Task {
|
|
do {
|
|
let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData, verifiedDomain: verifiedDomain)
|
|
await MainActor.run {
|
|
if let relays = groupShortLinkInfo?.groupRelays, !relays.isEmpty,
|
|
case let .group(gInfo, _) = chat.chatInfo {
|
|
ChatModel.shared.channelRelayHostnames[gInfo.groupId] = relays
|
|
}
|
|
ChatModel.shared.addChat(Chat(chat))
|
|
openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup)
|
|
}
|
|
} catch let error {
|
|
logger.error("showPrepareGroupAlert apiPrepareGroup error: \(error.localizedDescription)")
|
|
showAlert(NSLocalizedString(isChannel ? "Error opening channel" : "Error opening group", comment: "alert title"), message: responseError(error))
|
|
await MainActor.run {
|
|
cleanup?()
|
|
}
|
|
}
|
|
}
|
|
},
|
|
onSecond: connectOtherLink.map { link in
|
|
{ planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) }
|
|
}
|
|
)
|
|
}
|
|
|
|
private func showOpenKnownContactAlert(
|
|
_ contact: Contact,
|
|
theme: AppTheme,
|
|
dismiss: Bool,
|
|
connectOtherButton: String? = nil,
|
|
connectOtherLink: String? = nil
|
|
) {
|
|
showOpenChatAlert(
|
|
profileName: contact.profile.displayName,
|
|
profileFullName: contact.profile.fullName,
|
|
profileImage:
|
|
ProfileImage(
|
|
imageStr: contact.profile.image,
|
|
iconName: contact.chatIconName,
|
|
size: alertProfileImageSize
|
|
),
|
|
profileBadge: contact.active ? contact.profile.localBadge : nil,
|
|
theme: theme,
|
|
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
|
|
confirmTitle:
|
|
contact.nextConnectPrepared
|
|
? NSLocalizedString("Open new chat", comment: "new chat action")
|
|
: NSLocalizedString("Open chat", comment: "new chat action"),
|
|
secondTitle: connectOtherButton,
|
|
onConfirm: {
|
|
openKnownContact(contact, dismiss: dismiss, cleanup: nil)
|
|
},
|
|
onSecond: connectOtherLink.map { link in
|
|
{ planAndConnect(link, theme: theme, dismiss: dismiss) }
|
|
}
|
|
)
|
|
}
|
|
|
|
private func showOpenKnownGroupAlert(
|
|
_ groupInfo: GroupInfo,
|
|
theme: AppTheme,
|
|
dismiss: Bool,
|
|
connectOtherButton: String? = nil,
|
|
connectOtherLink: String? = nil
|
|
) {
|
|
let subscriberCount = groupInfo.groupSummary.publicMemberCount.map { "\($0) subscribers" }
|
|
showOpenChatAlert(
|
|
profileName: groupInfo.groupProfile.displayName,
|
|
profileFullName: groupInfo.groupProfile.fullName,
|
|
profileImage:
|
|
ProfileImage(
|
|
imageStr: groupInfo.groupProfile.image,
|
|
iconName: groupInfo.chatIconName,
|
|
size: alertProfileImageSize
|
|
),
|
|
theme: theme,
|
|
subtitle: groupInfo.useRelays ? subscriberCount : nil,
|
|
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
|
|
confirmTitle:
|
|
groupInfo.useRelays
|
|
? ( groupInfo.nextConnectPrepared
|
|
? NSLocalizedString("Open new channel", comment: "new chat action")
|
|
: NSLocalizedString("Open channel", comment: "new chat action")
|
|
)
|
|
: groupInfo.businessChat == nil
|
|
? ( groupInfo.nextConnectPrepared
|
|
? NSLocalizedString("Open new group", comment: "new chat action")
|
|
: NSLocalizedString("Open group", comment: "new chat action")
|
|
)
|
|
: ( groupInfo.nextConnectPrepared
|
|
? NSLocalizedString("Open new chat", comment: "new chat action")
|
|
: NSLocalizedString("Open chat", comment: "new chat action")
|
|
),
|
|
secondTitle: connectOtherButton,
|
|
onConfirm: {
|
|
openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil)
|
|
},
|
|
onSecond: connectOtherLink.map { link in
|
|
{ planAndConnect(link, theme: theme, dismiss: dismiss) }
|
|
}
|
|
)
|
|
}
|
|
|
|
// Spec: spec/client/navigation.md#planAndConnect
|
|
func planAndConnect(
|
|
_ shortOrFullLink: String,
|
|
linkOwnerSig: LinkOwnerSig? = nil,
|
|
theme: AppTheme,
|
|
dismiss: Bool,
|
|
cleanup: (() -> Void)? = nil,
|
|
filterKnownContact: ((Contact) -> Void)? = nil,
|
|
filterKnownGroup: ((GroupInfo) -> Void)? = nil
|
|
) {
|
|
switch strConnectTarget(shortOrFullLink) {
|
|
case let .link(_, linkType, _):
|
|
if linkType == .relay {
|
|
showAlert(
|
|
NSLocalizedString("Relay address", comment: "alert title"),
|
|
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message")
|
|
)
|
|
cleanup?()
|
|
return
|
|
}
|
|
// A SimplexName falls through to apiConnectPlan, which resolves it on the
|
|
// core (the /_connect plan command accepts a name target, not only a link).
|
|
case .name, .none: break
|
|
}
|
|
ConnectProgressManager.shared.cancelConnectProgress()
|
|
let inProgress = BoxedValue(true)
|
|
connectTask(inProgress)
|
|
ConnectProgressManager.shared.startConnectProgress(NSLocalizedString("Loading profile…", comment: "in progress text")) {
|
|
inProgress.boxedValue = false
|
|
cleanup?()
|
|
}
|
|
|
|
func connectTask(_ inProgress: BoxedValue<Bool>) {
|
|
Task {
|
|
let result = await apiConnectPlan(connLink: shortOrFullLink, linkOwnerSig: linkOwnerSig, inProgress: inProgress)
|
|
await MainActor.run {
|
|
ConnectProgressManager.shared.stopConnectProgress()
|
|
}
|
|
if !inProgress.boxedValue { return }
|
|
if let result {
|
|
let connectionLink = result.connLink
|
|
let connectionPlan = result.connectionPlan
|
|
let planSimplexName = result.planSimplexName
|
|
// the name can also resolve to the other kind; its type picks the verb, its short form the label and target
|
|
let connectOtherLink = result.otherSimplexName?.shortStr
|
|
let connectOtherButton: String? = result.otherSimplexName.map { info in
|
|
String.localizedStringWithFormat(
|
|
info.nameType == .publicGroup
|
|
? NSLocalizedString("Join channel %@", comment: "new chat action")
|
|
: NSLocalizedString("Connect to %@", comment: "new chat action"),
|
|
info.shortStr
|
|
)
|
|
}
|
|
switch connectionPlan {
|
|
case let .invitationLink(ilp):
|
|
switch ilp {
|
|
case let .ok(contactSLinkData_, ownerVerification):
|
|
if let contactSLinkData = contactSLinkData_ {
|
|
logger.debug("planAndConnect, .invitationLink, .ok, short link data present")
|
|
await MainActor.run {
|
|
showPrepareContactAlert(
|
|
connectionLink: connectionLink,
|
|
contactShortLinkData: contactSLinkData,
|
|
ownerVerification: ownerVerification,
|
|
theme: theme,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
} else {
|
|
logger.debug("planAndConnect, .invitationLink, .ok, no short link data")
|
|
await MainActor.run {
|
|
showAskCurrentOrIncognitoProfileSheet(
|
|
title: NSLocalizedString("Connect via one-time link", comment: "new chat sheet title"),
|
|
connectionLink: connectionLink,
|
|
connectionPlan: connectionPlan,
|
|
ownerVerification: ownerVerification,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
}
|
|
case .ownLink:
|
|
logger.debug("planAndConnect, .invitationLink, .ownLink")
|
|
await MainActor.run {
|
|
showAskCurrentOrIncognitoProfileSheet(
|
|
title: NSLocalizedString("Connect to yourself?\nThis is your own one-time link!", comment: "new chat sheet title"),
|
|
actionStyle: .destructive,
|
|
connectionLink: connectionLink,
|
|
connectionPlan: connectionPlan,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
case let .connecting(contact_):
|
|
logger.debug("planAndConnect, .invitationLink, .connecting")
|
|
await MainActor.run {
|
|
if let contact = contact_ {
|
|
if let f = filterKnownContact {
|
|
f(contact)
|
|
} else {
|
|
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
|
|
}
|
|
} else {
|
|
showInvitationLinkConnectingAlert(cleanup: cleanup)
|
|
}
|
|
}
|
|
case let .known(contact):
|
|
logger.debug("planAndConnect, .invitationLink, .known")
|
|
await MainActor.run {
|
|
if let f = filterKnownContact {
|
|
f(contact)
|
|
} else {
|
|
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
|
|
}
|
|
}
|
|
}
|
|
case let .contactAddress(cap):
|
|
switch cap {
|
|
case let .ok(contactSLinkData_, ownerVerification):
|
|
if let contactSLinkData = contactSLinkData_ {
|
|
logger.debug("planAndConnect, .contactAddress, .ok, short link data present")
|
|
await MainActor.run {
|
|
showPrepareContactAlert(
|
|
connectionLink: connectionLink,
|
|
contactShortLinkData: contactSLinkData,
|
|
ownerVerification: ownerVerification,
|
|
verifiedDomain: planSimplexName?.nameDomain,
|
|
connectOtherButton: connectOtherButton,
|
|
connectOtherLink: connectOtherLink,
|
|
theme: theme,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
} else {
|
|
logger.debug("planAndConnect, .contactAddress, .ok, no short link data")
|
|
await MainActor.run {
|
|
showAskCurrentOrIncognitoProfileSheet(
|
|
title: NSLocalizedString("Connect via contact address", comment: "new chat sheet title"),
|
|
connectionLink: connectionLink,
|
|
connectionPlan: connectionPlan,
|
|
ownerVerification: ownerVerification,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
}
|
|
case .ownLink:
|
|
logger.debug("planAndConnect, .contactAddress, .ownLink")
|
|
await MainActor.run {
|
|
showAskCurrentOrIncognitoProfileSheet(
|
|
title: NSLocalizedString("Connect to yourself?\nThis is your own SimpleX address!", comment: "new chat sheet title"),
|
|
actionStyle: .destructive,
|
|
connectionLink: connectionLink,
|
|
connectionPlan: connectionPlan,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
case .connectingConfirmReconnect:
|
|
logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect")
|
|
await MainActor.run {
|
|
showAskCurrentOrIncognitoProfileSheet(
|
|
title: NSLocalizedString("You have already requested connection!\nRepeat connection request?", comment: "new chat sheet title"),
|
|
actionStyle: .destructive,
|
|
connectionLink: connectionLink,
|
|
connectionPlan: connectionPlan,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
case let .connectingProhibit(contact):
|
|
logger.debug("planAndConnect, .contactAddress, .connectingProhibit")
|
|
await MainActor.run {
|
|
if let f = filterKnownContact {
|
|
f(contact)
|
|
} else {
|
|
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink)
|
|
}
|
|
}
|
|
case let .known(contact):
|
|
logger.debug("planAndConnect, .contactAddress, .known")
|
|
await MainActor.run {
|
|
if ChatModel.shared.getContactChat(contact.contactId) == nil {
|
|
ChatModel.shared.addChat(Chat(chatInfo: .direct(contact: contact)))
|
|
}
|
|
if let f = filterKnownContact {
|
|
f(contact)
|
|
} else {
|
|
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink)
|
|
}
|
|
}
|
|
case let .contactViaAddress(contact):
|
|
logger.debug("planAndConnect, .contactAddress, .contactViaAddress")
|
|
await MainActor.run {
|
|
showAskCurrentOrIncognitoProfileConnectContactViaAddressSheet(
|
|
contact: contact,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
}
|
|
case let .groupLink(glp):
|
|
switch glp {
|
|
case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification):
|
|
if let groupSLinkData = groupSLinkData_ {
|
|
logger.debug("planAndConnect, .groupLink, .ok, short link data present")
|
|
await MainActor.run {
|
|
showPrepareGroupAlert(
|
|
connectionLink: connectionLink,
|
|
groupShortLinkInfo: groupShortLinkInfo_,
|
|
groupShortLinkData: groupSLinkData,
|
|
ownerVerification: ownerVerification,
|
|
verifiedDomain: planSimplexName?.nameDomain,
|
|
connectOtherButton: connectOtherButton,
|
|
connectOtherLink: connectOtherLink,
|
|
theme: theme,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
} else {
|
|
logger.debug("planAndConnect, .groupLink, .ok, no short link data")
|
|
await MainActor.run {
|
|
showAskCurrentOrIncognitoProfileSheet(
|
|
title: NSLocalizedString("Join group", comment: "new chat sheet title"),
|
|
connectionLink: connectionLink,
|
|
connectionPlan: connectionPlan,
|
|
ownerVerification: ownerVerification,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
}
|
|
case let .ownLink(groupInfo):
|
|
logger.debug("planAndConnect, .groupLink, .ownLink")
|
|
await MainActor.run {
|
|
if let f = filterKnownGroup {
|
|
f(groupInfo)
|
|
}
|
|
showOwnGroupLinkConfirmConnectSheet(
|
|
groupInfo: groupInfo,
|
|
connectionLink: connectionLink,
|
|
connectionPlan: connectionPlan,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
case .connectingConfirmReconnect:
|
|
logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect")
|
|
await MainActor.run {
|
|
showAskCurrentOrIncognitoProfileSheet(
|
|
title: NSLocalizedString("You are already joining the group!\nRepeat join request?", comment: "new chat sheet title"),
|
|
actionStyle: .destructive,
|
|
connectionLink: connectionLink,
|
|
connectionPlan: connectionPlan,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
case let .connectingProhibit(groupInfo_):
|
|
logger.debug("planAndConnect, .groupLink, .connectingProhibit")
|
|
await MainActor.run {
|
|
showGroupLinkConnectingAlert(groupInfo: groupInfo_, cleanup: cleanup)
|
|
}
|
|
case let .known(groupInfo):
|
|
logger.debug("planAndConnect, .groupLink, .known")
|
|
await MainActor.run {
|
|
if ChatModel.shared.getGroupChat(groupInfo.groupId) == nil {
|
|
ChatModel.shared.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil)))
|
|
}
|
|
if let f = filterKnownGroup {
|
|
f(groupInfo)
|
|
} else {
|
|
showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink)
|
|
}
|
|
}
|
|
case let .noRelays(groupSLinkData_):
|
|
logger.debug("planAndConnect, .groupLink, .noRelays")
|
|
await MainActor.run {
|
|
if let groupSLinkData = groupSLinkData_ {
|
|
showOpenChatAlert(
|
|
profileName: groupSLinkData.groupProfile.displayName,
|
|
profileFullName: groupSLinkData.groupProfile.fullName,
|
|
profileImage:
|
|
ProfileImage(
|
|
imageStr: groupSLinkData.groupProfile.image,
|
|
iconName: "antenna.radiowaves.left.and.right.circle.fill",
|
|
size: alertProfileImageSize
|
|
),
|
|
theme: theme,
|
|
subtitle: NSLocalizedString("Channel has no active relays. Please try to join later.", comment: "alert subtitle"),
|
|
cancelTitle: NSLocalizedString("OK", comment: "alert button"),
|
|
confirmTitle: nil,
|
|
onCancel: { cleanup?() }
|
|
)
|
|
} else {
|
|
showAlert(
|
|
NSLocalizedString("Channel temporarily unavailable", comment: "alert title"),
|
|
message: NSLocalizedString("Channel has no active relays. Please try to join later.", comment: "alert message")
|
|
)
|
|
cleanup?()
|
|
}
|
|
}
|
|
case let .updateRequired(groupSLinkData_):
|
|
logger.debug("planAndConnect, .groupLink, .updateRequired")
|
|
await MainActor.run {
|
|
if let groupSLinkData = groupSLinkData_ {
|
|
showOpenChatAlert(
|
|
profileName: groupSLinkData.groupProfile.displayName,
|
|
profileFullName: groupSLinkData.groupProfile.fullName,
|
|
profileImage:
|
|
ProfileImage(
|
|
imageStr: groupSLinkData.groupProfile.image,
|
|
iconName: "person.2.circle.fill",
|
|
size: alertProfileImageSize
|
|
),
|
|
theme: theme,
|
|
subtitle: NSLocalizedString("This group requires a newer version of the app. Please update the app to join.", comment: "alert subtitle"),
|
|
cancelTitle: NSLocalizedString("OK", comment: "alert button"),
|
|
confirmTitle: nil,
|
|
onCancel: { cleanup?() }
|
|
)
|
|
} else {
|
|
showAlert(
|
|
NSLocalizedString("App update required", comment: "alert title"),
|
|
message: NSLocalizedString("This group requires a newer version of the app. Please update the app to join.", comment: "alert message")
|
|
)
|
|
cleanup?()
|
|
}
|
|
}
|
|
}
|
|
case let .error(chatError):
|
|
logger.debug("planAndConnect, .error \(chatErrorString(chatError))")
|
|
showAskCurrentOrIncognitoProfileSheet(
|
|
title: NSLocalizedString("Connect via link", comment: "new chat sheet title"),
|
|
connectionLink: connectionLink,
|
|
connectionPlan: nil,
|
|
dismiss: dismiss,
|
|
cleanup: cleanup
|
|
)
|
|
}
|
|
} else if let cleanup {
|
|
await MainActor.run { cleanup() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incognito: Bool, cleanup: (() -> Void)? = nil) {
|
|
Task {
|
|
if dismiss {
|
|
DispatchQueue.main.async {
|
|
dismissAllSheets(animated: true)
|
|
}
|
|
}
|
|
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
|
|
if ok {
|
|
AlertManager.shared.showAlert(connReqSentAlert(.contact))
|
|
}
|
|
cleanup?()
|
|
}
|
|
}
|
|
|
|
private func connectViaLink(
|
|
_ connectionLink: CreatedConnLink,
|
|
connectionPlan: ConnectionPlan?,
|
|
dismiss: Bool,
|
|
incognito: Bool,
|
|
cleanup: (() -> Void)?
|
|
) {
|
|
Task {
|
|
if let (connReqType, pcc) = await apiConnect(incognito: incognito, connLink: connectionLink) {
|
|
await MainActor.run {
|
|
ChatModel.shared.updateContactConnection(pcc)
|
|
}
|
|
let crt: ConnReqType
|
|
crt = if let plan = connectionPlan {
|
|
planToConnReqType(plan) ?? connReqType
|
|
} else {
|
|
connReqType
|
|
}
|
|
DispatchQueue.main.async {
|
|
if dismiss {
|
|
dismissAllSheets(animated: true) {
|
|
AlertManager.shared.showAlert(connReqSentAlert(crt))
|
|
}
|
|
} else {
|
|
AlertManager.shared.showAlert(connReqSentAlert(crt))
|
|
}
|
|
}
|
|
} else {
|
|
if dismiss {
|
|
DispatchQueue.main.async {
|
|
dismissAllSheets(animated: true)
|
|
}
|
|
}
|
|
}
|
|
cleanup?()
|
|
}
|
|
}
|
|
|
|
func openKnownContact(_ contact: Contact, dismiss: Bool, cleanup: (() -> Void)?) {
|
|
if let c = ChatModel.shared.getContactChat(contact.contactId) {
|
|
openKnownChat(c.id, dismiss: dismiss, cleanup: cleanup)
|
|
}
|
|
}
|
|
|
|
func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, cleanup: (() -> Void)?) {
|
|
if let g = ChatModel.shared.getGroupChat(groupInfo.groupId) {
|
|
openKnownChat(g.id, dismiss: dismiss, cleanup: cleanup)
|
|
}
|
|
}
|
|
|
|
func openKnownChat(_ chatId: ChatId, dismiss: Bool, cleanup: (() -> Void)?) {
|
|
if dismiss {
|
|
dismissAllSheets(animated: true) {
|
|
ItemsModel.shared.loadOpenChat(chatId) {
|
|
cleanup?()
|
|
}
|
|
}
|
|
} else {
|
|
ItemsModel.shared.loadOpenChat(chatId) {
|
|
cleanup?()
|
|
}
|
|
}
|
|
}
|
|
|
|
func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
|
|
mkAlert(
|
|
title: "Contact already exists",
|
|
message: "You are already connecting to \(contact.displayName)."
|
|
)
|
|
}
|
|
|
|
func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
|
|
groupInfo.businessChat == nil
|
|
? mkAlert(
|
|
title: "Group already exists",
|
|
message: "You are already in group \(groupInfo.displayName)."
|
|
)
|
|
: mkAlert(
|
|
title: "Chat already exists",
|
|
message: "You are already connected with \(groupInfo.displayName)."
|
|
)
|
|
}
|
|
|
|
enum ConnReqType: Equatable {
|
|
case invitation
|
|
case contact
|
|
case groupLink
|
|
|
|
var connReqSentText: LocalizedStringKey {
|
|
switch self {
|
|
case .invitation: return "You will be connected when your contact's device is online, please wait or check later!"
|
|
case .contact: return "You will be connected when your connection request is accepted, please wait or check later!"
|
|
case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!"
|
|
}
|
|
}
|
|
}
|
|
|
|
private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType? {
|
|
switch connectionPlan {
|
|
case .invitationLink: .invitation
|
|
case .contactAddress: .contact
|
|
case .groupLink: .groupLink
|
|
case .error: nil
|
|
}
|
|
}
|
|
|
|
private func ownerVerificationMessage(_ ov: OwnerVerification?) -> String? {
|
|
switch ov {
|
|
case .verified: NSLocalizedString("Link signature verified.", comment: "owner verification")
|
|
case let .failed(reason): String.localizedStringWithFormat(NSLocalizedString("⚠️ Signature verification failed: %@.", comment: "owner verification"), reason)
|
|
case .none: nil
|
|
}
|
|
}
|
|
|
|
func connReqSentAlert(_ type: ConnReqType) -> Alert {
|
|
return mkAlert(
|
|
title: "Connection request sent!",
|
|
message: type.connReqSentText
|
|
)
|
|
}
|
|
|
|
struct NewChatView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
@State var parentAlert: SomeAlert?
|
|
@State var contactConnection: PendingContactConnection? = nil
|
|
|
|
NewChatView(
|
|
selection: .invite
|
|
)
|
|
}
|
|
}
|