diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index c8ceacae5a..8448475a58 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -439,18 +439,18 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a suspend fun apiHideUser(userId: Long, viewPwd: String): User = setUserPrivacy(CC.ApiHideUser(userId, viewPwd)) - suspend fun apiUnhideUser(userId: Long, viewPwd: String?): User = + suspend fun apiUnhideUser(userId: Long, viewPwd: String): User = setUserPrivacy(CC.ApiUnhideUser(userId, viewPwd)) - suspend fun apiMuteUser(userId: Long, viewPwd: String?): User = - setUserPrivacy(CC.ApiMuteUser(userId, viewPwd)) + suspend fun apiMuteUser(userId: Long): User = + setUserPrivacy(CC.ApiMuteUser(userId)) - suspend fun apiUnmuteUser(userId: Long, viewPwd: String?): User = - setUserPrivacy(CC.ApiUnmuteUser(userId, viewPwd)) + suspend fun apiUnmuteUser(userId: Long): User = + setUserPrivacy(CC.ApiUnmuteUser(userId)) private suspend fun setUserPrivacy(cmd: CC): User { val r = sendCmd(cmd) - if (r is CR.UserPrivacy) return r.user + if (r is CR.UserPrivacy) return r.updatedUser else throw Exception("Failed to change user privacy: ${r.responseType} ${r.details}") } @@ -1796,9 +1796,9 @@ sealed class CC { class ListUsers: CC() class ApiSetActiveUser(val userId: Long, val viewPwd: String?): CC() class ApiHideUser(val userId: Long, val viewPwd: String): CC() - class ApiUnhideUser(val userId: Long, val viewPwd: String?): CC() - class ApiMuteUser(val userId: Long, val viewPwd: String?): CC() - class ApiUnmuteUser(val userId: Long, val viewPwd: String?): CC() + class ApiUnhideUser(val userId: Long, val viewPwd: String): CC() + class ApiMuteUser(val userId: Long): CC() + class ApiUnmuteUser(val userId: Long): CC() class ApiDeleteUser(val userId: Long, val delSMPQueues: Boolean, val viewPwd: String?): CC() class StartChat(val expire: Boolean): CC() class ApiStopChat: CC() @@ -1879,9 +1879,9 @@ sealed class CC { is ListUsers -> "/users" is ApiSetActiveUser -> "/_user $userId${maybePwd(viewPwd)}" is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}" - is ApiUnhideUser -> "/_unhide user $userId${maybePwd(viewPwd)}" - is ApiMuteUser -> "/_mute user $userId${maybePwd(viewPwd)}" - is ApiUnmuteUser -> "/_unmute user $userId${maybePwd(viewPwd)}" + is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}" + is ApiMuteUser -> "/_mute user $userId" + is ApiUnmuteUser -> "/_unmute user $userId" is ApiDeleteUser -> "/_delete user $userId del_smp=${onOff(delSMPQueues)}${maybePwd(viewPwd)}" is StartChat -> "/_start subscribe=on expire=${onOff(expire)}" is ApiStopChat -> "/_stop" @@ -2052,9 +2052,7 @@ sealed class CC { is ApiStorageEncryption -> ApiStorageEncryption(DBEncryptionConfig(obfuscate(config.currentKey), obfuscate(config.newKey))) is ApiSetActiveUser -> ApiSetActiveUser(userId, obfuscateOrNull(viewPwd)) is ApiHideUser -> ApiHideUser(userId, obfuscate(viewPwd)) - is ApiUnhideUser -> ApiUnhideUser(userId, obfuscateOrNull(viewPwd)) - is ApiMuteUser -> ApiMuteUser(userId, obfuscateOrNull(viewPwd)) - is ApiUnmuteUser -> ApiUnmuteUser(userId, obfuscateOrNull(viewPwd)) + is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd)) is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd)) else -> this } @@ -2990,7 +2988,7 @@ sealed class CR { @Serializable @SerialName("chatCleared") class ChatCleared(val user: User, val chatInfo: ChatInfo): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile): CR() - @Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User): CR() + @Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User, val updatedUser: User): CR() @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: User, val toContact: Contact): CR() @Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: User, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: User, val fromContact: Contact, val toContact: Contact): CR() @@ -3200,7 +3198,7 @@ sealed class CR { is ChatCleared -> withUser(user, json.encodeToString(chatInfo)) is UserProfileNoChange -> withUser(user, noDetails()) is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) - is UserPrivacy -> withUser(user, "") + is UserPrivacy -> withUser(user, json.encodeToString(updatedUser)) is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact)) is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection)) is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}") diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HiddenProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HiddenProfileView.kt index b9fa39b11d..a755313126 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HiddenProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HiddenProfileView.kt @@ -69,11 +69,12 @@ private fun HiddenProfileLayout( val hidePassword = rememberSaveable { mutableStateOf("") } val confirmHidePassword = rememberSaveable { mutableStateOf("") } + val passwordValid by remember { derivedStateOf { hidePassword.value == hidePassword.value.trim() } } val confirmValid by remember { derivedStateOf { confirmHidePassword.value == "" || hidePassword.value == confirmHidePassword.value } } - val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || confirmHidePassword.value == "" || !confirmValid } } + val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || !passwordValid || confirmHidePassword.value == "" || !confirmValid } } SectionView(stringResource(R.string.hidden_profile_password).uppercase()) { SectionItemView { - PassphraseField(hidePassword, generalGetString(R.string.password_to_show), isValid = { true }, showStrength = true) + PassphraseField(hidePassword, generalGetString(R.string.password_to_show), isValid = { passwordValid }, showStrength = true) } SectionDivider() SectionItemView { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt index 960da1a88d..046362512c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt @@ -2,9 +2,11 @@ package chat.simplex.app.views.usersettings import SectionDivider import SectionItemView +import SectionItemViewSpaceBetween import SectionSpacer import SectionTextFooter import SectionView +import androidx.annotation.StringRes import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -24,6 +26,8 @@ import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.item.ItemAction import chat.simplex.app.views.chatlist.UserProfilePickerItem +import chat.simplex.app.views.chatlist.UserProfileRow +import chat.simplex.app.views.database.PassphraseField import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.CreateProfile import kotlinx.coroutines.delay @@ -93,15 +97,28 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: } }, unhideUser = { user -> - setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, userViewPassword(user, searchTextOrPassword.value)) } + if (passwordEntryRequired(user, searchTextOrPassword.value)) { + ModalManager.shared.showModalCloseable(true) { close -> + ProfileActionView(UserProfileAction.UNHIDE, user) { pwd -> + withBGApi { + setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, pwd) } + close() + } + } + } + } else { + withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, searchTextOrPassword.value) } } + } }, muteUser = { user -> - setUserPrivacy(m, onSuccess = { if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert) }) { - m.controller.apiMuteUser(user.userId, userViewPassword(user, searchTextOrPassword.value)) + withBGApi { + setUserPrivacy(m, onSuccess = { + if (m.controller.appPrefs.showMuteProfileAlert.get()) showMuteProfileAlert(m.controller.appPrefs.showMuteProfileAlert) + }) { m.controller.apiMuteUser(user.userId) } } }, unmuteUser = { user -> - setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId, userViewPassword(user, searchTextOrPassword.value)) } + withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId) } } }, showHiddenProfile = { user -> ModalManager.shared.showModalCloseable(true) { close -> @@ -235,60 +252,129 @@ private fun UserView( } } +enum class UserProfileAction { + DELETE, + UNHIDE +} + +@Composable +private fun ProfileActionView(action: UserProfileAction, user: User, doAction: (String) -> Unit) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(bottom = DEFAULT_BOTTOM_PADDING), + ) { + val actionPassword = rememberSaveable { mutableStateOf("") } + val passwordValid by remember { derivedStateOf { actionPassword.value == actionPassword.value.trim() } } + val actionEnabled by remember { derivedStateOf { actionPassword.value != "" && passwordValid && correctPassword(user, actionPassword.value) } } + + @Composable fun ActionHeader(@StringRes title: Int) { + AppBarTitle(stringResource(title)) + SectionView(padding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) { + UserProfileRow(user) + } + SectionSpacer() + } + + @Composable fun PasswordAndAction(@StringRes label: Int, color: Color = MaterialTheme.colors.primary) { + SectionView() { + SectionItemView { + PassphraseField(actionPassword, generalGetString(R.string.profile_password), isValid = { passwordValid }, showStrength = true) + } + SectionItemViewSpaceBetween({ doAction(actionPassword.value) }, disabled = !actionEnabled, minHeight = TextFieldDefaults.MinHeight) { + Text(generalGetString(label), color = if (actionEnabled) color else HighOrLowlight) + } + } + } + + when (action) { + UserProfileAction.DELETE -> { + ActionHeader(R.string.delete_profile) + PasswordAndAction(R.string.delete_chat_profile, color = Color.Red) + if (actionEnabled) { + SectionTextFooter(stringResource(R.string.users_delete_all_chats_deleted)) + } + } + UserProfileAction.UNHIDE -> { + ActionHeader(R.string.unhide_profile) + PasswordAndAction(R.string.unhide_chat_profile) + } + } + } +} + private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List { val s = searchTextOrPassword.trim() val lower = s.lowercase() return m.users.filter { u -> - if ((u.user.activeUser || u.user.viewPwdHash == null) && (s == "" || u.user.chatViewName.lowercase().contains(lower))) { + if ((u.user.activeUser || !u.user.hidden) && (s == "" || u.user.chatViewName.lowercase().contains(lower))) { true - } else if (u.user.viewPwdHash != null) { - s != "" && chatPasswordHash(s, u.user.viewPwdHash.salt) == u.user.viewPwdHash.hash } else { - false + correctPassword(u.user, s) } }.map { it.user } } private fun visibleUsersCount(m: ChatModel): Int = m.users.filter { u -> !u.user.hidden }.size +private fun correctPassword(user: User, pwd: String): Boolean { + val ph = user.viewPwdHash + return ph != null && pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash +} + private fun userViewPassword(user: User, searchTextOrPassword: String): String? = - if (user.activeUser || !user.hidden) null else searchTextOrPassword + if (user.hidden) searchTextOrPassword else null + +private fun passwordEntryRequired(user: User, searchTextOrPassword: String): Boolean = + user.hidden && user.activeUser && !correctPassword(user, searchTextOrPassword) private fun removeUser(m: ChatModel, user: User, users: List, delSMPQueues: Boolean, searchTextOrPassword: String) { - if (users.size < 2) return - - withBGApi { - suspend fun deleteUser(user: User) { - m.controller.apiDeleteUser(user.userId, delSMPQueues, userViewPassword(user, searchTextOrPassword)) - m.removeUser(user) - } - try { - if (user.activeUser) { - val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden } - if (newActive != null) { - m.controller.changeActiveUser_(newActive.userId, null) - deleteUser(user.copy(activeUser = false)) + if (passwordEntryRequired(user, searchTextOrPassword)) { + ModalManager.shared.showModalCloseable(true) { close -> + ProfileActionView(UserProfileAction.DELETE, user) { pwd -> + withBGApi { + doRemoveUser(m, user, users, delSMPQueues, pwd) + close() } - } else { - deleteUser(user) } - } catch (e: Exception) { - AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_user), e.stackTraceToString()) } + } else { + withBGApi { doRemoveUser(m, user, users, delSMPQueues, userViewPassword(user, searchTextOrPassword)) } } } -private fun setUserPrivacy(m: ChatModel, onSuccess: (() -> Unit)? = null, api: suspend () -> User) { - withBGApi { - try { - m.updateUser(api()) - onSuccess?.invoke() - } catch (e: Exception) { - AlertManager.shared.showAlertMsg( - title = generalGetString(R.string.error_updating_user_privacy), - text = e.stackTraceToString() - ) +private suspend fun doRemoveUser(m: ChatModel, user: User, users: List, delSMPQueues: Boolean, viewPwd: String?) { + if (users.size < 2) return + + suspend fun deleteUser(user: User) { + m.controller.apiDeleteUser(user.userId, delSMPQueues, viewPwd) + m.removeUser(user) + } + try { + if (user.activeUser) { + val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden } + if (newActive != null) { + m.controller.changeActiveUser_(newActive.userId, null) + deleteUser(user.copy(activeUser = false)) + } + } else { + deleteUser(user) } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(R.string.error_deleting_user), e.stackTraceToString()) + } +} + +private suspend fun setUserPrivacy(m: ChatModel, onSuccess: (() -> Unit)? = null, api: suspend () -> User) { + try { + m.updateUser(api()) + onSuccess?.invoke() + } catch (e: Exception) { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.error_updating_user_privacy), + text = e.stackTraceToString() + ) } } diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 8137e48ab4..6e0ea8f02a 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -1056,6 +1056,11 @@ Don\'t show again Muted when inactive! You will still receive calls and notifications from muted profiles when they are active. + Delete profile + Delete chat profile + Unhide profile + Unhide chat profile + Profile password Incognito diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 67ba1079cb..ceb7c16adb 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -176,7 +176,7 @@ func apiUnmuteUser(_ userId: Int64) async throws -> User { func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User { let r = await chatSendCmd(cmd) - if case let .userPrivacy(user) = r { return user } + if case let .userPrivacy(_, updatedUser) = r { return updatedUser } throw r } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 9bbf844e4a..5f34903ac8 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -182,13 +182,10 @@ struct UserProfilesView: View { let s = searchTextOrPassword.trimmingCharacters(in: .whitespaces) let lower = s.localizedLowercase return m.users.filter { u in - if (u.user.activeUser || u.user.viewPwdHash == nil) && (s == "" || u.user.chatViewName.localizedLowercase.contains(lower)) { + if (u.user.activeUser || !u.user.hidden) && (s == "" || u.user.chatViewName.localizedLowercase.contains(lower)) { return true } - if let ph = u.user.viewPwdHash { - return s != "" && chatPasswordHash(s, ph.salt) == ph.hash - } - return false + return correctPassword(u.user, s) } } @@ -214,11 +211,11 @@ struct UserProfilesView: View { List { switch action { case let .deleteUser(user, delSMPQueues): - actionHeader("Delete user", user) + actionHeader("Delete profile", user) Section { passwordField settingsRow("trash") { - Button("Delete user", role: .destructive) { + Button("Delete chat profile", role: .destructive) { profileAction = nil Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) } } @@ -231,11 +228,11 @@ struct UserProfilesView: View { } } case let .unhideUser(user): - actionHeader("Unhide user", user) + actionHeader("Unhide profile", user) Section { passwordField settingsRow("lock.open") { - Button("Unhide user") { + Button("Unhide chat profile") { profileAction = nil setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 40ff9f46bb..069c652902 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -389,7 +389,7 @@ public enum ChatResponse: Decodable, Error { case chatCleared(user: User, chatInfo: ChatInfo) case userProfileNoChange(user: User) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile) - case userPrivacy(user: User) + case userPrivacy(user: User, updatedUser: User) case contactAliasUpdated(user: User, toContact: Contact) case connectionAliasUpdated(user: User, toConnection: PendingContactConnection) case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) @@ -611,7 +611,7 @@ public enum ChatResponse: Decodable, Error { case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case .userProfileNoChange: return noDetails case let .userProfileUpdated(u, _, toProfile): return withUser(u, String(describing: toProfile)) - case let .userPrivacy(u): return withUser(u, "") + case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser)) case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))")