From a0ec5ed9ddea1e42a278551074987e394a1c6f66 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:21:51 +0100 Subject: [PATCH] Add the UI for your own user status in the Settings screen. (#5888) * Show the user's own Status emoji on the SettingsScreen. * Add the UI for setting your own status. * Add placeholder action for selecting a custom emoji and fix some accessibility issues. --- .../Sources/GeneratedAccessibilityTests.swift | 8 ++ ElementX.xcodeproj/project.pbxproj | 8 ++ .../en-US.lproj/Localizable.strings | 19 +-- .../en.lproj/Localizable.strings | 19 +-- .../SettingsFlowCoordinator.swift | 3 +- ElementX/Sources/Generated/Strings.swift | 38 +++--- ElementX/Sources/Mocks/ClientProxyMock.swift | 7 +- .../TestablePreviewsDictionary.swift | 2 + .../SettingsScreenCoordinator.swift | 4 +- .../SettingsScreen/SettingsScreenModels.swift | 27 ++++ .../SettingsScreenViewModel.swift | 43 +++++- .../SettingsScreen/View/SettingsScreen.swift | 41 +++++- .../SettingsScreenUserStatusPickerView.swift | 78 +++++++++++ .../View/SettingsScreenUserStatusRow.swift | 121 +++++++++++++++++ .../Sources/GeneratedPreviewTests.swift | 14 ++ ...sScreen.Bug-report-disabled-iPad-en-GB.png | 4 +- ...Screen.Bug-report-disabled-iPad-pseudo.png | 4 +- ...creen.Bug-report-disabled-iPhone-en-GB.png | 4 +- ...reen.Bug-report-disabled-iPhone-pseudo.png | 4 +- .../settingsScreen.Default-iPad-en-GB.png | 4 +- .../settingsScreen.Default-iPad-pseudo.png | 4 +- .../settingsScreen.Default-iPhone-en-GB.png | 4 +- .../settingsScreen.Default-iPhone-pseudo.png | 4 +- ...creenUserStatusPickerView.iPad-en-GB-0.png | 3 + ...reenUserStatusPickerView.iPad-pseudo-0.png | 3 + ...eenUserStatusPickerView.iPhone-en-GB-0.png | 3 + ...enUserStatusPickerView.iPhone-pseudo-0.png | 3 + ...ttingsScreenUserStatusRow.iPad-en-GB-0.png | 3 + ...tingsScreenUserStatusRow.iPad-pseudo-0.png | 3 + ...ingsScreenUserStatusRow.iPhone-en-GB-0.png | 3 + ...ngsScreenUserStatusRow.iPhone-pseudo-0.png | 3 + .../SettingsScreenViewModelTests.swift | 125 +++++++++++++++--- 32 files changed, 533 insertions(+), 80 deletions(-) create mode 100644 ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusPickerView.swift create mode 100644 ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPad-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPad-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPhone-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPhone-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-pseudo-0.png diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 389abf909..693140736 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -663,6 +663,14 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "SessionVerification_Previews") } + func testSettingsScreenUserStatusPickerView() async throws { + try await performAccessibilityAudit(named: "SettingsScreenUserStatusPickerView_Previews") + } + + func testSettingsScreenUserStatusRow() async throws { + try await performAccessibilityAudit(named: "SettingsScreenUserStatusRow_Previews") + } + func testSettingsScreen() async throws { try await performAccessibilityAudit(named: "SettingsScreen_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 4cd845aa9..39a8eae0f 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -569,6 +569,7 @@ 5DD85A0FE3D85AEC3C7EFE36 /* DeveloperOptionsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */; }; 5DFC2A889D3B39DD47AC63A8 /* PillUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C537DE821FED94D23467B6C4 /* PillUtilities.swift */; }; 5E597B9959BDAE7A67DBD5B2 /* LinkMetadataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C78111573987B1D79ED0868 /* LinkMetadataProvider.swift */; }; + 5E67990F98AF11563EA50080 /* SettingsScreenUserStatusPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06775BFC4D00C62B7E1353A8 /* SettingsScreenUserStatusPickerView.swift */; }; 5E73893669BB56307A44AAAD /* RoomListActivityVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5DA892E8643240C7BC41900 /* RoomListActivityVisibility.swift */; }; 5EB116B58533C9A0EBA22717 /* ThreadTimelineScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2E7CEE6E9314FD69FE0ED9 /* ThreadTimelineScreen.swift */; }; 5EDBDE802761B5ECB54E6787 /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2711E5996016ABD6EAAEB58A /* LogLevel.swift */; }; @@ -1125,6 +1126,7 @@ B6DF6B6FA8734B70F9BF261E /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */; }; B6EC2148FA5443C9289BEEBA /* MediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17EFA1D3D09FC2F9C5E1CB2 /* MediaProvider.swift */; }; B73E50AF1AB2EB5477E20710 /* RoomDetailsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 166D45E1861A73B232109843 /* RoomDetailsScreenViewModelTests.swift */; }; + B74CCA58911A0C9C3E277A6B /* SettingsScreenUserStatusRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C816902BB6F450B4225B90C /* SettingsScreenUserStatusRow.swift */; }; B7513A1D8145E7F03B60696B /* sound_01.caf in Resources */ = {isa = PBXBuildFile; fileRef = 7349B71A67B765AA86002723 /* sound_01.caf */; }; B773ACD8881DB18E876D950C /* WaveformSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94028A227645FA880B966211 /* WaveformSource.swift */; }; B7888FC1E1DEF816D175C8D6 /* SecureBackupKeyBackupScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD72A9B720D75DBE60AC299F /* SecureBackupKeyBackupScreenModels.swift */; }; @@ -1693,6 +1695,7 @@ 05A3E8741D199CD1A37F4CBF /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 05AF58372CA884A789EB9C5A /* AppMediatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMediatorProtocol.swift; sourceTree = ""; }; 05F598B1B346DAF223651C91 /* LoginScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenCoordinator.swift; sourceTree = ""; }; + 06775BFC4D00C62B7E1353A8 /* SettingsScreenUserStatusPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenUserStatusPickerView.swift; sourceTree = ""; }; 0685156EB62D7E243F097CFC /* ServerSelectionScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModelProtocol.swift; sourceTree = ""; }; 06B098A612DCB5A7358EECD5 /* DeveloperOptionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenModels.swift; sourceTree = ""; }; 06F27F588F9059128E17C669 /* WindowManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManagerProtocol.swift; sourceTree = ""; }; @@ -2106,6 +2109,7 @@ 4B41FABA2B0AEF4389986495 /* LoginMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginMode.swift; sourceTree = ""; }; 4B73675A21580971E9ADF66A /* LocationSharingScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSharingScreenViewModelProtocol.swift; sourceTree = ""; }; 4BD371B60E07A5324B9507EF /* AnalyticsSettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenCoordinator.swift; sourceTree = ""; }; + 4C816902BB6F450B4225B90C /* SettingsScreenUserStatusRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenUserStatusRow.swift; sourceTree = ""; }; 4C8D988E82A8DFA13BE46F7C /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = pl; path = pl.lproj/Localizable.stringsdict; sourceTree = ""; }; 4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = ElementX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4CDDDDD9FE1A699D23A5E096 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = ""; }; @@ -3683,6 +3687,8 @@ isa = PBXGroup; children = ( 3CFD5EB0B0EEA4549FB49784 /* SettingsScreen.swift */, + 06775BFC4D00C62B7E1353A8 /* SettingsScreenUserStatusPickerView.swift */, + 4C816902BB6F450B4225B90C /* SettingsScreenUserStatusRow.swift */, ); path = View; sourceTree = ""; @@ -9073,6 +9079,8 @@ 34F1261CEF6D6A00D559B520 /* SettingsScreen.swift in Sources */, AF8BFA37791E1756EE243E08 /* SettingsScreenCoordinator.swift in Sources */, B93D7CE520088AD53FA6D53C /* SettingsScreenModels.swift in Sources */, + 5E67990F98AF11563EA50080 /* SettingsScreenUserStatusPickerView.swift in Sources */, + B74CCA58911A0C9C3E277A6B /* SettingsScreenUserStatusRow.swift in Sources */, E0B6A569AC3E81D233B43D60 /* SettingsScreenViewModel.swift in Sources */, A009BDFB0A6816D4C392ADCB /* SettingsScreenViewModelProtocol.swift in Sources */, 5D99F63CC88BB29383019FC6 /* ShareExtensionModels.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index aed5e2376..264026df1 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -6,6 +6,7 @@ "a11y_collapse_message_text_field" = "Minimize message text field"; "a11y_create_poll_votes_allowed_decrease" = "Decrease votes allowed per person"; "a11y_create_poll_votes_allowed_increase" = "Increase votes allowed per person"; +"a11y_custom_emoji" = "Custom emoji"; "a11y_delete" = "Delete"; "a11y_duration" = "Duration: %1$@"; "a11y_edit_avatar" = "Edit avatar"; @@ -51,6 +52,7 @@ "a11y_start_call" = "Start a call"; "a11y_start_video_call" = "Start a video call"; "a11y_start_voice_call" = "Start a voice call"; +"a11y_status" = "Status"; "a11y_tombstoned_room" = "Tombstoned room"; "a11y_user_menu" = "User menu"; "a11y_video_preview" = "Video preview"; @@ -387,13 +389,6 @@ "common_unsupported_call" = "Unsupported call"; "common_unsupported_event" = "Unsupported event"; "common_user_started_a_call" = "%1$@ started a call"; -"common_user_status_away" = "Away"; -"common_user_status_be_right_back" = "Be right back"; -"common_user_status_custom" = "Custom…"; -"common_user_status_focus_time" = "Focus time"; -"common_user_status_in_a_meeting" = "In a meeting"; -"common_user_status_on_a_call" = "On a call"; -"common_user_status_on_the_road" = "On the road"; "common_username" = "Username"; "common_verification_cancelled" = "Verification cancelled"; "common_verification_complete" = "Verification complete"; @@ -1125,8 +1120,6 @@ "screen_polls_history_filter_ongoing" = "Ongoing"; "screen_polls_history_filter_past" = "Past"; "screen_polls_history_title" = "Polls"; -"screen_preferences_user_status_custom_hint" = "Custom status…"; -"screen_preferences_user_status_placeholder" = "What's your status?"; "screen_push_history_title" = "Push history"; "screen_qr_code_login_connecting_subtitle" = "Establishing a secure connection"; "screen_qr_code_login_connection_note_secure_state_description" = "A secure connection could not be made to the new device. Your existing devices are still safe and you don't need to worry about them."; @@ -1515,6 +1508,14 @@ "screen_session_verification_waiting_subtitle" = "Once accepted you’ll be able to continue with the verification."; "screen_session_verification_waiting_to_accept_subtitle" = "Accept the request to start the verification process in your other session to continue."; "screen_session_verification_waiting_to_accept_title" = "Waiting to accept request"; +"screen_settings_user_status_away" = "Away"; +"screen_settings_user_status_be_right_back" = "Be right back"; +"screen_settings_user_status_custom" = "Custom…"; +"screen_settings_user_status_custom_hint" = "Custom status…"; +"screen_settings_user_status_focus_time" = "Focus time"; +"screen_settings_user_status_in_a_meeting" = "In a meeting"; +"screen_settings_user_status_on_the_road" = "On the road"; +"screen_settings_user_status_placeholder" = "What's your status?"; "screen_share_location_live_location_disclaimer_title" = "Your live location history will be stored in the room and visible to members after the session ends."; "screen_share_location_live_location_duration_picker_title" = "Choose how long to share your live location."; "screen_share_location_live_location_missing_permissions" = "You do not have permissions to share your live location in this room"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 4427dd7b4..d0e084527 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -6,6 +6,7 @@ "a11y_collapse_message_text_field" = "Minimise message text field"; "a11y_create_poll_votes_allowed_decrease" = "Decrease votes allowed per person"; "a11y_create_poll_votes_allowed_increase" = "Increase votes allowed per person"; +"a11y_custom_emoji" = "Custom emoji"; "a11y_delete" = "Delete"; "a11y_duration" = "Duration: %1$@"; "a11y_edit_avatar" = "Edit avatar"; @@ -51,6 +52,7 @@ "a11y_start_call" = "Start a call"; "a11y_start_video_call" = "Start a video call"; "a11y_start_voice_call" = "Start a voice call"; +"a11y_status" = "Status"; "a11y_tombstoned_room" = "Tombstoned room"; "a11y_user_menu" = "User menu"; "a11y_video_preview" = "Video preview"; @@ -387,13 +389,6 @@ "common_unsupported_call" = "Unsupported call"; "common_unsupported_event" = "Unsupported event"; "common_user_started_a_call" = "%1$@ started a call"; -"common_user_status_away" = "Away"; -"common_user_status_be_right_back" = "Be right back"; -"common_user_status_custom" = "Custom…"; -"common_user_status_focus_time" = "Focus time"; -"common_user_status_in_a_meeting" = "In a meeting"; -"common_user_status_on_a_call" = "On a call"; -"common_user_status_on_the_road" = "On the road"; "common_username" = "Username"; "common_verification_cancelled" = "Verification cancelled"; "common_verification_complete" = "Verification complete"; @@ -1125,8 +1120,6 @@ "screen_polls_history_filter_ongoing" = "Ongoing"; "screen_polls_history_filter_past" = "Past"; "screen_polls_history_title" = "Polls"; -"screen_preferences_user_status_custom_hint" = "Custom status…"; -"screen_preferences_user_status_placeholder" = "What's your status?"; "screen_push_history_title" = "Push history"; "screen_qr_code_login_connecting_subtitle" = "Establishing a secure connection"; "screen_qr_code_login_connection_note_secure_state_description" = "A secure connection could not be made to the new device. Your existing devices are still safe and you don't need to worry about them."; @@ -1515,6 +1508,14 @@ "screen_session_verification_waiting_subtitle" = "Once accepted you’ll be able to continue with the verification."; "screen_session_verification_waiting_to_accept_subtitle" = "Accept the request to start the verification process in your other session to continue."; "screen_session_verification_waiting_to_accept_title" = "Waiting to accept request"; +"screen_settings_user_status_away" = "Away"; +"screen_settings_user_status_be_right_back" = "Be right back"; +"screen_settings_user_status_custom" = "Custom…"; +"screen_settings_user_status_custom_hint" = "Custom status…"; +"screen_settings_user_status_focus_time" = "Focus time"; +"screen_settings_user_status_in_a_meeting" = "In a meeting"; +"screen_settings_user_status_on_the_road" = "On the road"; +"screen_settings_user_status_placeholder" = "What's your status?"; "screen_share_location_live_location_disclaimer_title" = "Your live location history will be stored in the room and visible to members after the session ends."; "screen_share_location_live_location_duration_picker_title" = "Choose how long to share your live location."; "screen_share_location_live_location_missing_permissions" = "You do not have permissions to share your live location in this room"; diff --git a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift index 07987d725..2eb212bfe 100644 --- a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift @@ -76,7 +76,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: .init(userSession: flowParameters.userSession, appSettings: flowParameters.appSettings, isBugReportServiceEnabled: flowParameters.bugReportService.isEnabled, - isInSecondaryWindow: isInSecondaryWindow)) + isInSecondaryWindow: isInSecondaryWindow, + userIndicatorController: flowParameters.userIndicatorController)) settingsScreenCoordinator.actions .sink { [weak self] action in diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 6eca5c21e..a70c7f513 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -28,6 +28,8 @@ internal nonisolated enum L10n { internal static var a11yCreatePollVotesAllowedDecrease: String { return L10n.tr("Localizable", "a11y_create_poll_votes_allowed_decrease") } /// Increase votes allowed per person internal static var a11yCreatePollVotesAllowedIncrease: String { return L10n.tr("Localizable", "a11y_create_poll_votes_allowed_increase") } + /// Custom emoji + internal static var a11yCustomEmoji: String { return L10n.tr("Localizable", "a11y_custom_emoji") } /// Delete internal static var a11yDelete: String { return L10n.tr("Localizable", "a11y_delete") } /// Plural format key: "%#@COUNT@" @@ -150,6 +152,8 @@ internal nonisolated enum L10n { internal static var a11yStartVideoCall: String { return L10n.tr("Localizable", "a11y_start_video_call") } /// Start a voice call internal static var a11yStartVoiceCall: String { return L10n.tr("Localizable", "a11y_start_voice_call") } + /// Status + internal static var a11yStatus: String { return L10n.tr("Localizable", "a11y_status") } /// Tombstoned room internal static var a11yTombstonedRoom: String { return L10n.tr("Localizable", "a11y_tombstoned_room") } /// User menu @@ -866,20 +870,6 @@ internal nonisolated enum L10n { internal static func commonUserStartedACall(_ p1: Any) -> String { return L10n.tr("Localizable", "common_user_started_a_call", String(describing: p1)) } - /// Away - internal static var commonUserStatusAway: String { return L10n.tr("Localizable", "common_user_status_away") } - /// Be right back - internal static var commonUserStatusBeRightBack: String { return L10n.tr("Localizable", "common_user_status_be_right_back") } - /// Custom… - internal static var commonUserStatusCustom: String { return L10n.tr("Localizable", "common_user_status_custom") } - /// Focus time - internal static var commonUserStatusFocusTime: String { return L10n.tr("Localizable", "common_user_status_focus_time") } - /// In a meeting - internal static var commonUserStatusInAMeeting: String { return L10n.tr("Localizable", "common_user_status_in_a_meeting") } - /// On a call - internal static var commonUserStatusOnACall: String { return L10n.tr("Localizable", "common_user_status_on_a_call") } - /// On the road - internal static var commonUserStatusOnTheRoad: String { return L10n.tr("Localizable", "common_user_status_on_the_road") } /// Username internal static var commonUsername: String { return L10n.tr("Localizable", "common_username") } /// Verification cancelled @@ -2612,10 +2602,6 @@ internal nonisolated enum L10n { internal static var screenPollsHistoryFilterPast: String { return L10n.tr("Localizable", "screen_polls_history_filter_past") } /// Polls internal static var screenPollsHistoryTitle: String { return L10n.tr("Localizable", "screen_polls_history_title") } - /// Custom status… - internal static var screenPreferencesUserStatusCustomHint: String { return L10n.tr("Localizable", "screen_preferences_user_status_custom_hint") } - /// What's your status? - internal static var screenPreferencesUserStatusPlaceholder: String { return L10n.tr("Localizable", "screen_preferences_user_status_placeholder") } /// Push history internal static var screenPushHistoryTitle: String { return L10n.tr("Localizable", "screen_push_history_title") } /// Establishing a secure connection @@ -3499,6 +3485,22 @@ internal nonisolated enum L10n { internal static var screenSessionVerificationWaitingToAcceptSubtitle: String { return L10n.tr("Localizable", "screen_session_verification_waiting_to_accept_subtitle") } /// Waiting to accept request internal static var screenSessionVerificationWaitingToAcceptTitle: String { return L10n.tr("Localizable", "screen_session_verification_waiting_to_accept_title") } + /// Away + internal static var screenSettingsUserStatusAway: String { return L10n.tr("Localizable", "screen_settings_user_status_away") } + /// Be right back + internal static var screenSettingsUserStatusBeRightBack: String { return L10n.tr("Localizable", "screen_settings_user_status_be_right_back") } + /// Custom… + internal static var screenSettingsUserStatusCustom: String { return L10n.tr("Localizable", "screen_settings_user_status_custom") } + /// Custom status… + internal static var screenSettingsUserStatusCustomHint: String { return L10n.tr("Localizable", "screen_settings_user_status_custom_hint") } + /// Focus time + internal static var screenSettingsUserStatusFocusTime: String { return L10n.tr("Localizable", "screen_settings_user_status_focus_time") } + /// In a meeting + internal static var screenSettingsUserStatusInAMeeting: String { return L10n.tr("Localizable", "screen_settings_user_status_in_a_meeting") } + /// On the road + internal static var screenSettingsUserStatusOnTheRoad: String { return L10n.tr("Localizable", "screen_settings_user_status_on_the_road") } + /// What's your status? + internal static var screenSettingsUserStatusPlaceholder: String { return L10n.tr("Localizable", "screen_settings_user_status_placeholder") } /// Your live location history will be stored in the room and visible to members after the session ends. internal static var screenShareLocationLiveLocationDisclaimerTitle: String { return L10n.tr("Localizable", "screen_share_location_live_location_disclaimer_title") } /// Choose how long to share your live location. diff --git a/ElementX/Sources/Mocks/ClientProxyMock.swift b/ElementX/Sources/Mocks/ClientProxyMock.swift index e97f8c6a9..aef83ee8f 100644 --- a/ElementX/Sources/Mocks/ClientProxyMock.swift +++ b/ElementX/Sources/Mocks/ClientProxyMock.swift @@ -14,8 +14,9 @@ struct ClientProxyMockConfiguration { var homeserver = "" var userIDServerName: String? var userID: String = RoomMemberProxyMock.mockMe.userID - var displayName: String? = "User display name" var deviceID: String? + var displayName: String? = "User display name" + var status: UserStatus = .init() var roomSummaryProvider: RoomSummaryProviderProtocol = RoomSummaryProviderMock(.init()) var spaceServiceConfiguration: SpaceServiceProxyMock.Configuration = .init() var roomPreviews: [RoomPreviewProxyProtocol]? @@ -65,7 +66,9 @@ extension ClientProxyMock { verificationStatePublisher = .init(.unknown) homeserverReachabilityPublisher = .init(.reachable) - userProfilePublisher = .init(UserProfile(userID: configuration.userID, displayName: configuration.displayName)) + userProfilePublisher = .init(UserProfile(userID: configuration.userID, + displayName: configuration.displayName, + status: configuration.status)) ignoredUsersPublisher = .init([RoomMemberProxyMock].allMembers.map(\.userID)) diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index 8fa9042ff..69befbd1b 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -164,6 +164,8 @@ enum TestablePreviewsDictionary { "ServerConfirmationScreen_Previews" : ServerConfirmationScreen_Previews.self, "SessionVerificationRequestDetailsView_Previews" : SessionVerificationRequestDetailsView_Previews.self, "SessionVerification_Previews" : SessionVerification_Previews.self, + "SettingsScreenUserStatusPickerView_Previews" : SettingsScreenUserStatusPickerView_Previews.self, + "SettingsScreenUserStatusRow_Previews" : SettingsScreenUserStatusRow_Previews.self, "ShimmerOverlay_Previews" : ShimmerOverlay_Previews.self, "SoftLogoutScreen_Previews" : SoftLogoutScreen_Previews.self, "SpaceAddRoomsScreenSelectedItem_Previews" : SpaceAddRoomsScreenSelectedItem_Previews.self, diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift index 57d5e935b..ce95b286c 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift @@ -14,6 +14,7 @@ struct SettingsScreenCoordinatorParameters { let appSettings: AppSettings let isBugReportServiceEnabled: Bool let isInSecondaryWindow: Bool + let userIndicatorController: UserIndicatorControllerProtocol } enum SettingsScreenCoordinatorAction { @@ -51,7 +52,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { viewModel = SettingsScreenViewModel(userSession: parameters.userSession, appSettings: parameters.appSettings, isBugReportServiceEnabled: parameters.isBugReportServiceEnabled, - isInSecondaryWindow: parameters.isInSecondaryWindow) + isInSecondaryWindow: parameters.isInSecondaryWindow, + userIndicatorController: parameters.userIndicatorController) viewModel.actions .sink { [weak self] action in diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift index 6af59dce8..bbbe1d9bd 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift @@ -35,6 +35,7 @@ enum SettingsScreenSecuritySectionMode { struct SettingsScreenViewState: BindableState { var deviceID: String? var userProfile: UserProfile + var showUserStatus: Bool var showLinkNewDeviceButton: Bool var accountProfileURL: URL? var showAccountDeactivation: Bool @@ -51,15 +52,28 @@ struct SettingsScreenViewState: BindableState { let navigationBarVisibility: Visibility var bindings = SettingsScreenViewStateBindings() + + var userStatusRowMode: SettingsScreenUserStatusRow.Mode { + if bindings.isShowingCustomStatusField { + .custom + } else if let rawStatus = userProfile.status.raw { + .show(rawStatus) + } else { + .pick + } + } } struct SettingsScreenViewStateBindings { + var isPresentingStatusPicker = false + var isShowingCustomStatusField = false var isPresentingAccountDeactivationConfirmation = false } enum SettingsScreenViewAction { case close case userDetails + case userStatus(UserStatusAction) case analytics case appLock case reportBug @@ -75,4 +89,17 @@ enum SettingsScreenViewAction { case labs case logout case deactivateAccount + + enum UserStatusAction { + /// Show status picker sheet to select a preset status. + case pickStatus + /// Dismiss the picker sheet and show the custom status input. + case customStatus + /// Show the emoji picker to select the emoji for the custom status. + case pickCustomEmoji + /// Set the user's status to the provided value. + case set(UserStatus.Raw?) + /// Cancel user status picking/input. + case cancel + } } diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift index d45591d4c..e8049c73b 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift @@ -13,6 +13,8 @@ typealias SettingsScreenViewModelType = StateStoreViewModelV2 = .init() @@ -20,11 +22,14 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo actionsSubject.eraseToAnyPublisher() } - init(userSession: UserSessionProtocol, appSettings: AppSettings, isBugReportServiceEnabled: Bool, isInSecondaryWindow: Bool) { + init(userSession: UserSessionProtocol, appSettings: AppSettings, isBugReportServiceEnabled: Bool, isInSecondaryWindow: Bool, userIndicatorController: UserIndicatorControllerProtocol) { self.appSettings = appSettings + clientProxy = userSession.clientProxy + self.userIndicatorController = userIndicatorController super.init(initialViewState: .init(deviceID: userSession.clientProxy.deviceID, userProfile: userSession.clientProxy.userProfilePublisher.value, + showUserStatus: appSettings.userStatusEnabled, showLinkNewDeviceButton: appSettings.linkNewDeviceEnabled, showAccountDeactivation: userSession.clientProxy.canDeactivateAccount, showDeveloperOptions: appSettings.developerOptionsEnabled, @@ -92,6 +97,18 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo actionsSubject.send(.close) case .userDetails: actionsSubject.send(.userDetails) + case .userStatus(.pickStatus): + state.bindings.isPresentingStatusPicker = true + case .userStatus(.customStatus): + state.bindings.isPresentingStatusPicker = false + state.bindings.isShowingCustomStatusField = true + case .userStatus(.pickCustomEmoji): + break // Hook-up the emoji picker here. + case .userStatus(.set(let status)): + Task { await setUserStatus(status) } + case .userStatus(.cancel): + state.bindings.isPresentingStatusPicker = false + state.bindings.isShowingCustomStatusField = false case .linkNewDevice: actionsSubject.send(.linkNewDevice) case let .manageAccount(url): @@ -124,4 +141,28 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo actionsSubject.send(.deactivateAccount) } } + + // MARK: - Private + + func setUserStatus(_ status: UserStatus.Raw?) async { + // Loading state tbc + state.bindings.isPresentingStatusPicker = false + state.bindings.isShowingCustomStatusField = false + + let result = if let status { + await clientProxy.setUserStatus(status) + } else { + await clientProxy.removeUserStatus() + } + + switch result { + case .success: + break // Loading/error state tbc + case .failure: + userIndicatorController.submitIndicator(.init(id: UUID().uuidString, + type: .toast, + title: L10n.errorUnknown, + icon: \.close)) + } + } } diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift index 628af3a39..94047717a 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift @@ -11,7 +11,7 @@ import SFSafeSymbols import SwiftUI struct SettingsScreen: View { - let context: SettingsScreenViewModel.Context + @Bindable var context: SettingsScreenViewModel.Context private var shouldHideManageAccountSection: Bool { context.viewState.accountProfileURL == nil && @@ -23,6 +23,10 @@ struct SettingsScreen: View { Form { userSection + if context.viewState.showUserStatus { + userStatusSection + } + if !shouldHideManageAccountSection { manageAccountSection } @@ -59,9 +63,16 @@ struct SettingsScreen: View { .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { - Text(context.viewState.userProfile.displayName ?? "") - .font(.compound.headingMD) - .foregroundColor(.compound.textPrimary) + HStack(spacing: 6) { + Text(context.viewState.userProfile.displayName ?? "") + + if let statusEmoji = context.viewState.userProfile.status.displayed?.emoji { + Text(String(statusEmoji)) + } + } + .font(.compound.headingMD) + .foregroundColor(.compound.textPrimary) + Text(context.viewState.userProfile.id) .font(.compound.bodySM) .foregroundColor(.compound.textSecondary) @@ -78,6 +89,19 @@ struct SettingsScreen: View { } } + private var userStatusSection: some View { + Section { + SettingsScreenUserStatusRow(mode: context.viewState.userStatusRowMode) { action in + context.send(viewAction: .userStatus(action)) + } + .sheet(isPresented: $context.isPresentingStatusPicker) { + SettingsScreenUserStatusPickerView { action in + context.send(viewAction: .userStatus(action)) + } + } + } + } + private var manageMyAppSection: some View { Section { ListRow(label: .default(title: L10n.screenNotificationSettingsTitle, @@ -275,11 +299,14 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview { } static func makeViewModel(isBugReportServiceEnabled: Bool = true) -> SettingsScreenViewModel { - let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@userid:example.com", - deviceID: "AAAAAAAAAAA")))) + let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@alice:example.com", + deviceID: "AAAAAAAAAAA", + displayName: "Alice Liddell", + status: .mockFocussing)))) return SettingsScreenViewModel(userSession: userSession, appSettings: .volatile(), isBugReportServiceEnabled: isBugReportServiceEnabled, - isInSecondaryWindow: false) + isInSecondaryWindow: false, + userIndicatorController: UserIndicatorControllerMock()) } } diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusPickerView.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusPickerView.swift new file mode 100644 index 000000000..fe87e3dda --- /dev/null +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusPickerView.swift @@ -0,0 +1,78 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import SwiftUI + +struct SettingsScreenUserStatusPickerView: View { + let action: (SettingsScreenViewAction.UserStatusAction) -> Void + + @State private var selection: UserStatusPreset? + + var body: some View { + ElementNavigationStack { + list + .navigationTitle(L10n.screenSettingsUserStatusPlaceholder) + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbar } + } + } + + var list: some View { + List { + ListRow(label: .plain(title: ""), + kind: .inlinePicker(selection: $selection, + items: UserStatusPreset.allCases.map { (title: "\($0.rawStatus.emoji) \($0.rawStatus.text)", tag: $0) })) + + ListRow(label: .plain(title: "✍️ \(L10n.screenSettingsUserStatusCustom)"), + kind: .button { action(.customStatus) }) + } + .compoundList(.plain) + } + + @ToolbarContentBuilder + var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + ToolbarButton(role: .cancel) { action(.cancel) } + } + + ToolbarItem(placement: .confirmationAction) { + ToolbarButton(role: .save) { + if let selection { + action(.set(selection.rawStatus)) + } + } + .disabled(selection == nil) + } + } +} + +private enum UserStatusPreset: CaseIterable { + case inAMeeting + case focusTime + case onTheRoad + case beRightBack + case away + + var rawStatus: UserStatus.Raw { + switch self { + case .inAMeeting: .init(text: L10n.screenSettingsUserStatusInAMeeting, emoji: "💬") + case .focusTime: .init(text: L10n.screenSettingsUserStatusFocusTime, emoji: "💡") + case .onTheRoad: .init(text: L10n.screenSettingsUserStatusOnTheRoad, emoji: "🚙") + case .beRightBack: .init(text: L10n.screenSettingsUserStatusBeRightBack, emoji: "☕️") + case .away: .init(text: L10n.screenSettingsUserStatusAway, emoji: "🌴") + } + } +} + +// MARK: - Previews + +struct SettingsScreenUserStatusPickerView_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + SettingsScreenUserStatusPickerView { _ in } + } +} diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift new file mode 100644 index 000000000..8e7199f6d --- /dev/null +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift @@ -0,0 +1,121 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import SwiftUI + +struct SettingsScreenUserStatusRow: View { + enum Mode: Equatable { case pick, custom, show(UserStatus.Raw) } + let mode: Mode + let action: (SettingsScreenViewAction.UserStatusAction) -> Void + + @State private var customEmoji = "😄" + @State private var customText = "" + + var body: some View { + switch mode { + case .pick: + ListRow(label: .default(title: L10n.screenSettingsUserStatusPlaceholder, icon: \.reaction), + kind: .button { action(.pickStatus) }) + case .custom: + ListRow(kind: .custom { + HStack(spacing: ListRowPadding.labelIconSpacing) { + Button { action(.pickCustomEmoji) } label: { + Text(customEmoji) + .font(.compound.headingSM) + .foregroundStyle(.compound.textPrimary) + .accessibilityLabel(L10n.a11yCustomEmoji) + .accessibilityValue(customEmoji) + } + .buttonStyle(EditEmojiButtonStyle()) + + TextField(L10n.screenSettingsUserStatusCustomHint, text: $customText) + .textFieldStyle(.compound(.raised)) + .accessibilityLabel(L10n.screenSettingsUserStatusCustomHint) + .padding(.vertical, 3) + + ZStack { // ZStack to stop the text field from resizing. + Button(L10n.actionCancel) { action(.cancel) } + .opacity(customText.isEmpty ? 1 : 0) + Button(L10n.actionSave, action: saveCustomStatus) + .opacity(customText.isEmpty ? 0 : 1) + } + .buttonStyle(.compound(.textLink)) + } + .listRowBackground(Color.clear) + }) + case .show(let status): + ListRow(kind: .custom { + HStack(spacing: ListRowPadding.labelIconSpacing) { + Button { action(.pickStatus) } label: { + HStack(spacing: ListRowPadding.labelIconSpacing) { + CompoundIcon(\.reaction) + .hidden() + .overlay { + Text(String(status.emoji)) + .font(.compound.bodyLG) + .foregroundStyle(.compound.textPrimary) + } + + Text(status.text) + .font(.compound.bodyLG) + .foregroundStyle(.compound.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .contentShape(Rectangle()) + } + .accessibilityLabel(L10n.a11yStatus) + .accessibilityValue(Text(String(status.emoji)) + Text(status.text)) + .buttonStyle(.plain) + + Button(L10n.actionClear) { action(.set(nil)) } + .buttonStyle(.compound(.textLink)) + } + .padding(ListRowPadding.insets) + }) + } + } + + private func saveCustomStatus() { + guard let emoji = customEmoji.first else { return } + action(.set(.init(text: customText, emoji: emoji))) + } + + struct EditEmojiButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(12) + .background { + ZStack { + Circle().fill(.compound.bgCanvasDefaultLevel1) + Circle().inset(by: 0.5).stroke(.compound.borderInteractiveSecondary) + } + } + .drawingGroup() + .opacity(configuration.isPressed ? 0.6 : 1) + } + } +} + +struct SettingsScreenUserStatusRow_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + Form { + Section { + SettingsScreenUserStatusRow(mode: .pick) { _ in } + } + + Section { + SettingsScreenUserStatusRow(mode: .custom) { _ in } + } + + Section { + SettingsScreenUserStatusRow(mode: .show(.init(text: "Away", emoji: "🌴"))) { _ in } + } + } + .compoundList() + } +} diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index cd1593e57..a684e6c15 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -1159,6 +1159,20 @@ extension PreviewTests { } } + @Test + func settingsScreenUserStatusPickerView() async throws { + for (index, preview) in SettingsScreenUserStatusPickerView_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + + @Test + func settingsScreenUserStatusRow() async throws { + for (index, preview) in SettingsScreenUserStatusRow_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func settingsScreen() async throws { for (index, preview) in SettingsScreen_Previews._allPreviews.enumerated() { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-en-GB.png index 6f7cd5e2e..facc96d64 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2d77bf89b1d8f32769fb344350d506385d285b8243d2e68e6a927526ec859f6 -size 148915 +oid sha256:5001fc410195d974fceb85fb9c8e03a25bd7614fe9ffd7d35d1ca3791b71df4d +size 150756 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-pseudo.png index 781cafd9b..5aeb8452b 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be915ee13c944b772a0e57afebc00a89c025da30a2b9752f63355d439da7cddb -size 155044 +oid sha256:da52953ab4cf8a4b09f00ee3c5942f8bb946b108b1613fad1e906e833cf6fa00 +size 156816 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-en-GB.png index 46de59b71..e9b1dd224 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a05f66738a862f6131e63903c87c9b1b94dfdf0a64cbb222185c30dc13d76dc3 -size 95770 +oid sha256:f0b3e32358f640201ad8efb3f397287c3b8f1286eb8f4b3510d521a104db5180 +size 97973 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-pseudo.png index 8cc3d2390..bca67f4ad 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:504ba2c922526a08812f1df453b7a3f7edb56c3e78666d75272f6da887965926 -size 111928 +oid sha256:834144dc590660e7553cb94e24464d5eab47d096641c4e435190c731233db3d1 +size 114090 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-en-GB.png index 8a948c84e..51b60f4ba 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e580af0e0cd966cf0fecec76f46412cfc258e7d2f354c3b9d0a08c072cef0a4b -size 149158 +oid sha256:49948b289947884a323d3d9e5af9b2d2b98689d57c6b15426cb56c9bab661da5 +size 150999 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-pseudo.png index 14fc98e7c..d9f2837fd 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a2e654f118059094b2245e8e6574a1c26b9debdc470453e938e27fed680f799c -size 155305 +oid sha256:c9a15bdedcbd5957ecf376e56a443dfaa55c08a486ab21df53b8def37df37e60 +size 157077 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-en-GB.png index c31e59731..f58c9cf37 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd8270f52ee525489e71ecdb3eefaf07879654d71adfccb71fe018fd8a3f01a6 -size 96176 +oid sha256:c63dec2f3675ac96551c66cc5398cdfaa87ed600c267c92267bb50b8cf83120d +size 98379 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-pseudo.png index 7733e612f..2adc49250 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b067e9231ded487380ca1bc6af2d17410fcabf349afb7fc81fd0ae9cf0fb7c04 -size 115309 +oid sha256:b0107312537fe0627c61fc799f91009b36b39464e64545cb7280bf2b67fa3cb5 +size 117471 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPad-en-GB-0.png new file mode 100644 index 000000000..a5f4e0d0a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c2dfd8d6bc2a475e5a77cf004b46dac6f4f56591315bd2a0399a8e6b3fb842e +size 106801 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPad-pseudo-0.png new file mode 100644 index 000000000..42567cfc2 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cfc43abc39bc53dccf09bac730b1a5415d0a1b3ac9b6e94127edc0d46604ed4f +size 109600 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPhone-en-GB-0.png new file mode 100644 index 000000000..f84c5e109 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPhone-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d0773ec07993bea5963f0d18526090b4d380b86634a6826edd97f910cd01946 +size 61378 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPhone-pseudo-0.png new file mode 100644 index 000000000..cfb96425e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusPickerView.iPhone-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a943da9248522547ff9cd82d6545c316636003799d3c5971d837f1ba3311d12 +size 68219 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-en-GB-0.png new file mode 100644 index 000000000..fa1168250 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3ac8e7e786f9769e382d8fc59f5ed7b702542d93dd4ce4f28cd683f6feb7cc8 +size 108146 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-pseudo-0.png new file mode 100644 index 000000000..bde520544 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf0f284e5e2fc6a942e6990dd0639c29c176a5824fc16f74e24a185fdea3f6bc +size 110210 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-en-GB-0.png new file mode 100644 index 000000000..3e47d1136 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93ee4b791e7b40f6ec0b21616806b749ac3dee51202e643b4b54800fd1d4c266 +size 60165 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-pseudo-0.png new file mode 100644 index 000000000..622e7737b --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8068d0b7d33018645ee13ae808e1f52c99e35198c3222567cd096705f4b2d41e +size 66395 diff --git a/UnitTests/Sources/SettingsScreenViewModelTests.swift b/UnitTests/Sources/SettingsScreenViewModelTests.swift index 166845c83..4e9d14284 100644 --- a/UnitTests/Sources/SettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/SettingsScreenViewModelTests.swift @@ -12,37 +12,130 @@ import Testing @MainActor struct SettingsScreenViewModelTests { - private var viewModel: SettingsScreenViewModelProtocol - private var context: SettingsScreenViewModelType.Context + private var clientProxy: ClientProxyMock! + private var viewModel: SettingsScreenViewModelProtocol! + private var context: SettingsScreenViewModelType.Context { + viewModel.context + } - init() { - let appSettings = AppSettings.volatile() - let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "")))) - viewModel = SettingsScreenViewModel(userSession: userSession, - appSettings: appSettings, - isBugReportServiceEnabled: true, - isInSecondaryWindow: false) - context = viewModel.context + // MARK: - User status + + @Test + mutating func statusPickerFlow() { + // Given a screen where no status has been set. + setupViewModel() + #expect(!context.viewState.bindings.isPresentingStatusPicker) + #expect(!context.viewState.bindings.isShowingCustomStatusField) + #expect(context.viewState.userStatusRowMode == .pick) + + // When choosing to pick a status. + context.send(viewAction: .userStatus(.pickStatus)) + + // Then the status picker should be presented. + #expect(context.viewState.bindings.isPresentingStatusPicker) + #expect(!context.viewState.bindings.isShowingCustomStatusField) + #expect(context.viewState.userStatusRowMode == .pick) + + // When choosing to enter a custom status. + context.send(viewAction: .userStatus(.customStatus)) + + // Then the picker should be dismissed and the custom status field shown. + #expect(!context.viewState.bindings.isPresentingStatusPicker) + #expect(context.viewState.bindings.isShowingCustomStatusField) + #expect(context.viewState.userStatusRowMode == .custom) + + // When cancelling. + context.send(viewAction: .userStatus(.cancel)) + + // Then all status editing should be dismissed. + #expect(!context.viewState.bindings.isPresentingStatusPicker) + #expect(!context.viewState.bindings.isShowingCustomStatusField) + #expect(context.viewState.userStatusRowMode == .pick) } @Test - func logout() async throws { - let deferred = deferFulfillment(viewModel.actions) { $0 == .logout } - context.send(viewAction: .logout) - try await deferred.fulfill() + mutating func persistingStatus() async throws { + // Given a screen where no status has been set or removed yet. + setupViewModel() + #expect(clientProxy.setUserStatusCallsCount == 0) + #expect(clientProxy.removeUserStatusCallsCount == 0) + + // When setting a status. + let status = UserStatus.Raw(text: "Away", emoji: "🌴") + let (setStream, setContinuation) = AsyncStream.makeStream() + clientProxy.setUserStatusClosure = { + setContinuation.yield($0) + return .success(()) + } + let statusSet = deferFulfillment(setStream) { _ in true } + context.send(viewAction: .userStatus(.set(status))) + + // Then only the set endpoint should be called, with the chosen status. + #expect(try await statusSet.fulfill() == status) + #expect(clientProxy.setUserStatusCallsCount == 1) + #expect(clientProxy.removeUserStatusCallsCount == 0) + + // When clearing the status. + let (removeStream, removeContinuation) = AsyncStream.makeStream() + clientProxy.removeUserStatusClosure = { + removeContinuation.yield() + return .success(()) + } + let statusRemoved = deferFulfillment(removeStream) { _ in true } + context.send(viewAction: .userStatus(.set(nil))) + + // Then only the remove endpoint should be called, leaving the set call untouched. + try await statusRemoved.fulfill() + #expect(clientProxy.removeUserStatusCallsCount == 1) + #expect(clientProxy.setUserStatusCallsCount == 1) } @Test - func reportBug() async throws { + mutating func showsExistingStatus() throws { + // Given a screen where the user has a status set. + let status = UserStatus.mockHoliday + setupViewModel(status: status) + + // Then that status should be shown. + let rawStatus = try #require(status.raw) + #expect(context.viewState.userStatusRowMode == .show(rawStatus)) + } + + @Test + mutating func reportBug() async throws { + setupViewModel() + let deferred = deferFulfillment(viewModel.actions) { $0 == .reportBug } context.send(viewAction: .reportBug) try await deferred.fulfill() } @Test - func analytics() async throws { + mutating func analytics() async throws { + setupViewModel() + let deferred = deferFulfillment(viewModel.actions) { $0 == .analytics } context.send(viewAction: .analytics) try await deferred.fulfill() } + + @Test + mutating func logout() async throws { + setupViewModel() + + let deferred = deferFulfillment(viewModel.actions) { $0 == .logout } + context.send(viewAction: .logout) + try await deferred.fulfill() + } + + // MARK: - Helpers + + private mutating func setupViewModel(status: UserStatus = .init()) { + clientProxy = ClientProxyMock(.init(userID: "", status: status)) + viewModel = SettingsScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)), + appSettings: AppSettings.volatile(), + isBugReportServiceEnabled: true, + isInSecondaryWindow: false, + userIndicatorController: UserIndicatorControllerMock()) + } }