ui: allow to add/edit profile short description, length limit; allow to set welcome message for address without auto-accept (#6086)

This commit is contained in:
spaced4ndy
2025-07-18 08:39:56 +00:00
committed by GitHub
parent 7d59920399
commit ec061bcbb9
10 changed files with 169 additions and 93 deletions

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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 }

View File

@@ -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",

View File

@@ -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 ?? ""
}
}
}

View File

@@ -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 = {

View File

@@ -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())

View File

@@ -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<AddressSettingsState>) {
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<AddressSettingsState>) {
PreferenceToggleWithIcon(

View File

@@ -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,

View File

@@ -1108,6 +1108,7 @@
<string name="display_name__field">Profile name:</string>
<string name="full_name__field">Full name:</string>
<string name="short_descr__field">Bio:</string>
<string name="bio_too_large">Bio too large</string>
<string name="your_current_profile">Your current profile</string>
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile.</string>
<string name="edit_image">Edit image</string>
@@ -1916,6 +1917,7 @@
<string name="group_display_name_field">Enter group name:</string>
<string name="group_full_name_field">Group full name:</string>
<string name="group_short_descr_field">Short description:</string>
<string name="group_descr_too_large">Description too large</string>
<string name="group_main_profile_sent">Your chat profile will be sent to group members</string>
<string name="chat_main_profile_sent">Your chat profile will be sent to chat members</string>
<string name="create_group_button">Create group</string>