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.
This commit is contained in:
Narasimha-sc
2026-09-05 11:27:56 +00:00
parent 7ca92217ab
commit d97fe5fd0c
6 changed files with 112 additions and 8 deletions
+4 -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)
@@ -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 {
+2 -2
View File
@@ -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
}
@@ -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<User>(\.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 {
@@ -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)
}
}
}
}
@@ -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)
+1 -1
View File
@@ -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) |