diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index db1c755682..69587c0152 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -26,6 +26,7 @@ struct GroupProfileView: View { @Environment(\.dismiss) var dismiss: DismissAction @Binding var groupInfo: GroupInfo @State var groupProfile: GroupProfile + @State private var shortDescr: String = "" @State private var currentProfileHash: Int? @State private var showChooseSource = false @State private var showImagePicker = false @@ -55,8 +56,16 @@ struct GroupProfileView: View { if fullName != "" && fullName != groupProfile.displayName { TextField("Group full name (optional)", text: $groupProfile.fullName) } - // TODO enable in v6.4.1, limit to 160 characters - // TextField("Short description", text: Binding(get: {groupProfile.shortDescr ?? ""}, set: {groupProfile.shortDescr = $0})) + HStack { + TextField("Short description", text: $shortDescr) + if !shortDescrFitsLimit() { + Button { + showAlert(NSLocalizedString("Description too large", comment: "alert title")) + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } + } } footer: { Text("Group profile is stored on members' devices, not on the servers.") } @@ -64,9 +73,13 @@ struct GroupProfileView: View { Section { Button("Reset") { groupProfile = groupInfo.groupProfile + shortDescr = groupInfo.groupProfile.shortDescr ?? "" currentProfileHash = groupProfile.hashValue } - .disabled(currentProfileHash == groupProfile.hashValue) + .disabled( + currentProfileHash == groupProfile.hashValue && + (groupInfo.groupProfile.shortDescr ?? "") == shortDescr.trimmingCharacters(in: .whitespaces) + ) Button("Save group profile", action: saveProfile) .disabled(!canUpdateProfile) } @@ -109,6 +122,7 @@ struct GroupProfileView: View { } .onAppear { currentProfileHash = groupProfile.hashValue + shortDescr = groupInfo.groupProfile.shortDescr ?? "" DispatchQueue.main.asyncAfter(deadline: .now() + 1) { withAnimation { focusDisplayName = true } } @@ -141,9 +155,13 @@ struct GroupProfileView: View { } private var canUpdateProfile: Bool { - currentProfileHash != groupProfile.hashValue && + ( + currentProfileHash != groupProfile.hashValue || + (groupProfile.shortDescr ?? "") != shortDescr.trimmingCharacters(in: .whitespaces) + ) && groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && - validNewProfileName + validNewProfileName && + shortDescrFitsLimit() } private var validNewProfileName: Bool { @@ -151,11 +169,16 @@ struct GroupProfileView: View { || validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces)) } + private func shortDescrFitsLimit() -> Bool { + chatJsonLength(shortDescr) <= MAX_BIO_LENGTH_BYTES + } + func saveProfile() { Task { do { groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces) groupProfile.fullName = groupProfile.fullName.trimmingCharacters(in: .whitespaces) + groupProfile.shortDescr = shortDescr.trimmingCharacters(in: .whitespaces) let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile) await MainActor.run { currentProfileHash = groupProfile.hashValue @@ -174,6 +197,9 @@ struct GroupProfileView: View { struct GroupProfileView_Previews: PreviewProvider { static var previews: some View { - GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData) + GroupProfileView( + groupInfo: Binding.constant(GroupInfo.sampleData), + groupProfile: GroupProfile.sampleData + ) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index 97bff70efb..5e7b8b9329 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -157,6 +157,9 @@ struct GroupWelcomeView: View { struct GroupWelcomeView_Previews: PreviewProvider { static var previews: some View { - GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData) + GroupProfileView( + groupInfo: Binding.constant(GroupInfo.sampleData), + groupProfile: GroupProfile.sampleData + ) } } diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index dfc8be738e..f119beec50 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -25,6 +25,8 @@ enum UserProfileAlert: Identifiable { } } +let MAX_BIO_LENGTH_BYTES = 160 + struct CreateProfile: View { @Environment(\.dismiss) var dismiss @EnvironmentObject var theme: AppTheme @@ -38,14 +40,13 @@ struct CreateProfile: View { Section { TextField("Enter your name…", text: $displayName) .focused($focusDisplayName) - // TODO enable in v6.4.1, limit to 160 characters - // TextField("Bio", text: $profileBio) + TextField("Bio", text: $profileBio) Button { createProfile() } label: { Label("Create profile", systemImage: "checkmark") } - .disabled(!canCreateProfile(displayName)) + .disabled(!canCreateProfile(displayName) || !bioFitsLimit()) } header: { HStack { Text("Your profile") @@ -55,11 +56,14 @@ struct CreateProfile: View { let validName = mkValidName(name) if name != validName { Spacer() - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - .onTapGesture { - alert = .invalidNameError(validName: validName) - } + validationErrorIndicator { + alert = .invalidNameError(validName: validName) + } + } else if !bioFitsLimit() { + Spacer() + validationErrorIndicator { + showAlert(NSLocalizedString("Bio too large", comment: "alert title")) + } } } .frame(height: 20) @@ -81,6 +85,18 @@ struct CreateProfile: View { } } + private func validationErrorIndicator(_ onTap: @escaping () -> Void) -> some View { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + .onTapGesture { + onTap() + } + } + + private func bioFitsLimit() -> Bool { + chatJsonLength(profileBio) <= MAX_BIO_LENGTH_BYTES + } + private func createProfile() { hideKeyboard() let shortDescr: String? = if profileBio.isEmpty { nil } else { profileBio } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 58d4ffb30e..bed77cdab1 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -459,13 +459,16 @@ struct UserAddressSettingsView: View { Section { shareWithContactsButton() autoAcceptToggle().disabled(settings.businessAddress) + if settings.autoAccept && !ChatModel.shared.addressShortLinkDataSet && !settings.businessAddress { + acceptIncognitoToggle() + } } - // TODO v6.4.1 move auto-reply editor here - // messageEditor(placeholder: NSLocalizedString("Enter welcome message… (optional)", comment: "placeholder"), text: $settings.autoReply) - - if settings.autoAccept { - autoAcceptSection() + Section { + messageEditor(placeholder: NSLocalizedString("Enter welcome message… (optional)", comment: "placeholder"), text: $settings.autoReply) + } header: { + Text("Welcome message") + .foregroundColor(theme.colors.secondary) } Section { @@ -541,21 +544,6 @@ struct UserAddressSettingsView: View { } } - private func autoAcceptSection() -> some View { - Section { - if !ChatModel.shared.addressShortLinkDataSet && !settings.businessAddress { - acceptIncognitoToggle() - } - // TODO v6.4.1 show this message editor even with auto-accept disabled - messageEditor(placeholder: NSLocalizedString("Enter welcome message… (optional)", comment: "placeholder"), text: $settings.autoReply) - } header: { - Text("Auto-accept") - .foregroundColor(theme.colors.secondary) - } footer: { - Text("Sent to your contact after connection.") - } - } - private func acceptIncognitoToggle() -> some View { settingsRow( settings.autoAcceptIncognito ? "theatermasks.fill" : "theatermasks", diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 444975d236..569b5caf13 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -15,6 +15,7 @@ struct UserProfile: View { @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner @State private var profile = Profile(displayName: "", fullName: "") @State private var currentProfileHash: Int? + @State private var shortDescr = "" // Modals @State private var showChooseSource = false @State private var showImagePicker = false @@ -43,8 +44,16 @@ struct UserProfile: View { if let user = chatModel.currentUser, showFullName(user) { TextField("Full name (optional)", text: $profile.fullName) } - // TODO enable in v6.4.1, limit to 160 characters - // TextField("Bio", text: Binding(get: {profile.shortDescr ?? ""}, set: {profile.shortDescr = $0})) + HStack { + TextField("Bio", text: $shortDescr) + if !bioFitsLimit() { + Button { + showAlert(NSLocalizedString("Bio too large", comment: "alert title")) + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } + } } footer: { Text("Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile.") } @@ -53,7 +62,10 @@ struct UserProfile: View { Button(action: getCurrentProfile) { Text("Reset") } - .disabled(currentProfileHash == profile.hashValue) + .disabled( + currentProfileHash == profile.hashValue && + (profile.shortDescr ?? "") == shortDescr.trimmingCharacters(in: .whitespaces) + ) Button(action: saveProfile) { Text("Save (and notify contacts)") } @@ -118,11 +130,19 @@ struct UserProfile: View { private func showFullName(_ user: User) -> Bool { user.profile.fullName != "" && user.profile.fullName != user.profile.displayName } - + + private func bioFitsLimit() -> Bool { + chatJsonLength(shortDescr) <= MAX_BIO_LENGTH_BYTES + } + private var canSaveProfile: Bool { - currentProfileHash != profile.hashValue && + ( + currentProfileHash != profile.hashValue || + (chatModel.currentUser?.profile.shortDescr ?? "") != shortDescr.trimmingCharacters(in: .whitespaces) + ) && profile.displayName.trimmingCharacters(in: .whitespaces) != "" && - validDisplayName(profile.displayName) + validDisplayName(profile.displayName) && + bioFitsLimit() } private func saveProfile() { @@ -130,6 +150,7 @@ struct UserProfile: View { Task { do { profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) + profile.shortDescr = shortDescr.trimmingCharacters(in: .whitespaces) if let (newProfile, _) = try await apiUpdateProfile(profile: profile) { await MainActor.run { chatModel.updateCurrentUser(newProfile) @@ -148,6 +169,7 @@ struct UserProfile: View { if let user = chatModel.currentUser { profile = fromLocalProfile(user.profile) currentProfileHash = profile.hashValue + shortDescr = profile.shortDescr ?? "" } } } 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 9d9b4c8f5e..6ec124048c 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 @@ -34,6 +34,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +const val MAX_BIO_LENGTH_BYTES = 160 + +fun bioFitsLimit(bio: String): Boolean { + return chatJsonLength(bio) <= MAX_BIO_LENGTH_BYTES +} + @Composable fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { val scope = rememberCoroutineScope() @@ -68,18 +74,28 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { } ProfileNameField(displayName, "", { it.trim() == mkValidName(it) }, focusRequester) -// TODO enable in v6.4.1, limit to 160 characters + Spacer(Modifier.height(DEFAULT_PADDING)) -// Text( -// stringResource(MR.strings.short_descr), -// fontSize = 16.sp -// ) -// ProfileNameField(shortDescr, "") + Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + stringResource(MR.strings.short_descr), + fontSize = 16.sp + ) + Spacer(Modifier.height(20.dp)) + if (!bioFitsLimit(shortDescr.value)) { + IconButton( + onClick = { AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.bio_too_large)) }, + Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } + } + } + ProfileNameField(shortDescr, "", isValid = { bioFitsLimit(it) }) } SettingsActionItem( painterResource(MR.images.ic_check), stringResource(MR.strings.create_another_profile_button), - disabled = !canCreateProfile(displayName.value), + disabled = !canCreateProfile(displayName.value) || !bioFitsLimit(shortDescr.value), textColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary, click = { 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 05b4be1c47..f15f70673a 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 @@ -68,7 +68,7 @@ fun GroupProfileLayout( shortDescr.value.trim() == (groupProfile.shortDescr ?: "") && groupProfile.image == profileImage.value val closeWithAlert = { - if (dataUnchanged || !canUpdateProfile(displayName.value, groupProfile)) { + if (dataUnchanged || !canUpdateProfile(displayName.value, shortDescr.value, groupProfile)) { close() } else { showUnsavedChangesAlert({ @@ -143,18 +143,27 @@ fun GroupProfileLayout( ProfileNameField(fullName) } -// TODO enable in v6.4.1, limit to 160 characters + Spacer(Modifier.height(DEFAULT_PADDING)) -// Spacer(Modifier.height(DEFAULT_PADDING)) -// Text( -// stringResource(MR.strings.group_short_descr_field), -// fontSize = 16.sp, -// modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) -// ) -// ProfileNameField(shortDescr) + Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + stringResource(MR.strings.group_short_descr_field), + fontSize = 16.sp, + ) + if (!bioFitsLimit(shortDescr.value)) { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) + IconButton( + onClick = { AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.group_descr_too_large)) }, + Modifier.size(20.dp) + ) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } + } + } + ProfileNameField(shortDescr, "", isValid = { bioFitsLimit(it) }) Spacer(Modifier.height(DEFAULT_PADDING)) - val enabled = !dataUnchanged && canUpdateProfile(displayName.value, groupProfile) + val enabled = !dataUnchanged && canUpdateProfile(displayName.value, shortDescr.value, groupProfile) if (enabled) { Text( stringResource(MR.strings.save_group_profile), @@ -189,8 +198,8 @@ fun GroupProfileLayout( } } -private fun canUpdateProfile(displayName: String, groupProfile: GroupProfile): Boolean = - displayName.trim().isNotEmpty() && isValidNewProfileName(displayName, groupProfile) +private fun canUpdateProfile(displayName: String, shortDescr: String, groupProfile: GroupProfile): Boolean = + displayName.trim().isNotEmpty() && isValidNewProfileName(displayName, groupProfile) && bioFitsLimit(shortDescr) private fun isValidNewProfileName(displayName: String, groupProfile: GroupProfile): Boolean = displayName == groupProfile.displayName || isValidDisplayName(displayName.trim()) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 12ee2faafe..ba424be618 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -396,19 +396,16 @@ private fun ModalData.UserAddressSettings( SectionView { ShareWithContactsButton(shareViaProfile, setProfileAddress) AutoAcceptToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } + if (addressSettingsState.value.autoAccept && !chatModel.addressShortLinkDataSet() && !addressSettingsState.value.businessAddress) { + AcceptIncognitoToggle(addressSettingsState) + } } SectionDividerSpaced() - // TODO v6.4.1 move auto-reply editor here - // SectionView(stringResource(MR.strings.address_welcome_message).uppercase()) { - // AutoReplyEditor(addressSettingsState) - // } - // SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) - - if (addressSettingsState.value.autoAccept) { - AutoAcceptSection(addressSettingsState = addressSettingsState) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionView(stringResource(MR.strings.address_welcome_message).uppercase()) { + AutoReplyEditor(addressSettingsState) } + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) saveAddressSettingsButton(addressSettingsState.value == savedAddressSettingsState.value) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) @@ -572,18 +569,6 @@ private class AddressSettingsState { } } -@Composable -private fun AutoAcceptSection(addressSettingsState: MutableState) { - SectionView(stringResource(MR.strings.auto_accept_contact).uppercase()) { - if (!chatModel.addressShortLinkDataSet() && !addressSettingsState.value.businessAddress) { - AcceptIncognitoToggle(addressSettingsState) - } - // TODO v6.4.1 show this message editor even with auto-accept disabled - AutoReplyEditor(addressSettingsState) - SectionTextFooter(stringResource(MR.strings.sent_to_your_contact_after_connection)) - } -} - @Composable private fun AcceptIncognitoToggle(addressSettingsState: MutableState) { PreferenceToggleWithIcon( 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 2149649755..45cdee6108 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 @@ -92,7 +92,7 @@ fun UserProfileLayout( shortDescr.value.trim() == (profile.shortDescr ?: "") && profile.image == profileImage.value val closeWithAlert = { - if (dataUnchanged || !canSaveProfile(displayName.value, profile)) { + if (dataUnchanged || !canSaveProfile(displayName.value, shortDescr.value, profile)) { close() } else { showUnsavedChangesAlert({ saveProfile(displayName.value, fullName.value, shortDescr.value, profileImage.value) }, close) @@ -148,18 +148,27 @@ fun UserProfileLayout( ProfileNameField(fullName) } -// TODO enable in v6.4.1, limit to 160 characters + Spacer(Modifier.height(DEFAULT_PADDING)) -// Spacer(Modifier.height(DEFAULT_PADDING)) -// Text( -// stringResource(MR.strings.short_descr__field), -// fontSize = 16.sp, -// modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) -// ) -// ProfileNameField(shortDescr) + Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + stringResource(MR.strings.short_descr__field), + fontSize = 16.sp, + ) + if (!bioFitsLimit(shortDescr.value)) { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) + IconButton( + onClick = { AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.bio_too_large)) }, + Modifier.size(20.dp) + ) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } + } + } + ProfileNameField(shortDescr) Spacer(Modifier.height(DEFAULT_PADDING)) - val enabled = !dataUnchanged && canSaveProfile(displayName.value, profile) + val enabled = !dataUnchanged && canSaveProfile(displayName.value, shortDescr.value, profile) val saveModifier: Modifier = Modifier.clickable(enabled) { saveProfile(displayName.value, fullName.value, shortDescr.value, profileImage.value) } val saveColor: Color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Text( @@ -225,8 +234,8 @@ private fun isValidNewProfileName(displayName: String, profile: Profile): Boolea private fun showFullName(profile: Profile): Boolean = profile.fullName.trim().isNotEmpty() && profile.fullName.trim() != profile.displayName.trim() -private fun canSaveProfile(displayName: String, profile: Profile): Boolean = - displayName.trim().isNotEmpty() && isValidNewProfileName(displayName, profile) +private fun canSaveProfile(displayName: String, shortDescr: String, profile: Profile): Boolean = + displayName.trim().isNotEmpty() && isValidNewProfileName(displayName, profile) && bioFitsLimit(shortDescr) @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, 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 2fe4f5b9a3..0a6b2b539d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1108,6 +1108,7 @@ Profile name: Full name: Bio: + Bio too large Your current profile Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Edit image @@ -1916,6 +1917,7 @@ Enter group name: Group full name: Short description: + Description too large Your chat profile will be sent to group members Your chat profile will be sent to chat members Create group