From 9a696629705d190dcc86122d67a350c3c95e5321 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:27:52 +0000 Subject: [PATCH] ios: offer creating a profile when accepting an invitation Adds "Add profile" to the profile picker shown above the compose box for a prepared chat, so connecting as someone new does not mean leaving the invitation to create a profile in settings. NewUser and createActiveUser gain keepActiveUser, with apiCreateProfileKeepingActive as the named entry point; the flag never appears at a call site. The profile is created without becoming active so the profile that owns the prepared chat stays active for the reassignment. The row is emitted first: unlike the Android/desktop list this one is not reverse-laid-out, so first renders at the top. After switching, the chat is reopened explicitly - keepingChatId only preserves its place in the reloaded list. Reuses the existing "Add profile" and "Error changing chat profile" strings, so no new translation entries. --- apps/ios/Shared/Model/AppAPITypes.swift | 10 ++- apps/ios/Shared/Model/SimpleXAPI.swift | 13 ++- .../ContextProfilePickerView.swift | 84 ++++++++++++++++++- 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 40b88ec338..c1430d51ff 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) @@ -201,8 +201,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))" @@ -1363,6 +1363,10 @@ struct NewUser: Encodable { var profile: Profile? var pastTimestamp: Bool var userChatRelay: Bool = false + // when set, the user is created without becoming active, preserving the current one; + // absent/false activates it as before. The response is activeUser either way - it + // carries the created user, which is then not the active one. + var keepActiveUser: Bool = false } enum ChatPagination { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7a934fc746..a1f342a023 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -254,7 +254,18 @@ 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) + try createUser(p, pastTimestamp: pastTimestamp, keepActiveUser: false, ctrl: ctrl) +} + +// Creates a profile *without* activating it: apiChangePreparedContactUser resolves the +// prepared chat under the active user, so the profile that owns it must stay active until +// the chat has moved. The returned user is therefore not the active one. +func apiCreateProfileKeepingActive(_ p: Profile) throws -> User { + try createUser(p, pastTimestamp: false, keepActiveUser: true, ctrl: nil) +} + +private func createUser(_ p: Profile?, pastTimestamp: Bool, keepActiveUser: Bool, ctrl: chat_ctrl?) 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..83cc6c58f5 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift @@ -21,6 +21,8 @@ struct ContextProfilePickerView: View { @State private var listExpanded = false @State private var expandedListReady = false @State private var showIncognitoSheet = false + @State private var showAddProfile = false + @State private var creatingProfile = false @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @@ -79,6 +81,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 +121,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))) // + 1 for incognito, + 1 for "Add profile" .onAppear { DispatchQueue.main.async { withAnimation(nil) { @@ -142,6 +150,17 @@ struct ContextProfilePickerView: View { } } } + // 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. Not the root either - two + // .sheet modifiers on the same view conflict, and body already presents + // IncognitoHelp. + .sheet(isPresented: $showAddProfile) { + NavigationView { + CreateProfile(onSubmit: { displayName, shortDescr, image in + try await createProfileForChat(displayName, shortDescr, image) + }) + } + } } private func profilerPickerUserOption(_ user: User) -> some View { @@ -193,6 +212,65 @@ struct ContextProfilePickerView: View { } } + private func 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) + .lineLimit(1) + + Spacer() + } + .padding(.leading, 12) + .padding(.trailing) + .frame(height: USER_ROW_SIZE) + } + .disabled(creatingProfile) + } + + // Creates a profile to use for this invitation. It is created without becoming + // active, because changeProfile below reassigns the prepared chat and the API + // resolves that chat under the currently active user - so the profile that owns the + // invitation has to stay active until the chat has been moved. + private func createProfileForChat(_ 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 apiCreateProfileKeepingActive(profile) + let users = try? listUsers() + await MainActor.run { + if let users = users { chatModel.users = users } + } + if newUser.activeUser { + // The core did not honour keepActiveUser and activated the profile - an older + // remote host ignoring the unknown field. Reassigning would now fail, so + // resync to what the host actually did and report it. + try await changeActiveUserAsync_(newUser.userId, viewPwd: nil) + // The form stays open, as it does for any other failure, so the alert is not + // presented while a sheet is dismissing - it would be swallowed. + await MainActor.run { + showAlert(NSLocalizedString("Error changing chat profile", comment: "alert title")) + } + return + } + await MainActor.run { showAddProfile = false } + changeProfile(newUser) + } + private func changeProfile(_ newUser: User) { Task { do { @@ -215,6 +293,10 @@ struct ContextProfilePickerView: View { } do { try await changeActiveUserAsync_(newUser.userId, viewPwd: nil, keepingChatId: chat.id) + // Reopen the chat under the new profile: keepingChatId only preserves + // its place in the reloaded list, so without this the switch lands on + // the chat list rather than the invitation it was chosen for. + await MainActor.run { chatModel.chatId = chat.id } } catch { await MainActor.run { showAlert(