Files
simplex-chat/apps/ios/Shared/Views/NewChat/NewChatView.swift
T
Narasimha-sc 02eaa882ac fix the docs the API merge invalidated, and use the async listUsers
Merging keepActiveUser into apiCreateActiveUser changed a signature that two Kotlin
docs quote. spec/api.md still described the old parameters and said the command sets
the profile as active, full stop; product/flows/onboarding.md quoted the old signature
one step above the line this branch had already updated to mention the flag. The iOS
spec table was fixed in the previous commit and these were missed - the same drift on
the other platform.

Both iOS call sites now use listUsersAsync, which is what changeActiveUserAsync_ uses
a few lines later; the synchronous listUsers blocks whatever executor the Task is on,
and SwiftUI infers that as the main actor.

Not compiled: the Kotlin changes here are markdown only, and the Swift ones cannot be
built on this machine.
2026-08-06 17:49:37 +00:00

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? await listUsersAsync()
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
)
}
}