mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-28 09:44:15 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user