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.
This commit is contained in:
Narasimha-sc
2026-08-03 16:12:10 +00:00
parent a570d56ece
commit 9a69662970
3 changed files with 102 additions and 5 deletions
+7 -3
View File
@@ -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 {
+12 -1
View File
@@ -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
}
@@ -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<User>(\.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(