diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 729556e73..7e123c389 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(!canUpdateProfile()) } } .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) @@ -99,27 +119,39 @@ 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 canUpdateProfile() -> Bool { + groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName() + } + + 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 +160,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..186a24e99 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,8 @@ struct AddGroupView: View { } func canCreateProfile() -> Bool { - profile.displayName != "" && validDisplayName(profile.displayName) + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + return name != "" && validDisplayName(name) } } 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..b1a362a5a 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.trimmingCharacters(in: .whitespaces) != "" && 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); diff --git a/apps/multiplatform/.gitignore b/apps/multiplatform/.gitignore index 81d296183..f30061200 100644 --- a/apps/multiplatform/.gitignore +++ b/apps/multiplatform/.gitignore @@ -11,7 +11,6 @@ local.properties common/src/commonMain/cpp/android/libs/ common/src/commonMain/cpp/desktop/libs/ -desktop/src/jvmMain/resources/libs/ android/build android/release common/build diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 10faa1a82..1bc965849 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -50,6 +50,7 @@ actual fun PlatformTextField( userIsObserver: Boolean, onMessageChange: (String) -> Unit, onUpArrow: () -> Unit, + onFilesPasted: (List) -> Unit, onDone: () -> Unit, ) { val cs = composeState.value diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index 8bb70c4a0..3aa4a9261 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -5,6 +5,8 @@ import android.os.Build import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem @@ -41,3 +43,7 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL showMenu.value = false }) } + +actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { + clipboard.setText(AnnotatedString(cItem.content.text)) +} diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index dbfee7025..351ed93c9 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -46,6 +46,7 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); +extern char *chat_valid_name(const char *name); extern char *chat_write_file(const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_encrypt_file(const char *from_path, const char *to_path); @@ -121,6 +122,14 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) { + const char *_name = (*env)->GetStringUTFChars(env, name, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_valid_name(_name)); + (*env)->ReleaseStringUTFChars(env, name, _name); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt index e4fddeeb7..b304800a3 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt @@ -54,12 +54,11 @@ add_library( # Sets the name of the library. simplex-api.c) add_library( simplex SHARED IMPORTED ) -# Lib has different name because of version, find it -FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT}) - if(WIN32) + FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT}) set_target_properties( simplex PROPERTIES IMPORTED_IMPLIB ${SIMPLEXLIB}) else() + FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex-chat*.${OS_LIB_EXT}) set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB}) endif() @@ -72,7 +71,7 @@ if(NOT APPLE) else() # Without direct linking it can't find hs_init in linking step add_library( rts SHARED IMPORTED ) - FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/deps/libHSrts_thr-*.${OS_LIB_EXT}) + FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/deps/libHSrts*_thr-*.${OS_LIB_EXT}) set_target_properties( rts PROPERTIES IMPORTED_LOCATION ${RTSLIB}) target_link_libraries(app-lib rts simplex) diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index ddc5c92f9..f36c86c36 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -21,6 +21,7 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); +extern char *chat_valid_name(const char *name); extern char *chat_write_file(const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_encrypt_file(const char *from_path, const char *to_path); @@ -75,7 +76,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, jclass cla jstring res = decode_to_utf8_string(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl)); (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath); (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey); - (*env)->ReleaseStringUTFChars(env, dbKey, _confirm); + (*env)->ReleaseStringUTFChars(env, confirm, _confirm); // Creating array of Object's (boxed values can be passed, eg. Long instead of long) jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); @@ -133,6 +134,14 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass cl return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) { + const char *_name = encode_to_utf8_chars(env, name); + jstring res = decode_to_utf8_string(env, chat_valid_name(_name)); + (*env)->ReleaseStringUTFChars(env, name, _name); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { const char *_path = encode_to_utf8_chars(env, path); diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index c08ad5f91..4531f88f9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -17,6 +17,7 @@ import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.CreateFirstProfile import chat.simplex.common.views.helpers.SimpleButton import chat.simplex.common.views.SplashView import chat.simplex.common.views.call.ActiveCallView @@ -135,7 +136,7 @@ fun MainScreen() { ModalManager.fullscreen.showInView() } } - onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {} + onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 801a0270e..2bed24b1f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -20,6 +20,7 @@ external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String external fun chatParseMarkdown(str: String): String external fun chatParseServer(str: String): String external fun chatPasswordHash(pwd: String, salt: String): String +external fun chatValidName(name: String): String external fun chatWriteFile(path: String, buffer: ByteBuffer): String external fun chatReadFile(path: String, key: String, nonce: String): Array external fun chatEncryptFile(fromPath: String, toPath: String): String diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt index 95b6a73ca..fa99d0f93 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.text.TextStyle import chat.simplex.common.views.chat.ComposeState +import java.io.File +import java.net.URI @Composable expect fun PlatformTextField( @@ -14,5 +16,6 @@ expect fun PlatformTextField( userIsObserver: Boolean, onMessageChange: (String) -> Unit, onUpArrow: () -> Unit, + onFilesPasted: (List) -> Unit, onDone: () -> Unit, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index c4f2a2cbd..a471b5645 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -97,6 +97,7 @@ fun TerminalLayout( updateLiveMessage = null, editPrevMessage = {}, onMessageChange = ::onMessageChange, + onFilesPasted = {}, textStyle = textStyle ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 13ce16d0a..504ecac89 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -1,10 +1,10 @@ package chat.simplex.common.views +import SectionTextFooter import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* import androidx.compose.material.MaterialTheme.colors import androidx.compose.runtime.* @@ -18,115 +18,160 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatModel import chat.simplex.common.model.Profile -import chat.simplex.common.platform.appPlatform -import chat.simplex.common.platform.navigationBarsWithImePadding +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.common.views.onboarding.ReadableText +import chat.simplex.common.views.onboarding.* +import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged - -fun isValidDisplayName(name: String) : Boolean { - return (name.firstOrNull { it.isWhitespace() }) == null && !name.startsWith("@") && !name.startsWith("#") -} +import kotlinx.coroutines.launch @Composable -fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) { - val displayName = rememberSaveable { mutableStateOf("") } - val fullName = rememberSaveable { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } +fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val keyboardState by getKeyboardState() + var savedKeyboardState by remember { mutableStateOf(keyboardState) } - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) - ) { - /*CloseSheetBar(close = { - if (chatModel.users.isEmpty()) { - chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo - } else { - close() - } - })*/ - Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { - AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING) - ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body1) - ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Center, style = MaterialTheme.typography.body1) - Spacer(Modifier.height(DEFAULT_PADDING)) - Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text( - stringResource(MR.strings.display_name), - fontSize = 16.sp - ) - if (!isValidDisplayName(displayName.value)) { - Text( - stringResource(MR.strings.no_spaces), - fontSize = 16.sp, - color = Color.Red - ) - } - } - ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester) - Spacer(Modifier.height(DEFAULT_PADDING)) - Text( - stringResource(MR.strings.full_name_optional__prompt), - fontSize = 16.sp, - modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) - ) - ProfileNameField(fullName, "") - } - Spacer(Modifier.fillMaxHeight().weight(1f)) - Row { - if (chatModel.users.isEmpty()) { - SimpleButtonDecorated( - text = stringResource(MR.strings.about_simplex), - icon = painterResource(MR.images.ic_arrow_back_ios_new), - textDecoration = TextDecoration.None, - fontWeight = FontWeight.Medium - ) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) } - } - Spacer(Modifier.fillMaxWidth().weight(1f)) - val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) - val createModifier: Modifier - val createColor: Color - if (enabled) { - createModifier = Modifier.clickable { - if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { - createProfileInProfiles(chatModel, displayName.value, fullName.value, close) - } else { - createProfileOnboarding(chatModel, displayName.value, fullName.value, close) + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 20.dp) + ) { + val displayName = rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + ) { + Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING) + Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + stringResource(MR.strings.display_name), + fontSize = 16.sp + ) + val name = displayName.value.trim() + val validName = mkValidName(name) + Spacer(Modifier.height(20.dp)) + if (name != validName) { + IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } + } + } + ProfileNameField(displayName, "", { it.trim() == mkValidName(it) }, focusRequester) + } + SettingsActionItem( + painterResource(MR.images.ic_check), + stringResource(MR.strings.create_another_profile_button), + disabled = !canCreateProfile(displayName.value), + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + click = { createProfileInProfiles(chatModel, displayName.value, close) }, + ) + SectionTextFooter(generalGetString(MR.strings.your_profile_is_stored_on_your_device)) + SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts)) + + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } + } + if (savedKeyboardState != keyboardState) { + LaunchedEffect(keyboardState) { + scope.launch { + savedKeyboardState = keyboardState + scrollState.animateScrollTo(scrollState.maxValue) } - }.padding(8.dp) - createColor = MaterialTheme.colors.primary - } else { - createModifier = Modifier.padding(8.dp) - createColor = MaterialTheme.colors.secondary - } - Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) { - Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium) - Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = createColor) } } - } - - LaunchedEffect(Unit) { - delay(300) - focusRequester.requestFocus() } } } -fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { +@Composable +fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) { + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val keyboardState by getKeyboardState() + var savedKeyboardState by remember { mutableStateOf(keyboardState) } + + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 20.dp) + ) { + val displayName = rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + ) { + /*CloseSheetBar(close = { + if (chatModel.users.isEmpty()) { + chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + } else { + close() + } + })*/ + Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING) + ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary)) + ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Center, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary)) + Spacer(Modifier.height(DEFAULT_PADDING)) + Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + stringResource(MR.strings.display_name), + fontSize = 16.sp + ) + val name = displayName.value.trim() + val validName = mkValidName(name) + Spacer(Modifier.height(20.dp)) + if (name != validName) { + IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } + } + } + ProfileNameField(displayName, "", { it.trim() == mkValidName(it) }, focusRequester) + } + Spacer(Modifier.fillMaxHeight().weight(1f)) + OnboardingButtons(displayName, close) + + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } + } + LaunchedEffect(Unit) { + setLastVersionDefault(chatModel) + } + if (savedKeyboardState != keyboardState) { + LaunchedEffect(keyboardState) { + scope.launch { + savedKeyboardState = keyboardState + scrollState.animateScrollTo(scrollState.maxValue) + } + } + } + } + } +} + +fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { val user = chatModel.controller.apiCreateActiveUser( - Profile(displayName, fullName, null) + Profile(displayName.trim(), "", null) ) ?: return@withApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { @@ -142,10 +187,10 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName: } } -fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { +fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { chatModel.controller.apiCreateActiveUser( - Profile(displayName, fullName, null) + Profile(displayName.trim(), "", null) ) ?: return@withApi val onboardingStage = chatModel.controller.appPrefs.onboardingStage if (chatModel.users.isEmpty()) { @@ -163,6 +208,28 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName: } } +@Composable +fun OnboardingButtons(displayName: MutableState, close: () -> Unit) { + Row { + SimpleButtonDecorated( + text = stringResource(MR.strings.about_simplex), + icon = painterResource(MR.images.ic_arrow_back_ios_new), + textDecoration = TextDecoration.None, + fontWeight = FontWeight.Medium + ) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) } + Spacer(Modifier.fillMaxWidth().weight(1f)) + val enabled = canCreateProfile(displayName.value) + val createModifier: Modifier = Modifier.clickable(enabled) { createProfileOnboarding(chatModel, displayName.value, close) }.padding(8.dp) + val createColor: Color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) { + Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium) + Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = createColor) + } + } + } +} + @Composable fun ProfileNameField(name: MutableState, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) { var valid by rememberSaveable { mutableStateOf(true) } @@ -195,10 +262,6 @@ fun ProfileNameField(name: MutableState, placeholder: String = "", isVal onValueChange = { name.value = it }, modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester), textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrect = false - ), singleLine = true, cursorBrush = SolidColor(MaterialTheme.colors.secondary) ) @@ -211,3 +274,28 @@ fun ProfileNameField(name: MutableState, placeholder: String = "", isVal } } } + +private fun canCreateProfile(displayName: String): Boolean { + val name = displayName.trim() + return name.isNotEmpty() && mkValidName(name) == name +} + +fun showInvalidNameAlert(name: String, displayName: MutableState) { + if (name.isEmpty()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_name), + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.invalid_name), + text = generalGetString(MR.strings.correct_name_to).format(name), + onConfirm = { + displayName.value = name + } + ) + } +} + +fun isValidDisplayName(name: String) : Boolean = mkValidName(name.trim()) == name + +fun mkValidName(s: String): String = chatValidName(s) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index ee9e109a7..4afcdacbd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -458,17 +458,7 @@ fun ChatLayout( .fillMaxWidth() .desktopOnExternalDrag( enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value, - onFiles = { paths -> - val uris = paths.map { URI.create(it) } - val groups = uris.groupBy { isImage(it) } - val images = groups[true] ?: emptyList() - val files = groups[false] ?: emptyList() - if (images.isNotEmpty()) { - CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(images, null) } - } else if (files.isNotEmpty()) { - composeState.processPickedFile(uris.first(), null) - } - }, + onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) }, onImage = { val tmpFile = File.createTempFile("image", ".bmp", tmpDir) tmpFile.deleteOnExit() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index c6e6ca7b7..972bc6621 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -159,6 +159,17 @@ expect fun AttachmentSelection( processPickedMedia: (List, String?) -> Unit ) +fun MutableState.onFilesAttached(uris: List) { + val groups = uris.groupBy { isImage(it) } + val images = groups[true] ?: emptyList() + val files = groups[false] ?: emptyList() + if (images.isNotEmpty()) { + CoroutineScope(Dispatchers.IO).launch { processPickedMedia(images, null) } + } else if (files.isNotEmpty()) { + processPickedFile(uris.first(), null) + } +} + fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { val fileSize = getFileSize(uri) @@ -816,6 +827,7 @@ fun ComposeView( chatModel.removeLiveDummy() }, editPrevMessage = ::editPrevMessage, + onFilesPasted = { composeState.onFilesAttached(it) }, onMessageChange = ::onMessageChange, textStyle = textStyle ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 2d696b778..28882e6b7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -29,6 +29,8 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* +import java.io.File +import java.net.URI @Composable fun SendMsgView( @@ -52,6 +54,7 @@ fun SendMsgView( updateLiveMessage: (suspend () -> Unit)? = null, cancelLiveMessage: (() -> Unit)? = null, editPrevMessage: () -> Unit, + onFilesPasted: (List) -> Unit, onMessageChange: (String) -> Unit, textStyle: MutableState ) { @@ -79,7 +82,7 @@ fun SendMsgView( val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } - PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage) { + PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) { if (!cs.inProgress) { sendMessage(null) } @@ -612,6 +615,7 @@ fun PreviewSendMsgView() { sendMessage = {}, editPrevMessage = {}, onMessageChange = { _ -> }, + onFilesPasted = {}, textStyle = textStyle ) } @@ -645,6 +649,7 @@ fun PreviewSendMsgViewEditing() { sendMessage = {}, editPrevMessage = {}, onMessageChange = { _ -> }, + onFilesPasted = {}, textStyle = textStyle ) } @@ -678,6 +683,7 @@ fun PreviewSendMsgViewInProgress() { sendMessage = {}, editPrevMessage = {}, onMessageChange = { _ -> }, + onFilesPasted = {}, textStyle = textStyle ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index ac13dd483..5376cb092 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -19,12 +19,12 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.ProfileNameField +import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.isValidDisplayName import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.net.URI @@ -65,13 +65,13 @@ fun GroupProfileLayout( fullName.value == groupProfile.fullName && groupProfile.image == profileImage.value val closeWithAlert = { - if (dataUnchanged || !(displayName.value.isNotEmpty() && isValidDisplayName(displayName.value))) { + if (dataUnchanged || !canUpdateProfile(displayName.value, groupProfile)) { close() } else { showUnsavedChangesAlert({ saveProfile( groupProfile.copy( - displayName = displayName.value, + displayName = displayName.value.trim(), fullName = fullName.value, image = profileImage.value ) @@ -125,32 +125,32 @@ fun GroupProfileLayout( stringResource(MR.strings.group_display_name_field), fontSize = 16.sp ) - if (!isValidDisplayName(displayName.value)) { + if (!isValidNewProfileName(displayName.value, groupProfile)) { Spacer(Modifier.size(DEFAULT_PADDING_HALF)) - Text( - stringResource(MR.strings.no_spaces), - fontSize = 16.sp, - color = Color.Red - ) + IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } } } - ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester) + ProfileNameField(displayName, "", { isValidNewProfileName(it, groupProfile) }, focusRequester) + if (groupProfile.fullName.isNotEmpty() && groupProfile.fullName != groupProfile.displayName) { + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(MR.strings.group_full_name_field), + fontSize = 16.sp, + modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) + ) + ProfileNameField(fullName) + } Spacer(Modifier.height(DEFAULT_PADDING)) - Text( - stringResource(MR.strings.group_full_name_field), - fontSize = 16.sp, - modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) - ) - ProfileNameField(fullName) - Spacer(Modifier.height(DEFAULT_PADDING)) - val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) + val enabled = !dataUnchanged && canUpdateProfile(displayName.value, groupProfile) if (enabled) { Text( stringResource(MR.strings.save_group_profile), modifier = Modifier.clickable { saveProfile( groupProfile.copy( - displayName = displayName.value, + displayName = displayName.value.trim(), fullName = fullName.value, image = profileImage.value ) @@ -178,6 +178,12 @@ fun GroupProfileLayout( } } +private fun canUpdateProfile(displayName: String, groupProfile: GroupProfile): Boolean = + displayName.trim().isNotEmpty() && isValidNewProfileName(displayName, groupProfile) + +private fun isValidNewProfileName(displayName: String, groupProfile: GroupProfile): Boolean = + displayName == groupProfile.displayName || isValidDisplayName(displayName.trim()) + private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(MR.strings.save_preferences_question), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index b5236e249..dd07a3fc1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -201,7 +201,7 @@ fun ChatItemView( showMenu.value = false }) ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - clipboard.setText(AnnotatedString(cItem.content.text)) + copyItemToClipboard(cItem, clipboard) showMenu.value = false }) if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { @@ -561,6 +561,8 @@ private fun showMsgDeliveryErrorAlert(description: String) { ) } +expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) + @Preview @Composable fun PreviewChatItemView() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 3ab5ef9b2..6ad919c27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -19,15 +19,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.ProfileNameField import chat.simplex.common.views.chat.group.AddGroupMembersView import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.isValidDisplayName import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.DeleteImageButton import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.platform.* +import chat.simplex.common.views.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -60,7 +59,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val displayName = rememberSaveable { mutableStateOf("") } - val fullName = rememberSaveable { mutableStateOf("") } val chosenImage = rememberSaveable { mutableStateOf(null) } val profileImage = rememberSaveable { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } @@ -110,31 +108,22 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { stringResource(MR.strings.group_display_name_field), fontSize = 16.sp ) - if (!isValidDisplayName(displayName.value)) { + if (!isValidDisplayName(displayName.value.trim())) { Spacer(Modifier.size(DEFAULT_PADDING_HALF)) - Text( - stringResource(MR.strings.no_spaces), - fontSize = 16.sp, - color = Color.Red - ) + IconButton({ showInvalidNameAlert(mkValidName(displayName.value.trim()), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } } } - ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester) - Spacer(Modifier.height(DEFAULT_PADDING)) - Text( - stringResource(MR.strings.group_full_name_field), - fontSize = 16.sp, - modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) - ) - ProfileNameField(fullName, "") + ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) Spacer(Modifier.height(8.dp)) - val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) + val enabled = canCreateProfile(displayName.value) if (enabled) { CreateGroupButton(MaterialTheme.colors.primary, Modifier .clickable { createGroup(GroupProfile( - displayName = displayName.value, - fullName = fullName.value, + displayName = displayName.value.trim(), + fullName = "", image = profileImage.value )) } @@ -167,6 +156,8 @@ fun CreateGroupButton(color: Color, modifier: Modifier) { } } +fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmpty() && isValidDisplayName(displayName.trim()) + @Preview @Composable fun PreviewAddGroupLayout() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index 119ed8cd4..0a48bbd1e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -1,16 +1,5 @@ package chat.simplex.common.views.onboarding -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.platform.ProvideWindowInsets -import chat.simplex.common.views.CreateProfilePanel -import chat.simplex.common.platform.getKeyboardState -import kotlinx.coroutines.launch - enum class OnboardingStage { Step1_SimpleXInfo, Step2_CreateProfile, @@ -19,32 +8,3 @@ enum class OnboardingStage { Step4_SetNotificationsMode, OnboardingComplete } - -@Composable -fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { - val scope = rememberCoroutineScope() - val scrollState = rememberScrollState() - val keyboardState by getKeyboardState() - var savedKeyboardState by remember { mutableStateOf(keyboardState) } - - ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(top = 20.dp) - ) { - CreateProfilePanel(chatModel, close) - LaunchedEffect(Unit) { - setLastVersionDefault(chatModel) - } - if (savedKeyboardState != keyboardState) { - LaunchedEffect(keyboardState) { - scope.launch { - savedKeyboardState = keyboardState - scrollState.animateScrollTo(scrollState.maxValue) - } - } - } - } - } -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index eebe7b3f4..d949f800b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -364,7 +364,7 @@ fun AppVersionItem(showVersion: () -> Unit) { maxLines = 1, overflow = TextOverflow.Ellipsis ) - if (profileOf.fullName.isNotEmpty()) { + if (profileOf.fullName.isNotEmpty() && profileOf.fullName != profileOf.displayName) { Text( profileOf.fullName, Modifier.padding(vertical = 5.dp), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index a7ae3a675..38c9e58ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -17,14 +17,12 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.ProfileNameField import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.isValidDisplayName import chat.simplex.common.views.onboarding.ReadableText -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.Profile import chat.simplex.common.platform.* +import chat.simplex.common.views.* import chat.simplex.res.MR import kotlinx.coroutines.launch import java.net.URI @@ -39,7 +37,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { close, saveProfile = { displayName, fullName, image -> withApi { - val updated = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName, fullName = fullName, image = image)) + val updated = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName.trim(), fullName = fullName, image = image)) if (updated != null) { val (newProfile, _) = updated chatModel.updateCurrentUser(newProfile) @@ -89,7 +87,7 @@ fun UserProfileLayout( profile.image == profileImage.value val closeWithAlert = { - if (dataUnchanged || !(displayName.value.isNotEmpty() && isValidDisplayName(displayName.value))) { + if (dataUnchanged || !canSaveProfile(displayName.value, profile)) { close() } else { showUnsavedChangesAlert({ saveProfile(displayName.value, fullName.value, profileImage.value) }, close) @@ -128,36 +126,27 @@ fun UserProfileLayout( stringResource(MR.strings.display_name__field), fontSize = 16.sp ) - if (!isValidDisplayName(displayName.value)) { - Spacer(Modifier.size(DEFAULT_PADDING_HALF)) - Text( - stringResource(MR.strings.no_spaces), - fontSize = 16.sp, - color = Color.Red - ) + if (!isValidNewProfileName(displayName.value, profile)) { + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } } } - ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester) - Spacer(Modifier.height(DEFAULT_PADDING)) - Text( - stringResource(MR.strings.full_name__field), - fontSize = 16.sp, - modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) - ) - ProfileNameField(fullName) - - Spacer(Modifier.height(DEFAULT_PADDING)) - val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) - val saveModifier: Modifier - val saveColor: Color - if (enabled) { - saveModifier = Modifier - .clickable { saveProfile(displayName.value, fullName.value, profileImage.value) } - saveColor = MaterialTheme.colors.primary - } else { - saveModifier = Modifier - saveColor = MaterialTheme.colors.secondary + ProfileNameField(displayName, "", { isValidNewProfileName(it, profile) }, focusRequester) + if (showFullName(profile)) { + Spacer(Modifier.height(DEFAULT_PADDING)) + Text( + stringResource(MR.strings.full_name__field), + fontSize = 16.sp, + modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) + ) + ProfileNameField(fullName) } + Spacer(Modifier.height(DEFAULT_PADDING)) + val enabled = !dataUnchanged && canSaveProfile(displayName.value, profile) + val saveModifier: Modifier = Modifier.clickable(enabled) { saveProfile(displayName.value, fullName.value, profileImage.value) } + val saveColor: Color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Text( stringResource(MR.strings.save_and_notify_contacts), modifier = saveModifier, @@ -216,6 +205,15 @@ private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { ) } +private fun isValidNewProfileName(displayName: String, profile: Profile): Boolean = + displayName == profile.displayName || isValidDisplayName(displayName.trim()) + +private fun showFullName(profile: Profile): Boolean = + profile.fullName.isNotEmpty() && profile.fullName != profile.displayName + +private fun canSaveProfile(displayName: String, profile: Profile): Boolean = + displayName.trim().isNotEmpty() && isValidNewProfileName(displayName, profile) + @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index 7929413c9..7d3239700 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -28,9 +28,8 @@ import chat.simplex.common.views.chatlist.UserProfilePickerItem import chat.simplex.common.views.chatlist.UserProfileRow import chat.simplex.common.views.database.PassphraseField import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.CreateProfile -import chat.simplex.common.model.* import chat.simplex.common.platform.appPlatform +import chat.simplex.common.views.CreateProfile import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index d57211f4a..7912a5ad1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -675,7 +675,7 @@ Your contacts in SimpleX will see it.\nYou can change it in Settings. - Display name: + Profile name: Full name: Your current profile Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. @@ -703,11 +703,12 @@ Create profile Your profile, contacts and delivered messages are stored on your device. The profile is only shared with your contacts. - No spaces! Display name cannot contain whitespace. - Display Name - Full Name (optional) + Enter your name: Create + Create profile + Invalid name! + Correct name to %s? About SimpleX @@ -1290,7 +1291,7 @@ Create secret group The group is fully decentralized – it is visible only to the members. - Group display name: + Enter group name: Group full name: Your chat profile will be sent to group members diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 46124a44f..9042a6283 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -21,8 +21,6 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db" actual val databaseExportDir: File = tmpDir -val vlcDir: File = File(System.getProperty("java.io.tmpdir") + File.separator + "simplex-vlc").also { it.deleteOnExit() } - actual fun desktopOpenDatabaseDir() { if (Desktop.isDesktopSupported()) { try { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt index cb4e3acdb..9217551a8 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt @@ -8,12 +8,12 @@ private val unixConfigPath = (System.getenv("XDG_CONFIG_HOME") ?: "$home/.config private val unixDataPath = (System.getenv("XDG_DATA_HOME") ?: "$home/.local/share") + "/simplex" val desktopPlatform = detectDesktopPlatform() -enum class DesktopPlatform(val libPath: String, val libExtension: String, val configPath: String, val dataPath: String) { - LINUX_X86_64("/libs/linux-x86_64", "so", unixConfigPath, unixDataPath), - LINUX_AARCH64("/libs/aarch64", "so", unixConfigPath, unixDataPath), - WINDOWS_X86_64("/libs/windows-x86_64", "dll", System.getenv("AppData") + File.separator + "SimpleX", System.getenv("AppData") + File.separator + "SimpleX"), - MAC_X86_64("/libs/mac-x86_64", "dylib", unixConfigPath, unixDataPath), - MAC_AARCH64("/libs/mac-aarch64", "dylib", unixConfigPath, unixDataPath); +enum class DesktopPlatform(val libExtension: String, val configPath: String, val dataPath: String) { + LINUX_X86_64("so", unixConfigPath, unixDataPath), + LINUX_AARCH64("so", unixConfigPath, unixDataPath), + WINDOWS_X86_64("dll", System.getenv("AppData") + File.separator + "SimpleX", System.getenv("AppData") + File.separator + "SimpleX"), + MAC_X86_64("dylib", unixConfigPath, unixDataPath), + MAC_AARCH64("dylib", unixConfigPath, unixDataPath); fun isLinux() = this == LINUX_X86_64 || this == LINUX_AARCH64 fun isWindows() = this == WINDOWS_X86_64 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index 3b7ba8486..c677edc06 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.* @@ -27,6 +26,9 @@ import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay +import java.io.File +import java.net.URI +import kotlin.io.path.* import kotlin.math.min import kotlin.text.substring @@ -39,6 +41,7 @@ actual fun PlatformTextField( userIsObserver: Boolean, onMessageChange: (String) -> Unit, onUpArrow: () -> Unit, + onFilesPasted: (List) -> Unit, onDone: () -> Unit, ) { val cs = composeState.value @@ -63,10 +66,20 @@ actual fun PlatformTextField( val isRtl = remember(cs.message) { isRtl(cs.message.subSequence(0, min(50, cs.message.length))) } var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message)) } val textFieldValue = textFieldValueState.copy(text = cs.message) + val clipboard = LocalClipboardManager.current BasicTextField( value = textFieldValue, - onValueChange = { + onValueChange = onValueChange@ { if (!composeState.value.inProgress && !(composeState.value.preview is ComposePreview.VoicePreview && it.text != "")) { + val diff = textFieldValueState.selection.length + (it.text.length - textFieldValueState.text.length) + if (diff > 1 && it.text != textFieldValueState.text && it.selection.max - diff >= 0) { + val pasted = it.text.substring(it.selection.max - diff, it.selection.max) + val files = parseToFiles(AnnotatedString(pasted)) + if (files.isNotEmpty()) { + onFilesPasted(files) + return@onValueChange + } + } textFieldValueState = it onMessageChange(it.text) } @@ -98,6 +111,12 @@ actual fun PlatformTextField( } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.isEmpty()) { onUpArrow() true + } else if (it.key == Key.V && + it.type == KeyEventType.KeyDown && + ((it.isCtrlPressed && !desktopPlatform.isMac()) || (it.isMetaPressed && desktopPlatform.isMac())) && + parseToFiles(clipboard.getText()).isNotEmpty()) { + onFilesPasted(parseToFiles(clipboard.getText())) + true } else false }, @@ -142,3 +161,19 @@ private fun ComposeOverlay(textId: StringResource, textStyle: MutableState { + text ?: return emptyList() + val files = ArrayList() + text.lines().forEach { + try { + val uri = File(it.removePrefix("\"").removeSuffix("\"")).toURI() + val path = uri.toPath() + if (!path.exists() || !path.isAbsolute || path.isDirectory()) return emptyList() + files.add(uri) + } catch (e: Exception) { + return emptyList() + } + } + return files +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index c1d9eeec5..9df5bd0a1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -7,17 +7,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.foundation.layout.padding +import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.* -import chat.simplex.common.model.ChatItem -import chat.simplex.common.model.MsgContent -import chat.simplex.common.platform.FileChooserLauncher -import chat.simplex.common.platform.desktopPlatform +import chat.simplex.common.model.* +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.EmojiFont import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import java.io.File +import java.util.* @Composable actual fun ReactionIcon(text: String, fontSize: TextUnit) { @@ -39,3 +41,23 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL showMenu.value = false }) } + +actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { + val fileSource = getLoadedFileSource(cItem.file) + if (fileSource != null) { + val filePath: String = if (fileSource.cryptoArgs != null) { + val tmpFile = File(tmpDir, fileSource.filePath) + tmpFile.deleteOnExit() + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + tmpFile.absolutePath + } else { + getAppFilePath(fileSource.filePath) + } + when { + desktopPlatform.isWindows() -> clipboard.setText(AnnotatedString("\"${File(filePath).absolutePath}\"")) + else -> clipboard.setText(AnnotatedString(filePath)) + } + } else { + clipboard.setText(AnnotatedString(cItem.content.text)) + } +} diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 3da87850d..a7dab78ee 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -52,6 +52,7 @@ compose { } //includeAllModules = true outputBaseDir.set(project.file("../release")) + appResourcesRootDir.set(project.file("../build/links")) targetFormats( TargetFormat.Deb, TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Exe //, TargetFormat.AppImage // Gradle doesn't sync on Mac with it @@ -156,11 +157,10 @@ tasks.named("compileJava") { afterEvaluate { tasks.create("cmakeBuildAndCopy") { dependsOn("cmakeBuild") - val copyDetails = mutableMapOf>() doLast { copy { - from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps") - into("src/jvmMain/resources/libs/linux-x86_64") + from("${project(":desktop").buildDir}/cmake/main/linux-amd64") + into("$cppPath/desktop/libs/linux-x86_64") include("*.so*") eachFile { path = name @@ -169,16 +169,8 @@ afterEvaluate { duplicatesStrategy = DuplicatesStrategy.INCLUDE } copy { - val destinationDir = "src/jvmMain/resources/libs/linux-x86_64/vlc" - from("$cppPath/desktop/libs/linux-x86_64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - copy { - from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps") - into("src/jvmMain/resources/libs/linux-aarch64") + from("${project(":desktop").buildDir}/cmake/main/linux-aarch64") + into("$cppPath/desktop/libs/linux-aarch64") include("*.so*") eachFile { path = name @@ -187,16 +179,18 @@ afterEvaluate { duplicatesStrategy = DuplicatesStrategy.INCLUDE } copy { - val destinationDir = "src/jvmMain/resources/libs/linux-aarch64/vlc" - from("$cppPath/desktop/libs/linux-aarch64/deps/vlc") - into(destinationDir) + from("${project(":desktop").buildDir}/cmake/main/windows-amd64") + into("$cppPath/desktop/libs/windows-x86_64") + include("*.dll") + eachFile { + path = name + } includeEmptyDirs = false duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) } - copy { - from("${project(":desktop").buildDir}/cmake/main/windows-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps") - into("src/jvmMain/resources/libs/windows-x86_64") + copy { + from("${project(":desktop").buildDir}/cmake/main/windows-amd64") + into("../build/links/windows-x64") include("*.dll") eachFile { path = name @@ -205,16 +199,8 @@ afterEvaluate { duplicatesStrategy = DuplicatesStrategy.INCLUDE } copy { - val destinationDir = "src/jvmMain/resources/libs/windows-x86_64/vlc" - from("$cppPath/desktop/libs/windows-x86_64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - copy { - from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps") - into("src/jvmMain/resources/libs/mac-x86_64") + from("${project(":desktop").buildDir}/cmake/main/mac-x86_64") + into("$cppPath/desktop/libs/mac-x86_64") include("*.dylib") eachFile { path = name @@ -223,16 +209,8 @@ afterEvaluate { duplicatesStrategy = DuplicatesStrategy.INCLUDE } copy { - val destinationDir = "src/jvmMain/resources/libs/mac-x86_64/vlc" - from("$cppPath/desktop/libs/mac-x86_64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - copy { - from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps") - into("src/jvmMain/resources/libs/mac-aarch64") + from("${project(":desktop").buildDir}/cmake/main/mac-aarch64") + into("$cppPath/desktop/libs/mac-aarch64") include("*.dylib") eachFile { path = name @@ -240,39 +218,6 @@ afterEvaluate { includeEmptyDirs = false duplicatesStrategy = DuplicatesStrategy.INCLUDE } - copy { - val destinationDir = "src/jvmMain/resources/libs/mac-aarch64/vlc" - from("$cppPath/desktop/libs/mac-aarch64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - } - afterEvaluate { - doLast { - copyDetails.forEach { (destinationDir, details) -> - details.forEach { detail -> - val target = File(projectDir.absolutePath + File.separator + destinationDir + File.separator + detail.path) - if (target.exists()) { - target.setLastModified(detail.lastModified) - } - } - } - } } } } - -fun CopySpec.copyIfNeeded(destinationDir: String, into: MutableMap>) { - val details = arrayListOf() - eachFile { - val targetFile = File(destinationDir, path) - if (file.lastModified() == targetFile.lastModified() && file.length() == targetFile.length()) { - exclude() - } else { - details.add(this) - } - } - into[destinationDir] = details -} diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index a0be87732..ff92b08ff 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -18,51 +18,29 @@ fun main() { @Suppress("UnsafeDynamicallyLoadedCode") private fun initHaskell() { - val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs") - copyResources(desktopPlatform.libPath, libsTmpDir.toPath()) - vlcDir.deleteRecursively() - Files.move(File(libsTmpDir, "vlc").toPath(), vlcDir.toPath(), StandardCopyOption.REPLACE_EXISTING) + val resourcesDir = File(System.getProperty("compose.application.resources.dir")) + val vlcDir = File(resourcesDir.absolutePath + File.separator + "vlc") if (desktopPlatform == DesktopPlatform.WINDOWS_X86_64) { - windowsLoadRequiredLibs(libsTmpDir) + windowsLoadRequiredLibs(resourcesDir, vlcDir) } else { - System.load(File(libsTmpDir, "libapp-lib.${desktopPlatform.libExtension}").absolutePath) + System.load(File(resourcesDir, "libapp-lib.${desktopPlatform.libExtension}").absolutePath) } // No picture without preloading it, only sound. However, with libs from AppImage it works without preloading //val libXcb = "libvlc_xcb_events.so.0.0.0" //System.load(File(File(vlcDir, "vlc"), libXcb).absolutePath) System.setProperty("jna.library.path", vlcDir.absolutePath) //discoverVlcLibs(File(File(vlcDir, "vlc"), "plugins").absolutePath) - - libsTmpDir.deleteRecursively() initHS() } -private fun copyResources(from: String, to: Path) { - val resource = Class.forName("chat.simplex.desktop.MainKt").getResource("")!!.toURI() - val fileSystem = FileSystems.newFileSystem(resource, emptyMap()) - val resPath = fileSystem.getPath(from) - Files.walkFileTree(resPath, object: SimpleFileVisitor() { - override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { - Files.createDirectories(to.resolve(resPath.relativize(dir).toString())) - return FileVisitResult.CONTINUE - } - override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { - val dest = to.resolve(resPath.relativize(file).toString()) - Files.copy(file, dest, StandardCopyOption.REPLACE_EXISTING) - // Setting the same time on file as the time set in script that generates VLC libs - if (dest.toString().contains("." + desktopPlatform.libExtension)) { - dest.setLastModifiedTime(FileTime.fromMillis(0)) - } - return FileVisitResult.CONTINUE - } - }) -} - -private fun windowsLoadRequiredLibs(libsTmpDir: File) { +private fun windowsLoadRequiredLibs(libsTmpDir: File, vlcDir: File) { val mainLibs = arrayOf( "libcrypto-3-x64.dll", + "mcfgthread-12.dll", + "libgcc_s_seh-1.dll", + "libstdc++-6.dll", "libffi-8.dll", - "libgmp-10.dll", + "libgmp-10.dll", "libsimplex.dll", "libapp-lib.dll" ) @@ -72,7 +50,7 @@ private fun windowsLoadRequiredLibs(libsTmpDir: File) { val vlcLibs = arrayOf( "libvlccore.dll", "libvlc.dll", - "axvlc.dll", + "axvlc.dll", "npvlc.dll" ) vlcLibs.forEach { diff --git a/cabal.project b/cabal.project index 816599fb3..11a597897 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 899d26e8c8a66d903b98ad64bb068803cfa3d81d + tag: 9f0f53d2ec34de09ab684036844578ed5994c24b source-repository-package type: git @@ -34,7 +34,7 @@ source-repository-package source-repository-package type: git location: https://github.com/simplex-chat/aeson.git - tag: 3eb66f9a68f103b5f1489382aad89f5712a64db7 + tag: aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b source-repository-package type: git diff --git a/package.yaml b/package.yaml index efa8232e9..69c89c697 100644 --- a/package.yaml +++ b/package.yaml @@ -13,7 +13,7 @@ extra-source-files: - cabal.project dependencies: - - aeson == 2.0.* + - aeson == 2.2.* - ansi-terminal >= 0.10 && < 0.12 - async == 2.2.* - attoparsec == 0.14.* diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index f69b89773..d40fb0462 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -1,9 +1,23 @@ #!/bin/bash +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} + OS=linux ARCH=${1:-`uname -a | rev | cut -d' ' -f2 | rev`} GHC_VERSION=8.10.7 +if [ "$ARCH" == "aarch64" ]; then + COMPOSE_ARCH=arm64 +else + COMPOSE_ARCH=x64 +fi + +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" +cd $root_dir BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-${GHC_VERSION}/simplex-chat-* rm -rf $BUILD_DIR @@ -11,16 +25,20 @@ cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN' --ghc-optio cd $BUILD_DIR/build #patchelf --add-needed libHSrts_thr-ghc${GHC_VERSION}.so libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so #patchelf --add-rpath '$ORIGIN' libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so -mkdir deps +mkdir deps 2> /dev/null || true ldd libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so | grep "ghc" | cut -d' ' -f 3 | xargs -I {} cp {} ./deps/ cd - rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +cp -r $BUILD_DIR/build/deps/* apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ scripts/desktop/prepare-vlc-linux.sh + +links_dir=apps/multiplatform/build/links +mkdir -p $links_dir +cd $links_dir +ln -sfT ../../common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ $OS-$COMPOSE_ARCH diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index b93a568a1..d57838e94 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -4,8 +4,12 @@ set -e OS=mac ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}" +COMPOSE_ARCH=$ARCH + if [ "$ARCH" == "arm64" ]; then ARCH=aarch64 +else + COMPOSE_ARCH=x64 fi LIB_EXT=dylib LIB=libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT @@ -66,30 +70,29 @@ rm deps/`basename $LIB` cd - rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +cp -r $BUILD_DIR/build/deps/* apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cd apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -LIBCRYPTO_PATH=$(otool -l deps/libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) -install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSdrct-*.$LIB_EXT -cp $LIBCRYPTO_PATH deps/libcrypto.1.1.$LIB_EXT -chmod 755 deps/libcrypto.1.1.$LIB_EXT -install_name_tool -id "libcrypto.1.1.$LIB_EXT" deps/libcrypto.1.1.$LIB_EXT -install_name_tool -id "libffi.8.$LIB_EXT" deps/libffi.$LIB_EXT +LIBCRYPTO_PATH=$(otool -l libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSdrct-*.$LIB_EXT +cp $LIBCRYPTO_PATH libcrypto.1.1.$LIB_EXT +chmod 755 libcrypto.1.1.$LIB_EXT +install_name_tool -id "libcrypto.1.1.$LIB_EXT" libcrypto.1.1.$LIB_EXT +install_name_tool -id "libffi.8.$LIB_EXT" libffi.$LIB_EXT LIBCRYPTO_PATH=$(otool -l $LIB | grep libcrypto | cut -d' ' -f11) if [ -n "$LIBCRYPTO_PATH" ]; then install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT $LIB fi -LIBCRYPTO_PATH=$(otool -l deps/libHSsmplxmq*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +LIBCRYPTO_PATH=$(otool -l libHSsmplxmq*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) if [ -n "$LIBCRYPTO_PATH" ]; then - install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSsmplxmq*.$LIB_EXT + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsmplxmq*.$LIB_EXT fi for lib in $(find . -type f -name "*.$LIB_EXT"); do @@ -108,3 +111,9 @@ fi cd - scripts/desktop/prepare-vlc-mac.sh + +links_dir=apps/multiplatform/build/links +mkdir -p $links_dir +cd $links_dir +rm macos-$COMPOSE_ARCH 2>/dev/null | true +ln -sf ../../common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ macos-$COMPOSE_ARCH diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh index 658324baa..ef39ef868 100755 --- a/scripts/desktop/build-lib-windows.sh +++ b/scripts/desktop/build-lib-windows.sh @@ -8,15 +8,26 @@ function readlink() { root_dir="$(dirname "$(dirname "$(readlink "$0")")")" OS=windows -ARCH=`uname -a | rev | cut -d' ' -f2 | rev` +ARCH="x86_64" JOB_REPO=${1:-$SIMPLEX_CI_REPO_URL} +if [ "$ARCH" == "aarch64" ]; then + COMPOSE_ARCH=arm64 +else + COMPOSE_ARCH=x64 +fi + cd $root_dir rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ -rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ scripts/desktop/download-lib-windows.sh $JOB_REPO scripts/desktop/prepare-vlc-windows.sh + +links_dir=apps/multiplatform/build/links +mkdir -p $links_dir +cd $links_dir +rm -rf $OS-$COMPOSE_ARCH +ln -sfT ../../common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ $OS-$COMPOSE_ARCH diff --git a/scripts/desktop/download-lib-windows.sh b/scripts/desktop/download-lib-windows.sh index 14439274c..945bd7b5e 100644 --- a/scripts/desktop/download-lib-windows.sh +++ b/scripts/desktop/download-lib-windows.sh @@ -7,7 +7,7 @@ function readlink() { } if [ -z "${1}" ]; then - echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-lib-windows.sh https://something.com/job/something/{windows,windows-8107}" + echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-lib-windows.sh https://something.com/job/something/{master,stable}" exit 1 fi @@ -16,12 +16,15 @@ arch=x86_64 root_dir="$(dirname "$(dirname "$(readlink "$0")")")" output_dir="$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-$arch/" -mkdir -p "$output_dir"/deps 2> /dev/null +mkdir -p "$output_dir" 2> /dev/null curl --location -o libsimplex.zip $job_repo/$arch-linux.$arch-windows:lib:simplex-chat/latest/download/1 && \ $WINDIR\\System32\\tar.exe -xf libsimplex.zip && \ mv libsimplex.dll "$output_dir" && \ -mv libcrypto*.dll "$output_dir/deps" && \ -mv libffi*.dll "$output_dir/deps" && \ -mv libgmp*.dll "$output_dir/deps" && \ +mv libcrypto*.dll "$output_dir" && \ +mv libffi*.dll "$output_dir" && \ +mv libgmp*.dll "$output_dir" && \ +mv mcfgthread*.dll "$output_dir" && \ +mv libgcc_s_seh*.dll "$output_dir" && \ +mv libstdc++*.dll "$output_dir" && \ rm libsimplex.zip diff --git a/scripts/desktop/make-appimage-linux.sh b/scripts/desktop/make-appimage-linux.sh index 35e62481d..9cd6f525f 100755 --- a/scripts/desktop/make-appimage-linux.sh +++ b/scripts/desktop/make-appimage-linux.sh @@ -11,13 +11,12 @@ multiplatform_dir=$root_dir/apps/multiplatform release_app_dir=$root_dir/apps/multiplatform/release/main/app cd $multiplatform_dir -libcrypto_path=$(ldd common/src/commonMain/cpp/desktop/libs/*/deps/libHSdirect-sqlcipher-*.so | grep libcrypto | cut -d'=' -f 2 | cut -d ' ' -f 2) -trap "rm common/src/commonMain/cpp/desktop/libs/*/deps/`basename $libcrypto_path` 2> /dev/null || true" EXIT -cp $libcrypto_path common/src/commonMain/cpp/desktop/libs/*/deps +libcrypto_path=$(ldd common/src/commonMain/cpp/desktop/libs/*/libHSdirect-sqlcipher-*.so | grep libcrypto | cut -d'=' -f 2 | cut -d ' ' -f 2) +trap "rm common/src/commonMain/cpp/desktop/libs/*/`basename $libcrypto_path` 2> /dev/null || true" EXIT +cp $libcrypto_path common/src/commonMain/cpp/desktop/libs/* ./gradlew createDistributable -rm common/src/commonMain/cpp/desktop/libs/*/deps/`basename $libcrypto_path` -rm desktop/src/jvmMain/resources/libs/*/`basename $libcrypto_path` +rm common/src/commonMain/cpp/desktop/libs/*/`basename $libcrypto_path` rm -rf $release_app_dir/AppDir 2>/dev/null mkdir -p $release_app_dir/AppDir/usr diff --git a/scripts/desktop/prepare-vlc-linux.sh b/scripts/desktop/prepare-vlc-linux.sh index e1cfa7e9f..a76486150 100755 --- a/scripts/desktop/prepare-vlc-linux.sh +++ b/scripts/desktop/prepare-vlc-linux.sh @@ -6,7 +6,7 @@ function readlink() { echo "$(cd "$(dirname "$1")"; pwd -P)" } root_dir="$(dirname "$(dirname "$(readlink "$0")")")" -vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-x86_64/deps/vlc +vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-x86_64/vlc mkdir $vlc_dir || exit 0 diff --git a/scripts/desktop/prepare-vlc-mac.sh b/scripts/desktop/prepare-vlc-mac.sh index 69644bcc1..25ec1365f 100755 --- a/scripts/desktop/prepare-vlc-mac.sh +++ b/scripts/desktop/prepare-vlc-mac.sh @@ -16,7 +16,7 @@ function readlink() { } root_dir="$(dirname "$(dirname "$(readlink "$0")")")" -vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-$ARCH/deps/vlc +vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-$ARCH/vlc #rm -rf $vlc_dir mkdir -p $vlc_dir/vlc || exit 0 diff --git a/scripts/desktop/prepare-vlc-windows.sh b/scripts/desktop/prepare-vlc-windows.sh index bdb492344..680fa7b80 100644 --- a/scripts/desktop/prepare-vlc-windows.sh +++ b/scripts/desktop/prepare-vlc-windows.sh @@ -6,7 +6,7 @@ function readlink() { echo "$(cd "$(dirname "$1")"; pwd -P)" } root_dir="$(dirname "$(dirname "$(readlink "$0")")")" -vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-x86_64/deps/vlc +vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-x86_64/vlc rm -rf $vlc_dir mkdir -p $vlc_dir/vlc || exit 0 diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 23a7d41f8..8d722d440 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,10 +1,10 @@ { - "https://github.com/simplex-chat/simplexmq.git"."899d26e8c8a66d903b98ad64bb068803cfa3d81d" = "0jj5wl3l0r6gf01bwimmalr12s8c0jcdbbfhhyi0mivph886319r"; + "https://github.com/simplex-chat/simplexmq.git"."9f0f53d2ec34de09ab684036844578ed5994c24b" = "0n8as86gaaws3yqy8kvflpqw8jgjzdyrskb59aqs9k4yimg6wk50"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; - "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; + "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; } diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 9f6594c0a..2aa8e4d58 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -146,7 +146,7 @@ library src ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns build-depends: - aeson ==2.0.* + aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* , attoparsec ==0.14.* @@ -194,7 +194,7 @@ executable simplex-bot apps/simplex-bot ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: - aeson ==2.0.* + aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* , attoparsec ==0.14.* @@ -243,7 +243,7 @@ executable simplex-bot-advanced apps/simplex-bot-advanced ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: - aeson ==2.0.* + aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* , attoparsec ==0.14.* @@ -294,7 +294,7 @@ executable simplex-broadcast-bot apps/simplex-broadcast-bot/src ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: - aeson ==2.0.* + aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* , attoparsec ==0.14.* @@ -344,7 +344,7 @@ executable simplex-chat apps/simplex-chat ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: - aeson ==2.0.* + aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* , attoparsec ==0.14.* @@ -398,7 +398,7 @@ executable simplex-directory-service apps/simplex-directory-service/src ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: - aeson ==2.0.* + aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* , attoparsec ==0.14.* @@ -472,7 +472,7 @@ test-suite simplex-chat-test apps/simplex-directory-service/src ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: - aeson ==2.0.* + aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 , async ==2.2.* , attoparsec ==0.14.* diff --git a/stack.yaml b/stack.yaml index 74a0f9452..2d63fdfa2 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 899d26e8c8a66d903b98ad64bb068803cfa3d81d + commit: 9f0f53d2ec34de09ab684036844578ed5994c24b - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher @@ -60,7 +60,7 @@ extra-deps: commit: 5e154a2aeccc33ead6c243ec07195ab673137221 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson - commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 + commit: aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b - github: simplex-chat/haskell-terminal commit: f708b00009b54890172068f168bf98508ffcd495 #