diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index db70511a5a..0a446683bb 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -14,7 +14,7 @@ import SwiftUI // Spec: spec/api.md#ChatCommand enum ChatCommand: ChatCmdProtocol { case showActiveUser - case createActiveUser(profile: Profile?, pastTimestamp: Bool) + case createActiveUser(profile: Profile?, pastTimestamp: Bool, keepActiveUser: Bool) case listUsers case apiSetActiveUser(userId: Int64, viewPwd: String?) case setAllContactReceipts(enable: Bool) @@ -202,8 +202,8 @@ enum ChatCommand: ChatCmdProtocol { get { switch self { case .showActiveUser: return "/u" - case let .createActiveUser(profile, pastTimestamp): - let user = NewUser(profile: profile, pastTimestamp: pastTimestamp) + case let .createActiveUser(profile, pastTimestamp, keepActiveUser): + let user = NewUser(profile: profile, pastTimestamp: pastTimestamp, keepActiveUser: keepActiveUser) return "/_create user \(encodeJSON(user))" case .listUsers: return "/users" case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))" @@ -1367,6 +1367,7 @@ struct NewUser: Encodable { var profile: Profile? var pastTimestamp: Bool var userChatRelay: Bool = false + var keepActiveUser: Bool = false } enum ChatPagination { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d7f29087d8..6f8c6e80b0 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -253,8 +253,8 @@ func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? { } } -func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User { - let r: ChatResponse0 = try chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl: ctrl) +func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, keepActiveUser: Bool = false, ctrl: chat_ctrl? = nil) throws -> User { + let r: ChatResponse0 = try chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp, keepActiveUser: keepActiveUser), ctrl: ctrl) if case let .activeUser(user) = r { return user } throw r.unexpected } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift index 9047eaf84b..2919cc44d0 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift @@ -21,6 +21,7 @@ struct ContextProfilePickerView: View { @State private var listExpanded = false @State private var expandedListReady = false @State private var showIncognitoSheet = false + @State private var showAddProfile = false @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @@ -34,6 +35,11 @@ struct ContextProfilePickerView: View { .sheet(isPresented: $showIncognitoSheet) { IncognitoHelp() } + .sheet(isPresented: $showAddProfile) { + NavigationView { + CreateProfile(onCreate: createProfileForChat) + } + } } private func viewBody() -> some View { @@ -79,6 +85,12 @@ struct ContextProfilePickerView: View { if expandedListReady { let scroll = ScrollView { LazyVStack(spacing: 0) { + addProfileOption() + .contentShape(Rectangle()) + Divider() + .padding(.leading) + .padding(.leading, 48) + let otherUsers = users .filter { u in u.userId != selectedUser.userId } .sorted(using: KeyPathComparator(\.activeOrder)) @@ -113,7 +125,7 @@ struct ContextProfilePickerView: View { } } } - .frame(maxHeight: USER_ROW_SIZE * min(MAX_VISIBLE_USER_ROWS, CGFloat(users.count + 1))) // + 1 for incognito + .frame(maxHeight: USER_ROW_SIZE * min(MAX_VISIBLE_USER_ROWS, CGFloat(users.count + 2))) .onAppear { DispatchQueue.main.async { withAnimation(nil) { @@ -193,6 +205,42 @@ struct ContextProfilePickerView: View { } } + private func addProfileOption() -> some View { + Button { + if chat.chatInfo.profileChangeProhibited { + showCantChangeProfileAlert() + } else { + 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) + .lineLimit(1) + Spacer() + } + .padding(.leading, 12) + .padding(.trailing) + .frame(height: USER_ROW_SIZE) + } + } + + private func createProfileForChat(_ profile: Profile) async throws { + let newUser = try apiCreateActiveUser(profile, keepActiveUser: true) + let updatedUsers = try? await listUsersAsync() + await MainActor.run { + showAddProfile = false + if let updatedUsers { + chatModel.users = updatedUsers + users = updatedUsers.map { $0.user }.filter { u in u.activeUser || !u.hidden } + } + changeProfile(newUser) + } + } + private func changeProfile(_ newUser: User) { Task { do { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index f938fb0063..3241dbd894 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -397,6 +397,7 @@ private struct ActiveProfilePicker: View { @State private var searchTextOrPassword = "" @State private var showIncognitoSheet = false @State private var incognitoFirst: Bool = false + @State private var showAddProfile = false @State var selectedProfile: User var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)} @@ -558,6 +559,36 @@ private struct ActiveProfilePicker: View { } } + private var addProfileOption: some View { + Button { + showAddProfile = true + } label: { + HStack { + Image(systemName: "person.crop.circle.badge.plus") + .resizable().scaledToFit().frame(width: 30, height: 30) + .padding(.trailing, 2) + .foregroundColor(theme.colors.primary) + Text("Add profile") + .foregroundColor(theme.colors.primary) + Spacer() + } + } + } + + private func createProfileForConnection(_ profile: Profile) async throws { + let newUser = try apiCreateActiveUser(profile, keepActiveUser: true) + let updatedUsers = try? await listUsersAsync() + await MainActor.run { + showAddProfile = false + if let updatedUsers { + chatModel.users = updatedUsers + profiles = updatedUsers.map { $0.user } + } + selectedProfile = newUser + profileSwitchStatus = .switchingUser + } + } + @ViewBuilder private func profilePicker() -> some View { let incognitoOption = Button { if !incognitoEnabled { @@ -610,8 +641,17 @@ private struct ActiveProfilePicker: View { profilerPickerUserOption(p) } } + + if contactConnection != nil { + addProfileOption + } } .opacity(switchingProfileByTimeout ? 0.4 : 1) + .sheet(isPresented: $showAddProfile) { + NavigationView { + CreateProfile(onCreate: createProfileForConnection) + } + } } } diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index bd9530c4ee..57606996a0 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -29,9 +29,11 @@ enum UserProfileAlert: Identifiable { let MAX_BIO_LENGTH_BYTES = 160 struct CreateProfile: View { + var onCreate: ((Profile) async throws -> Void)? = nil @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss @EnvironmentObject var theme: AppTheme + @State private var submitting = false @State private var displayName: String = "" @State private var profileBio: String = "" @FocusState private var focusDisplayName @@ -104,7 +106,7 @@ struct CreateProfile: View { Button(action: createProfile) { settingsRow("checkmark", color: theme.colors.primary) { Text("Create profile") } } - .disabled(!canCreateProfile(displayName) || !bioFitsLimit()) + .disabled(submitting || !canCreateProfile(displayName) || !bioFitsLimit()) } footer: { VStack(alignment: .leading, spacing: 8) { Text("Your profile is stored on your device and only shared with your contacts.") @@ -115,6 +117,7 @@ struct CreateProfile: View { .compactSectionSpacing() } .navigationTitle("Create your profile") + .interactiveDismissDisabled(submitting) .modifier(ThemedBackground(grouped: true)) .alert(item: $alert) { a in userProfileAlert(a, $displayName) } .confirmationDialog("Profile image", isPresented: $showChooseSource, titleVisibility: .visible) { @@ -168,6 +171,18 @@ struct CreateProfile: View { shortDescr: shortDescr, image: profileImage ) + if let onCreate { + submitting = true + Task { + do { + try await onCreate(profile) + } catch let error { + await MainActor.run { showCreateProfileAlert(showAlert: { alert = $0 }, error) } + } + await MainActor.run { submitting = false } + } + return + } let m = ChatModel.shared do { AppChatState.shared.set(.active) diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index f9a3c35917..cedde5bbb2 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -50,7 +50,7 @@ The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.sw | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| | `showActiveUser` | -- | Get current active user | [L16](../Shared/Model/AppAPITypes.swift#L16) | -| `createActiveUser` | `profile: Profile?, pastTimestamp: Bool` | Create new user profile | [L17](../Shared/Model/AppAPITypes.swift#L17) | +| `createActiveUser` | `profile: Profile?, pastTimestamp: Bool, keepActiveUser: Bool` | Create new user profile | [L17](../Shared/Model/AppAPITypes.swift#L17) | | `listUsers` | -- | List all user profiles | [L18](../Shared/Model/AppAPITypes.swift#L18) | | `apiSetActiveUser` | `userId: Int64, viewPwd: String?` | Switch active user | [L19](../Shared/Model/AppAPITypes.swift#L19) | | `apiHideUser` | `userId: Int64, viewPwd: String` | Hide user behind password | [L24](../Shared/Model/AppAPITypes.swift#L24) |