mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:02:10 +00:00
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:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+78
@@ -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 }
|
||||
}
|
||||
}
|
||||
+121
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user