diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 729556e73..6ff3af66a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -9,6 +9,18 @@ import SwiftUI import SimpleXChat +enum GroupProfileAlert: Identifiable { + case saveError(err: String) + case invalidName(validName: String) + + var id: String { + switch self { + case let .saveError(err): return "saveError \(err)" + case let .invalidName(validName): return "invalidName \(validName)" + } + } +} + struct GroupProfileView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @@ -18,8 +30,7 @@ struct GroupProfileView: View { @State private var showImagePicker = false @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil - @State private var showSaveErrorAlert = false - @State private var saveGroupError: String? = nil + @State private var alert: GroupProfileAlert? @FocusState private var focusDisplayName var body: some View { @@ -47,20 +58,29 @@ struct GroupProfileView: View { .frame(maxWidth: .infinity, alignment: .center) VStack(alignment: .leading) { - ZStack(alignment: .leading) { - if !validDisplayName(groupProfile.displayName) { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.bottom, 10) + ZStack(alignment: .topLeading) { + if !validNewProfileName() { + Button { + alert = .invalidName(validName: mkValidName(groupProfile.displayName)) + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "exclamationmark.circle").foregroundColor(.clear) } profileNameTextEdit("Group display name", $groupProfile.displayName) .focused($focusDisplayName) } - profileNameTextEdit("Group full name (optional)", $groupProfile.fullName) + .padding(.bottom) + let fullName = groupInfo.groupProfile.fullName + if fullName != "" && fullName != groupProfile.displayName { + profileNameTextEdit("Group full name (optional)", $groupProfile.fullName) + .padding(.bottom) + } HStack(spacing: 20) { Button("Cancel") { dismiss() } Button("Save group profile") { saveProfile() } - .disabled(groupProfile.displayName == "" || !validDisplayName(groupProfile.displayName)) + .disabled(groupProfile.displayName == "" || !validNewProfileName()) } } .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) @@ -99,27 +119,35 @@ struct GroupProfileView: View { focusDisplayName = true } } - .alert(isPresented: $showSaveErrorAlert) { - Alert( - title: Text("Error saving group profile"), - message: Text("\(saveGroupError ?? "Unexpected error")") - ) + .alert(item: $alert) { a in + switch a { + case let .saveError(err): + return Alert( + title: Text("Error saving group profile"), + message: Text(err) + ) + case let .invalidName(name): + return createInvalidNameAlert(name, $groupProfile.displayName) + } } .contentShape(Rectangle()) .onTapGesture { hideKeyboard() } } + private func validNewProfileName() -> Bool { + groupProfile.displayName == groupInfo.groupProfile.displayName + || validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces)) + } + func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding) -> some View { TextField(label, text: name) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.bottom) - .padding(.leading, 28) + .padding(.leading, 32) } func saveProfile() { Task { do { + groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces) let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile) await MainActor.run { groupInfo = gInfo @@ -128,8 +156,7 @@ struct GroupProfileView: View { } } catch let error { let err = responseError(error) - saveGroupError = err - showSaveErrorAlert = true + alert = .saveError(err: err) logger.error("GroupProfile apiUpdateGroup error: \(err)") } } diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 8df37bb56..5cea52cc8 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -16,11 +16,11 @@ struct AddGroupView: View { @State private var groupInfo: GroupInfo? @State private var profile = GroupProfile(displayName: "", fullName: "") @FocusState private var focusDisplayName - @FocusState private var focusFullName @State private var showChooseSource = false @State private var showImagePicker = false @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil + @State private var showInvalidNameAlert = false var body: some View { if let chat = chat, let groupInfo = groupInfo { @@ -76,26 +76,24 @@ struct AddGroupView: View { .padding(.bottom, 4) ZStack(alignment: .topLeading) { - if !validDisplayName(profile.displayName) { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.top, 4) + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + if name != mkValidName(name) { + Button { + showInvalidNameAlert = true + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "exclamationmark.circle").foregroundColor(.clear) } - textField("Group display name", text: $profile.displayName) + textField("Enter group name…", text: $profile.displayName) .focused($focusDisplayName) - .submitLabel(.next) + .submitLabel(.go) .onSubmit { - if canCreateProfile() { focusFullName = true } - else { focusDisplayName = true } + if canCreateProfile() { createGroup() } } } - textField("Group full name (optional)", text: $profile.fullName) - .focused($focusFullName) - .submitLabel(.go) - .onSubmit { - if canCreateProfile() { createGroup() } - else { focusFullName = true } - } + .padding(.bottom) Spacer() @@ -133,6 +131,9 @@ struct AddGroupView: View { didSelectItem in showImagePicker = false } } + .alert(isPresented: $showInvalidNameAlert) { + createInvalidNameAlert(mkValidName(profile.displayName), $profile.displayName) + } .onChange(of: chosenImage) { image in if let image = image { profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500) @@ -146,15 +147,13 @@ struct AddGroupView: View { func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { TextField(placeholder, text: text) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.leading, 28) - .padding(.bottom) + .padding(.leading, 32) } func createGroup() { hideKeyboard() do { + profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) let gInfo = try apiNewGroup(profile) Task { let groupMembers = await apiListMembers(gInfo.groupId) @@ -180,7 +179,7 @@ struct AddGroupView: View { } func canCreateProfile() -> Bool { - profile.displayName != "" && validDisplayName(profile.displayName) + profile.displayName != "" && validDisplayName(profile.displayName.trimmingCharacters(in: .whitespaces)) } } diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index d05ac4458..f5db37dac 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -9,175 +9,244 @@ import SwiftUI import SimpleXChat +enum UserProfileAlert: Identifiable { + case duplicateUserError + case createUserError(error: LocalizedStringKey) + case invalidNameError(validName: String) + + var id: String { + switch self { + case .duplicateUserError: return "duplicateUserError" + case .createUserError: return "createUserError" + case let .invalidNameError(validName): return "invalidNameError \(validName)" + } + } +} + struct CreateProfile: View { + @Environment(\.dismiss) var dismiss + @State private var displayName: String = "" + @FocusState private var focusDisplayName + @State private var alert: UserProfileAlert? + + var body: some View { + List { + Section { + TextField("Enter your name…", text: $displayName) + .focused($focusDisplayName) + Button { + createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss) + } label: { + Label("Create profile", systemImage: "checkmark") + } + .disabled(!canCreateProfile(displayName)) + } header: { + HStack { + Text("Your profile") + let name = displayName.trimmingCharacters(in: .whitespaces) + let validName = mkValidName(name) + if name != validName { + Spacer() + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + .onTapGesture { + alert = .invalidNameError(validName: validName) + } + } + } + .frame(height: 20) + } footer: { + VStack(alignment: .leading, spacing: 8) { + Text("Your profile, contacts and delivered messages are stored on your device.") + Text("The profile is only shared with your contacts.") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .navigationTitle("Create your profile") + .alert(item: $alert) { a in userProfileAlert(a, $displayName) } + .onAppear() { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + focusDisplayName = true + } + } + .keyboardPadding() + } +} + +struct CreateFirstProfile: View { @EnvironmentObject var m: ChatModel @Environment(\.dismiss) var dismiss @State private var displayName: String = "" - @State private var fullName: String = "" @FocusState private var focusDisplayName - @FocusState private var focusFullName - @State private var alert: CreateProfileAlert? - - private enum CreateProfileAlert: Identifiable { - case duplicateUserError - case createUserError(error: LocalizedStringKey) - - var id: String { - switch self { - case .duplicateUserError: return "duplicateUserError" - case .createUserError: return "createUserError" - } - } - } var body: some View { VStack(alignment: .leading) { - Text("Create your profile") - .font(.largeTitle) - .bold() - .padding(.bottom, 4) - .frame(maxWidth: .infinity) - Text("Your profile, contacts and delivered messages are stored on your device.") - .padding(.bottom, 4) - Text("The profile is only shared with your contacts.") - .padding(.bottom) + Group { + Text("Create your profile") + .font(.largeTitle) + .bold() + Text("Your profile, contacts and delivered messages are stored on your device.") + .foregroundColor(.secondary) + Text("The profile is only shared with your contacts.") + .foregroundColor(.secondary) + .padding(.bottom) + } + .padding(.bottom) + ZStack(alignment: .topLeading) { - if !validDisplayName(displayName) { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.top, 4) + let name = displayName.trimmingCharacters(in: .whitespaces) + let validName = mkValidName(name) + if name != validName { + Button { + showAlert(.invalidNameError(validName: validName)) + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "exclamationmark.circle").foregroundColor(.clear) } - textField("Display name", text: $displayName) + TextField("Enter your name…", text: $displayName) .focused($focusDisplayName) - .submitLabel(.next) - .onSubmit { - if canCreateProfile() { focusFullName = true } - else { focusDisplayName = true } - } + .padding(.leading, 32) } - textField("Full name (optional)", text: $fullName) - .focused($focusFullName) - .submitLabel(.go) - .onSubmit { - if canCreateProfile() { createProfile() } - else { focusFullName = true } - } - + .padding(.bottom) Spacer() - - HStack { - if m.users.isEmpty { - Button { - hideKeyboard() - withAnimation { - m.onboardingStage = .step1_SimpleXInfo - } - } label: { - HStack { - Image(systemName: "lessthan") - Text("About SimpleX") - } - } - } - - Spacer() - - HStack { - Button { - createProfile() - } label: { - Text("Create") - Image(systemName: "greaterthan") - } - .disabled(!canCreateProfile()) - } - } + onboardingButtons() } .onAppear() { focusDisplayName = true setLastVersionDefault() } - .alert(item: $alert) { a in - switch a { - case .duplicateUserError: return duplicateUserAlert - case let .createUserError(err): return creatUserErrorAlert(err) - } - } .padding() + .frame(maxWidth: .infinity, alignment: .leading) .keyboardPadding() } - func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { - TextField(placeholder, text: text) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.leading, 28) - .padding(.bottom) - } - - func createProfile() { - hideKeyboard() - let profile = Profile( - displayName: displayName, - fullName: fullName - ) - do { - m.currentUser = try apiCreateActiveUser(profile) - if m.users.isEmpty { - try startChat() + func onboardingButtons() -> some View { + HStack { + Button { + hideKeyboard() withAnimation { - onboardingStageDefault.set(.step3_CreateSimpleXAddress) - m.onboardingStage = .step3_CreateSimpleXAddress + m.onboardingStage = .step1_SimpleXInfo } - } else { - onboardingStageDefault.set(.onboardingComplete) - m.onboardingStage = .onboardingComplete - dismiss() - m.users = try listUsers() - try getUserChatData() - } - } catch let error { - switch error as? ChatResponse { - case .chatCmdError(_, .errorStore(.duplicateName)), - .chatCmdError(_, .error(.userExists)): - if m.currentUser == nil { - AlertManager.shared.showAlert(duplicateUserAlert) - } else { - alert = .duplicateUserError - } - default: - let err: LocalizedStringKey = "Error: \(responseError(error))" - if m.currentUser == nil { - AlertManager.shared.showAlert(creatUserErrorAlert(err)) - } else { - alert = .createUserError(error: err) + } label: { + HStack { + Image(systemName: "lessthan") + Text("About SimpleX") } } - logger.error("Failed to create user or start chat: \(responseError(error))") + + Spacer() + + Button { + createProfile(displayName, showAlert: showAlert, dismiss: dismiss) + } label: { + HStack { + Text("Create") + Image(systemName: "greaterthan") + } + } + .disabled(!canCreateProfile(displayName)) } } - func canCreateProfile() -> Bool { - displayName != "" && validDisplayName(displayName) - } - - private var duplicateUserAlert: Alert { - Alert( - title: Text("Duplicate display name!"), - message: Text("You already have a chat profile with the same display name. Please choose another name.") - ) - } - - private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert { - Alert( - title: Text("Error creating profile!"), - message: Text(err) - ) + private func showAlert(_ alert: UserProfileAlert) { + AlertManager.shared.showAlert(userProfileAlert(alert, $displayName)) } } +private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) { + hideKeyboard() + let profile = Profile( + displayName: displayName.trimmingCharacters(in: .whitespaces), + fullName: "" + ) + let m = ChatModel.shared + do { + m.currentUser = try apiCreateActiveUser(profile) + if m.users.isEmpty { + try startChat() + withAnimation { + onboardingStageDefault.set(.step3_CreateSimpleXAddress) + m.onboardingStage = .step3_CreateSimpleXAddress + } + } else { + onboardingStageDefault.set(.onboardingComplete) + m.onboardingStage = .onboardingComplete + dismiss() + m.users = try listUsers() + try getUserChatData() + } + } catch let error { + switch error as? ChatResponse { + case .chatCmdError(_, .errorStore(.duplicateName)), + .chatCmdError(_, .error(.userExists)): + if m.currentUser == nil { + AlertManager.shared.showAlert(duplicateUserAlert) + } else { + showAlert(.duplicateUserError) + } + default: + let err: LocalizedStringKey = "Error: \(responseError(error))" + if m.currentUser == nil { + AlertManager.shared.showAlert(creatUserErrorAlert(err)) + } else { + showAlert(.createUserError(error: err)) + } + } + logger.error("Failed to create user or start chat: \(responseError(error))") + } +} + +private func canCreateProfile(_ displayName: String) -> Bool { + let name = displayName.trimmingCharacters(in: .whitespaces) + return name != "" && mkValidName(name) == name +} + +func userProfileAlert(_ alert: UserProfileAlert, _ displayName: Binding) -> Alert { + switch alert { + case .duplicateUserError: return duplicateUserAlert + case let .createUserError(err): return creatUserErrorAlert(err) + case let .invalidNameError(name): return createInvalidNameAlert(name, displayName) + } +} + +private var duplicateUserAlert: Alert { + Alert( + title: Text("Duplicate display name!"), + message: Text("You already have a chat profile with the same display name. Please choose another name.") + ) +} + +private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert { + Alert( + title: Text("Error creating profile!"), + message: Text(err) + ) +} + +func createInvalidNameAlert(_ name: String, _ displayName: Binding) -> Alert { + name == "" + ? Alert(title: Text("Invalid name!")) + : Alert( + title: Text("Invalid name!"), + message: Text("Correct name to \(name)?"), + primaryButton: .default( + Text("Ok"), + action: { displayName.wrappedValue = name } + ), + secondaryButton: .cancel() + ) +} + func validDisplayName(_ name: String) -> Bool { - name.firstIndex(of: " ") == nil && name.first != "@" && name.first != "#" + mkValidName(name.trimmingCharacters(in: .whitespaces)) == name +} + +func mkValidName(_ s: String) -> String { + var c = s.cString(using: .utf8)! + return fromCString(chat_valid_name(&c)!) } struct CreateProfile_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index b0734be64..438491b5f 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -14,7 +14,7 @@ struct OnboardingView: View { var body: some View { switch onboarding { case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) - case .step2_CreateProfile: CreateProfile() + case .step2_CreateProfile: CreateFirstProfile() case .step3_CreateSimpleXAddress: CreateSimpleXAddress() case .step4_SetNotificationsMode: SetNotificationsMode() case .onboardingComplete: EmptyView() diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index d6681a51c..f076a4eb7 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -381,7 +381,9 @@ struct ProfilePreview: View { Text(profileOf.displayName) .fontWeight(.bold) .font(.title2) - Text(profileOf.fullName) + if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName { + Text(profileOf.fullName) + } } } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index f38dc593a..88be0b6a7 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -17,6 +17,8 @@ struct UserProfile: View { @State private var showImagePicker = false @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil + @State private var alert: UserProfileAlert? + @FocusState private var focusDisplayName var body: some View { let user: User = chatModel.currentUser! @@ -47,18 +49,27 @@ struct UserProfile: View { VStack(alignment: .leading) { ZStack(alignment: .leading) { - if !validDisplayName(profile.displayName) { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .padding(.bottom, 10) + if !validNewProfileName(user) { + Button { + alert = .invalidNameError(validName: mkValidName(profile.displayName)) + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "exclamationmark.circle").foregroundColor(.clear) } - profileNameTextEdit("Display name", $profile.displayName) + profileNameTextEdit("Profile name", $profile.displayName) + .focused($focusDisplayName) + } + .padding(.bottom) + if showFullName(user) { + profileNameTextEdit("Full name (optional)", $profile.fullName) + .padding(.bottom) } - profileNameTextEdit("Full name (optional)", $profile.fullName) HStack(spacing: 20) { Button("Cancel") { editProfile = false } Button("Save (and notify contacts)") { saveProfile() } - .disabled(profile.displayName == "" || !validDisplayName(profile.displayName)) + .disabled(!canSaveProfile(user)) } } .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) @@ -74,11 +85,14 @@ struct UserProfile: View { .frame(maxWidth: .infinity, alignment: .center) VStack(alignment: .leading) { - profileNameView("Display name:", user.profile.displayName) - profileNameView("Full name:", user.profile.fullName) + profileNameView("Profile name:", user.profile.displayName) + if showFullName(user) { + profileNameView("Full name:", user.profile.fullName) + } Button("Edit") { profile = fromLocalProfile(user.profile) editProfile = true + focusDisplayName = true } } .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) @@ -117,14 +131,12 @@ struct UserProfile: View { profile.image = nil } } + .alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) } } func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding) -> some View { TextField(label, text: name) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.bottom) - .padding(.leading, 28) + .padding(.leading, 32) } func profileNameView(_ label: LocalizedStringKey, _ name: String) -> some View { @@ -141,9 +153,22 @@ struct UserProfile: View { showChooseSource = true } + private func validNewProfileName(_ user: User) -> Bool { + profile.displayName == user.profile.displayName || validDisplayName(profile.displayName.trimmingCharacters(in: .whitespaces)) + } + + private func showFullName(_ user: User) -> Bool { + user.profile.fullName != "" && user.profile.fullName != user.profile.displayName + } + + private func canSaveProfile(_ user: User) -> Bool { + profile.displayName != "" && validNewProfileName(user) + } + func saveProfile() { Task { do { + profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) if let (newProfile, _) = try await apiUpdateProfile(profile: profile) { DispatchQueue.main.async { chatModel.updateCurrentUser(newProfile) diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 67c2fa728..9db3f06ae 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -23,6 +23,7 @@ extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); extern char *chat_parse_markdown(char *str); extern char *chat_parse_server(char *str); extern char *chat_password_hash(char *pwd, char *salt); +extern char *chat_valid_name(char *name); extern char *chat_encrypt_media(char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len);