diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 26e71dbf6c..67ba1079cb 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -162,16 +162,16 @@ func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User { try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd)) } -func apiUnhideUser(_ userId: Int64, viewPwd: String?) async throws -> User { +func apiUnhideUser(_ userId: Int64, viewPwd: String) async throws -> User { try await setUserPrivacy_(.apiUnhideUser(userId: userId, viewPwd: viewPwd)) } -func apiMuteUser(_ userId: Int64, viewPwd: String?) async throws -> User { - try await setUserPrivacy_(.apiMuteUser(userId: userId, viewPwd: viewPwd)) +func apiMuteUser(_ userId: Int64) async throws -> User { + try await setUserPrivacy_(.apiMuteUser(userId: userId)) } -func apiUnmuteUser(_ userId: Int64, viewPwd: String?) async throws -> User { - try await setUserPrivacy_(.apiUnmuteUser(userId: userId, viewPwd: viewPwd)) +func apiUnmuteUser(_ userId: Int64) async throws -> User { + try await setUserPrivacy_(.apiUnmuteUser(userId: userId)) } func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index ef41b9919e..9bbf844e4a 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -44,10 +44,12 @@ struct UserProfilesView: View { private enum UserProfileAction: Identifiable { case deleteUser(user: User, delSMPQueues: Bool) + case unhideUser(user: User) var id: String { switch self { case let .deleteUser(user, delSMPQueues): return "deleteUser \(user.userId) \(delSMPQueues)" + case let .unhideUser(user): return "unhideUser \(user.userId)" } } } @@ -207,43 +209,58 @@ struct UserProfilesView: View { @ViewBuilder private func profileActionView(_ action: UserProfileAction) -> some View { let passwordValid = actionPassword == actionPassword.trimmingCharacters(in: .whitespaces) - switch action { - case let .deleteUser(user, delSMPQueues): - let actionEnabled = actionPassword != "" && passwordValid && correctPassword(user, actionPassword) - List { - Text("Delete user") - .font(.title) - .bold() - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - .listRowBackground(Color.clear) - - Section() { - ProfilePreview(profileOf: user).padding(.leading, -8) - } - + let passwordField = PassphraseField(key: $actionPassword, placeholder: "Profile password", valid: passwordValid) + let actionEnabled: (User) -> Bool = { user in actionPassword != "" && passwordValid && correctPassword(user, actionPassword) } + List { + switch action { + case let .deleteUser(user, delSMPQueues): + actionHeader("Delete user", user) Section { - PassphraseField(key: $actionPassword, placeholder: "Profile password", valid: passwordValid) + passwordField settingsRow("trash") { Button("Delete user", role: .destructive) { profileAction = nil Task { await removeUser(user, delSMPQueues, viewPwd: actionPassword) } } - .disabled(!actionEnabled) + .disabled(!actionEnabled(user)) } } footer: { - if actionEnabled { + if actionEnabled(user) { Text("All chats and messages will be deleted - this cannot be undone!") .font(.callout) } } + case let .unhideUser(user): + actionHeader("Unhide user", user) + Section { + passwordField + settingsRow("lock.open") { + Button("Unhide user") { + profileAction = nil + setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: actionPassword) } + } + .disabled(!actionEnabled(user)) + } + } } } } + @ViewBuilder func actionHeader(_ title: LocalizedStringKey, _ user: User) -> some View { + Text(title) + .font(.title) + .bold() + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + Section() { + ProfilePreview(profileOf: user).padding(.leading, -8) + } + } + private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View { Button(title, role: .destructive) { if let user = userToDelete { - if user.hidden && user.activeUser && !correctPassword(user, searchTextOrPassword) { + if passwordEntryRequired(user) { profileAction = .deleteUser(user: user, delSMPQueues: delSMPQueues) } else { alert = .deleteUser(user: user, delSMPQueues: delSMPQueues) @@ -252,6 +269,10 @@ struct UserProfilesView: View { } } + private func passwordEntryRequired(_ user: User) -> Bool { + user.hidden && user.activeUser && !correctPassword(user, searchTextOrPassword) + } + private func removeUser(_ user: User, _ delSMPQueues: Bool, viewPwd: String?) async { do { if user.activeUser { @@ -307,7 +328,11 @@ struct UserProfilesView: View { .swipeActions(edge: .leading, allowsFullSwipe: true) { if user.hidden { Button("Unhide") { - setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: userViewPassword(user)) } + if passwordEntryRequired(user) { + profileAction = .unhideUser(user: user) + } else { + setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: searchTextOrPassword) } + } } .tint(.green) } else { @@ -321,12 +346,12 @@ struct UserProfilesView: View { if user.showNtfs { Button("Mute") { setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) { - try await apiMuteUser(user.userId, viewPwd: userViewPassword(user)) + try await apiMuteUser(user.userId) } } } else { Button("Unmute") { - setUserPrivacy(user) { try await apiUnmuteUser(user.userId, viewPwd: userViewPassword(user)) } + setUserPrivacy(user) { try await apiUnmuteUser(user.userId) } } } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 246aea2309..40ff9f46bb 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -18,9 +18,9 @@ public enum ChatCommand { case listUsers case apiSetActiveUser(userId: Int64, viewPwd: String?) case apiHideUser(userId: Int64, viewPwd: String) - case apiUnhideUser(userId: Int64, viewPwd: String?) - case apiMuteUser(userId: Int64, viewPwd: String?) - case apiUnmuteUser(userId: Int64, viewPwd: String?) + case apiUnhideUser(userId: Int64, viewPwd: String) + case apiMuteUser(userId: Int64) + case apiUnmuteUser(userId: Int64) case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?) case startChat(subscribe: Bool, expire: Bool) case apiStopChat @@ -111,9 +111,9 @@ public enum ChatCommand { case .listUsers: return "/users" case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))" case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))" - case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId)\(maybePwd(viewPwd))" - case let .apiMuteUser(userId, viewPwd): return "/_mute user \(userId)\(maybePwd(viewPwd))" - case let .apiUnmuteUser(userId, viewPwd): return "/_unmute user \(userId)\(maybePwd(viewPwd))" + case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))" + case let .apiMuteUser(userId): return "/_mute user \(userId)" + case let .apiUnmuteUser(userId): return "/_unmute user \(userId)" case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))" case let .startChat(subscribe, expire): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire))" case .apiStopChat: return "/_stop" @@ -332,10 +332,6 @@ public enum ChatCommand { return .apiHideUser(userId: userId, viewPwd: obfuscate(viewPwd)) case let .apiUnhideUser(userId, viewPwd): return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd)) - case let .apiMuteUser(userId, viewPwd): - return .apiMuteUser(userId: userId, viewPwd: obfuscate(viewPwd)) - case let .apiUnmuteUser(userId, viewPwd): - return .apiUnmuteUser(userId: userId, viewPwd: obfuscate(viewPwd)) case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) default: return self @@ -349,9 +345,8 @@ public enum ChatCommand { private func obfuscate(_ s: String?) -> String? { if let s = s { return obfuscate(s) - } else { - return nil } + return nil } private func onOff(_ b: Bool) -> String { diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 1102a98b1e..10ac7c64da 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -335,7 +335,7 @@ processChatCommand = \case tryError (withStore (`getUserIdByName` uName)) >>= \case Left _ -> throwChatError CEUserUnknown Right userId -> processChatCommand $ APISetActiveUser userId viewPwd_ - APIHideUser userId' (UserPwd viewPwd) -> withUser $ \_ -> do + APIHideUser userId' (UserPwd viewPwd) -> withUser $ \user -> do user' <- privateGetUser userId' case viewPwdHash user' of Just _ -> throwChatError $ CEUserAlreadyHidden userId' @@ -344,34 +344,26 @@ processChatCommand = \case users <- withStore' getUsers unless (length (filter (isNothing . viewPwdHash) users) > 1) $ throwChatError $ CECantHideLastUser userId' viewPwdHash' <- hashPassword - setUserPrivacy user' {viewPwdHash = viewPwdHash', showNtfs = False} + setUserPrivacy user user' {viewPwdHash = viewPwdHash', showNtfs = False} where hashPassword = do salt <- drgRandomBytes 16 let hash = B64UrlByteString $ C.sha512Hash $ encodeUtf8 viewPwd <> salt pure $ Just UserPwdHash {hash, salt = B64UrlByteString salt} - APIUnhideUser userId' viewPwd_ -> withUser $ \user -> do + APIUnhideUser userId' viewPwd@(UserPwd pwd) -> withUser $ \user -> do user' <- privateGetUser userId' case viewPwdHash user' of Nothing -> throwChatError $ CEUserNotHidden userId' _ -> do - validateUserPassword user user' viewPwd_ - setUserPrivacy user' {viewPwdHash = Nothing, showNtfs = True} - APIMuteUser userId' viewPwd_ -> withUser $ \user -> do - user' <- privateGetUser userId' - validateUserPassword user user' viewPwd_ - setUserPrivacy user' {showNtfs = False} - APIUnmuteUser userId' viewPwd_ -> withUser $ \user -> do - user' <- privateGetUser userId' - case viewPwdHash user' of - Just _ -> throwChatError $ CECantUnmuteHiddenUser userId' - _ -> do - validateUserPassword user user' viewPwd_ - setUserPrivacy user' {showNtfs = True} + when (T.null pwd) $ throwChatError $ CEEmptyUserPassword userId' + validateUserPassword user user' $ Just viewPwd + setUserPrivacy user user' {viewPwdHash = Nothing, showNtfs = True} + APIMuteUser userId' -> setUserNotifications userId' False + APIUnmuteUser userId' -> setUserNotifications userId' True HideUser viewPwd -> withUser $ \User {userId} -> processChatCommand $ APIHideUser userId viewPwd - UnhideUser -> withUser $ \User {userId} -> processChatCommand $ APIUnhideUser userId Nothing - MuteUser -> withUser $ \User {userId} -> processChatCommand $ APIMuteUser userId Nothing - UnmuteUser -> withUser $ \User {userId} -> processChatCommand $ APIUnmuteUser userId Nothing + UnhideUser viewPwd -> withUser $ \User {userId} -> processChatCommand $ APIUnhideUser userId viewPwd + MuteUser -> withUser $ \User {userId} -> processChatCommand $ APIMuteUser userId + UnmuteUser -> withUser $ \User {userId} -> processChatCommand $ APIUnmuteUser userId APIDeleteUser userId' delSMPQueues viewPwd_ -> withUser $ \user -> do user' <- privateGetUser userId' validateUserPassword user user' viewPwd_ @@ -1706,11 +1698,21 @@ processChatCommand = \case validPassword :: Text -> UserPwdHash -> Bool validPassword pwd UserPwdHash {hash = B64UrlByteString hash, salt = B64UrlByteString salt} = hash == C.sha512Hash (encodeUtf8 pwd <> salt) - setUserPrivacy :: User -> m ChatResponse - setUserPrivacy user = do - asks currentUser >>= atomically . (`writeTVar` Just user) - withStore' (`updateUserPrivacy` user) - pure $ CRUserPrivacy user + setUserNotifications :: UserId -> Bool -> m ChatResponse + setUserNotifications userId' showNtfs = withUser $ \user -> do + user' <- privateGetUser userId' + case viewPwdHash user' of + Just _ -> throwChatError $ CEHiddenUserAlwaysMuted userId' + _ -> setUserPrivacy user user' {showNtfs} + setUserPrivacy :: User -> User -> m ChatResponse + setUserPrivacy user@User {userId} user'@User {userId = userId'} + | userId == userId' = do + asks currentUser >>= atomically . (`writeTVar` Just user') + withStore' (`updateUserPrivacy` user') + pure $ CRUserPrivacy {user = user', updatedUser = user'} + | otherwise = do + withStore' (`updateUserPrivacy` user') + pure $ CRUserPrivacy {user, updatedUser = user'} checkDeleteChatUser :: User -> m () checkDeleteChatUser user@User {userId} = do when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId) @@ -4319,11 +4321,11 @@ chatCommandP = "/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)), ("/user " <|> "/u ") *> (SetActiveUser <$> displayName <*> optional (A.space *> pwdP)), "/_hide user " *> (APIHideUser <$> A.decimal <* A.space <*> jsonP), - "/_unhide user " *> (APIUnhideUser <$> A.decimal <*> optional (A.space *> jsonP)), - "/_mute user " *> (APIMuteUser <$> A.decimal <*> optional (A.space *> jsonP)), - "/_unmute user " *> (APIUnmuteUser <$> A.decimal <*> optional (A.space *> jsonP)), + "/_unhide user " *> (APIUnhideUser <$> A.decimal <* A.space <*> jsonP), + "/_mute user " *> (APIMuteUser <$> A.decimal), + "/_unmute user " *> (APIUnmuteUser <$> A.decimal), "/hide user " *> (HideUser <$> pwdP), - "/unhide user" $> UnhideUser, + "/unhide user " *> (UnhideUser <$> pwdP), "/mute user" $> MuteUser, "/unmute user" $> UnmuteUser, "/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7bd7d6fd4f..74ed4dca8a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -190,11 +190,11 @@ data ChatCommand | APISetActiveUser UserId (Maybe UserPwd) | SetActiveUser UserName (Maybe UserPwd) | APIHideUser UserId UserPwd - | APIUnhideUser UserId (Maybe UserPwd) - | APIMuteUser UserId (Maybe UserPwd) - | APIUnmuteUser UserId (Maybe UserPwd) + | APIUnhideUser UserId UserPwd + | APIMuteUser UserId + | APIUnmuteUser UserId | HideUser UserPwd - | UnhideUser + | UnhideUser UserPwd | MuteUser | UnmuteUser | APIDeleteUser UserId Bool (Maybe UserPwd) @@ -421,7 +421,7 @@ data ChatResponse | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus | CRUserProfile {user :: User, profile :: Profile} | CRUserProfileNoChange {user :: User} - | CRUserPrivacy {user :: User} + | CRUserPrivacy {user :: User, updatedUser :: User} | CRVersionInfo {versionInfo :: CoreVersionInfo, chatMigrations :: [UpMigration], agentMigrations :: [UpMigration]} | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation} | CRSentConfirmation {user :: User} @@ -741,7 +741,7 @@ data ChatErrorType | CECantDeleteActiveUser {userId :: UserId} | CECantDeleteLastUser {userId :: UserId} | CECantHideLastUser {userId :: UserId} - | CECantUnmuteHiddenUser {userId :: UserId} + | CEHiddenUserAlwaysMuted {userId :: UserId} | CEEmptyUserPassword {userId :: UserId} | CEUserAlreadyHidden {userId :: UserId} | CEUserNotHidden {userId :: UserId} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2352b1e94b..8c6f164085 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -116,7 +116,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus CRUserProfile u p -> ttyUser u $ viewUserProfile p CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] - CRUserPrivacy u -> ttyUserPrefix u $ viewUserPrivacy u + CRUserPrivacy u u' -> ttyUserPrefix u $ viewUserPrivacy u u' CRVersionInfo info _ _ -> viewVersionInfo logLevel info CRInvitation u cReq -> ttyUser u $ viewConnReqInvitation cReq CRSentConfirmation u -> ttyUser u ["confirmation sent!"] @@ -740,10 +740,11 @@ viewUserProfile Profile {displayName, fullName} = "(the updated profile will be sent to all your contacts)" ] -viewUserPrivacy :: User -> [StyledString] -viewUserPrivacy User {showNtfs, viewPwdHash} = - [ "user messages are " <> if showNtfs then "shown" else "hidden (use /tail to view)", - "user profile is " <> if isJust viewPwdHash then "hidden" else "visible" +viewUserPrivacy :: User -> User -> [StyledString] +viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', showNtfs, viewPwdHash} = + [ (if userId == userId' then "current " else "") <> "user " <> plain n' <> ":", + "messages are " <> if showNtfs then "shown" else "hidden (use /tail to view)", + "profile is " <> if isJust viewPwdHash then "hidden" else "visible" ] -- TODO make more generic messages or split @@ -1241,8 +1242,8 @@ viewChatError logLevel = \case CECantDeleteActiveUser _ -> ["cannot delete active user"] CECantDeleteLastUser _ -> ["cannot delete last user"] CECantHideLastUser _ -> ["cannot hide the only not hidden user"] - CECantUnmuteHiddenUser _ -> ["cannot unmute hidden user"] - CEEmptyUserPassword _ -> ["cannot set empty password"] + CEHiddenUserAlwaysMuted _ -> ["hidden user always muted when inactive"] + CEEmptyUserPassword _ -> ["user password is required"] CEUserAlreadyHidden _ -> ["user is already hidden"] CEUserNotHidden _ -> ["user is not hidden"] CEChatNotStarted -> ["error: chat not started"] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 6116eeb29e..f80027a3cd 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1518,7 +1518,7 @@ testUserPrivacy = alice <# "bob> hey" -- hide user profile alice ##> "/hide user my_password" - userHidden alice + userHidden alice "current " -- shows messages when active bob #> "@alisa hello again" alice <# "bob> hello again" @@ -1555,13 +1555,15 @@ testUserPrivacy = ] -- change profile password alice ##> "/unmute user" - alice <## "cannot unmute hidden user" + alice <## "hidden user always muted when inactive" alice ##> "/hide user password" alice <## "user is already hidden" - alice ##> "/unhide user" - userVisible alice + alice ##> "/unhide user wrong_password" + alice <## "user does not exist or incorrect password" + alice ##> "/unhide user my_password" + userVisible alice "current " alice ##> "/hide user new_password" - userHidden alice + userHidden alice "current " alice ##> "/_delete user 1 del_smp=on" alice <## "cannot delete last user" alice ##> "/_hide user 1 \"password\"" @@ -1570,18 +1572,15 @@ testUserPrivacy = showActiveUser alice "alice (Alice)" -- change profile privacy for inactive user via API requires correct password alice ##> "/_unmute user 2" - alice <## "cannot unmute hidden user" + alice <## "hidden user always muted when inactive" alice ##> "/_hide user 2 \"password\"" alice <## "user is already hidden" - alice ##> "/_unhide user 2" - alice <## "user does not exist or incorrect password" alice ##> "/_unhide user 2 \"wrong_password\"" alice <## "user does not exist or incorrect password" alice ##> "/_unhide user 2 \"new_password\"" - userVisible alice + userVisible alice "" alice ##> "/_hide user 2 \"another_password\"" - userHidden alice - -- check new password + userHidden alice "" alice ##> "/user alisa another_password" showActiveUser alice "alisa" alice ##> "/user alice" @@ -1594,12 +1593,14 @@ testUserPrivacy = alice <## "ok" alice <## "completed deleting user" where - userHidden alice = do - alice <## "user messages are hidden (use /tail to view)" - alice <## "user profile is hidden" - userVisible alice = do - alice <## "user messages are shown" - alice <## "user profile is visible" + userHidden alice current = do + alice <## (current <> "user alisa:") + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + userVisible alice current = do + alice <## (current <> "user alisa:") + alice <## "messages are shown" + alice <## "profile is visible" testSetChatItemTTL :: HasCallStack => FilePath -> IO () testSetChatItemTTL =