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.
This commit is contained in:
Doug
2026-07-17 12:21:51 +01:00
committed by GitHub
parent a4a509f2de
commit a0ec5ed9dd
32 changed files with 533 additions and 80 deletions
@@ -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
+20 -18
View File
@@ -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.
+5 -2
View File
@@ -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))
@@ -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,
@@ -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
@@ -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
}
}
@@ -13,6 +13,8 @@ typealias SettingsScreenViewModelType = StateStoreViewModelV2<SettingsScreenView
class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewModelProtocol {
private let appSettings: AppSettings
private let clientProxy: ClientProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private var actionsSubject: PassthroughSubject<SettingsScreenViewModelAction, Never> = .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))
}
}
}
@@ -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())
}
}
@@ -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 }
}
}
@@ -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()
}
}