From d97fe5fd0cae01ca57302e9d701be545d31c9953 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:27:56 +0000 Subject: [PATCH] ios: offer creating a profile for an invitation Mirrors the Kotlin flow: both pickers get an "Add profile" row, and CreateProfile gains an optional onCreate so the same form is reused. Each picker's create function is five lines that end in the path a row tap already takes - changeProfile(newUser) for a prepared chat, selectedProfile plus .switchingUser for a one-time link. The sheets are attached to body rather than to a row, because rows live in a LazyVStack and profilePicker() is swapped for currentSelection() when listExpanded flips. Surface 1's picker height is computed from the row count, so it needs one more row. The list is refreshed after creating, for the same reason as Kotlin, and interactive dismissal is blocked while the create is in flight so backing out cannot leave the invitation moved. Not compiled: no Swift toolchain available. --- apps/ios/Shared/Model/AppAPITypes.swift | 7 +-- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../ContextProfilePickerView.swift | 50 ++++++++++++++++++- .../Shared/Views/NewChat/NewChatView.swift | 40 +++++++++++++++ .../Views/Onboarding/CreateProfile.swift | 17 ++++++- apps/ios/spec/api.md | 2 +- 6 files changed, 112 insertions(+), 8 deletions(-) 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) |