Allow clearing your own call status. (#5959)

* Allow clearing your own call status.

* Clarify the names of the SettingsScreenUserStatusRow.Mode cases.
This commit is contained in:
Doug
2026-07-31 18:15:42 +01:00
committed by GitHub
parent f5a1a7d952
commit 94cd9590b5
11 changed files with 128 additions and 41 deletions
@@ -3333,6 +3333,34 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
return removeUserStatusReturnValue return removeUserStatusReturnValue
} }
} }
//MARK: - removeCallStatus
private let removeCallStatusCallsCountLock = NSLock()
private nonisolated(unsafe) var removeCallStatusUnderlyingCallsCount = 0
var removeCallStatusCallsCount: Int {
get { removeCallStatusCallsCountLock.withLock { removeCallStatusUnderlyingCallsCount } }
set { removeCallStatusCallsCountLock.withLock { removeCallStatusUnderlyingCallsCount = newValue } }
}
var removeCallStatusCalled: Bool {
return removeCallStatusCallsCount > 0
}
private let removeCallStatusReturnValueLock = NSLock()
private nonisolated(unsafe) var removeCallStatusUnderlyingReturnValue: Result<Void, ClientProxyError>!
var removeCallStatusReturnValue: Result<Void, ClientProxyError>! {
get { removeCallStatusReturnValueLock.withLock { removeCallStatusUnderlyingReturnValue } }
set { removeCallStatusReturnValueLock.withLock { removeCallStatusUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var removeCallStatusClosure: (() async -> Result<Void, ClientProxyError>)?
@concurrent func removeCallStatus() async -> Result<Void, ClientProxyError> {
removeCallStatusCallsCountLock.withLock { removeCallStatusUnderlyingCallsCount += 1 }
if let removeCallStatusClosure = removeCallStatusClosure {
return await removeCallStatusClosure()
} else {
return removeCallStatusReturnValue
}
}
//MARK: - linkNewDeviceService //MARK: - linkNewDeviceService
private let linkNewDeviceServiceCallsCountLock = NSLock() private let linkNewDeviceServiceCallsCountLock = NSLock()
@@ -56,11 +56,11 @@ struct SettingsScreenViewState: BindableState {
var userStatusRowMode: SettingsScreenUserStatusRow.Mode { var userStatusRowMode: SettingsScreenUserStatusRow.Mode {
if bindings.isShowingCustomStatusField { if bindings.isShowingCustomStatusField {
.custom(emoji: bindings.customStatusEmoji) .customStatusInput(emoji: bindings.customStatusEmoji)
} else if let rawStatus = userProfile.status.raw { } else if let displayedStatus = userProfile.status.displayed {
.show(rawStatus) .showingStatus(displayedStatus)
} else { } else {
.pick .pickStatusButton
} }
} }
} }
@@ -107,7 +107,9 @@ enum SettingsScreenViewAction {
/// Show the emoji picker to select the emoji for the custom status. /// Show the emoji picker to select the emoji for the custom status.
case pickCustomEmoji case pickCustomEmoji
/// Set the user's status to the provided value. /// Set the user's status to the provided value.
case set(UserStatus.Raw?) case set(UserStatus.Raw)
/// Clears the user's currently displayed status.
case clear(UserStatus.Displayed)
/// Cancel user status picking/input. /// Cancel user status picking/input.
case cancel case cancel
} }
@@ -108,6 +108,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
pickCustomEmoji() pickCustomEmoji()
case .userStatus(.set(let status)): case .userStatus(.set(let status)):
Task { await setUserStatus(status) } Task { await setUserStatus(status) }
case .userStatus(.clear(let status)):
Task { await clearUserStatus(status) }
case .userStatus(.cancel): case .userStatus(.cancel):
state.bindings.isPresentingStatusPicker = false state.bindings.isPresentingStatusPicker = false
state.bindings.isShowingCustomStatusField = false state.bindings.isShowingCustomStatusField = false
@@ -159,15 +161,32 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
.asCancellable() .asCancellable()
} }
func setUserStatus(_ status: UserStatus.Raw?) async { func setUserStatus(_ status: UserStatus.Raw) async {
// Loading state tbc // Loading state tbc
state.bindings.isPresentingStatusPicker = false state.bindings.isPresentingStatusPicker = false
state.bindings.isShowingCustomStatusField = false state.bindings.isShowingCustomStatusField = false
let result = if let status { switch await clientProxy.setUserStatus(status) {
await clientProxy.setUserStatus(status) case .success:
} else { break // Loading/error state tbc
case .failure:
userIndicatorController.submitIndicator(.init(id: UUID().uuidString,
type: .toast,
title: L10n.errorUnknown,
icon: \.close))
}
}
func clearUserStatus(_ status: UserStatus.Displayed) async {
// Loading state tbc
state.bindings.isPresentingStatusPicker = false
state.bindings.isShowingCustomStatusField = false
let result = switch status {
case .userSet:
await clientProxy.removeUserStatus() await clientProxy.removeUserStatus()
case .inCall:
await clientProxy.removeCallStatus()
} }
switch result { switch result {
@@ -9,7 +9,7 @@ import Compound
import SwiftUI import SwiftUI
struct SettingsScreenUserStatusRow: View { struct SettingsScreenUserStatusRow: View {
enum Mode: Equatable { case pick, custom(emoji: Character), show(UserStatus.Raw) } enum Mode: Equatable { case pickStatusButton, customStatusInput(emoji: Character), showingStatus(UserStatus.Displayed) }
let mode: Mode let mode: Mode
let action: (SettingsScreenViewAction.UserStatusAction) -> Void let action: (SettingsScreenViewAction.UserStatusAction) -> Void
@@ -17,10 +17,10 @@ struct SettingsScreenUserStatusRow: View {
var body: some View { var body: some View {
switch mode { switch mode {
case .pick: case .pickStatusButton:
ListRow(label: .default(title: L10n.screenSettingsUserStatusPlaceholder, icon: \.reaction), ListRow(label: .default(title: L10n.screenSettingsUserStatusPlaceholder, icon: \.reaction),
kind: .button { action(.pickStatus) }) kind: .button { action(.pickStatus) })
case .custom(let emoji): case .customStatusInput(let emoji):
ListRow(kind: .custom { ListRow(kind: .custom {
HStack(spacing: ListRowPadding.labelIconSpacing) { HStack(spacing: ListRowPadding.labelIconSpacing) {
Button { action(.pickCustomEmoji) } label: { Button { action(.pickCustomEmoji) } label: {
@@ -47,7 +47,7 @@ struct SettingsScreenUserStatusRow: View {
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
}) })
case .show(let status): case .showingStatus(let status):
ListRow(kind: .custom { ListRow(kind: .custom {
HStack(spacing: ListRowPadding.labelIconSpacing) { HStack(spacing: ListRowPadding.labelIconSpacing) {
Button { action(.pickStatus) } label: { Button { action(.pickStatus) } label: {
@@ -71,7 +71,7 @@ struct SettingsScreenUserStatusRow: View {
.accessibilityValue(Text(String(status.emoji)) + Text(status.text)) .accessibilityValue(Text(String(status.emoji)) + Text(status.text))
.buttonStyle(.plain) .buttonStyle(.plain)
Button(L10n.actionClear) { action(.set(nil)) } Button(L10n.actionClear) { action(.clear(status)) }
.buttonStyle(.compound(.textLink)) .buttonStyle(.compound(.textLink))
} }
.padding(ListRowPadding.insets) .padding(ListRowPadding.insets)
@@ -80,7 +80,7 @@ struct SettingsScreenUserStatusRow: View {
} }
private func saveCustomStatus() { private func saveCustomStatus() {
guard case let .custom(emoji) = mode else { return } guard case let .customStatusInput(emoji) = mode else { return }
action(.set(.init(text: customText, emoji: emoji))) action(.set(.init(text: customText, emoji: emoji)))
} }
@@ -104,15 +104,19 @@ struct SettingsScreenUserStatusRow_Previews: PreviewProvider, TestablePreview {
static var previews: some View { static var previews: some View {
Form { Form {
Section { Section {
SettingsScreenUserStatusRow(mode: .pick) { _ in } SettingsScreenUserStatusRow(mode: .pickStatusButton) { _ in }
} }
Section { Section {
SettingsScreenUserStatusRow(mode: .custom(emoji: "😄")) { _ in } SettingsScreenUserStatusRow(mode: .customStatusInput(emoji: "😄")) { _ in }
} }
Section { Section {
SettingsScreenUserStatusRow(mode: .show(.init(text: "Away", emoji: "🌴"))) { _ in } SettingsScreenUserStatusRow(mode: .showingStatus(.userSet(.init(text: "Away", emoji: "🌴")))) { _ in }
}
Section {
SettingsScreenUserStatusRow(mode: .showingStatus(.inCall(.init(joinedDate: nil)))) { _ in }
} }
} }
.compoundList() .compoundList()
@@ -766,7 +766,6 @@ class ClientProxy: ClientProxyProtocol {
} }
} }
// periphery:ignore - might be useful to have
func setUserStatus(_ status: UserStatus.Raw) async -> Result<Void, ClientProxyError> { func setUserStatus(_ status: UserStatus.Raw) async -> Result<Void, ClientProxyError> {
do { do {
try await client.setUserStatus(status: status.rustValue) try await client.setUserStatus(status: status.rustValue)
@@ -778,11 +777,10 @@ class ClientProxy: ClientProxyProtocol {
} }
} }
// periphery:ignore - might be useful to have
func removeUserStatus() async -> Result<Void, ClientProxyError> { func removeUserStatus() async -> Result<Void, ClientProxyError> {
do { do {
try await client.clearUserStatus() try await client.clearUserStatus()
Task { await self.loadUserProfileIfNeeded() } // No need to refresh the profile, we only support user status with the profiles /sync extension.
return .success(()) return .success(())
} catch { } catch {
MXLog.error("Failed removing user status with error: \(error)") MXLog.error("Failed removing user status with error: \(error)")
@@ -790,6 +788,17 @@ class ClientProxy: ClientProxyProtocol {
} }
} }
func removeCallStatus() async -> Result<Void, ClientProxyError> {
do {
try await client.clearCallStatus()
// No need to refresh the profile, we only support user status with the profiles /sync extension.
return .success(())
} catch {
MXLog.error("Failed removing call status with error: \(error)")
return .failure(.sdkError(error))
}
}
func linkNewDeviceService() -> LinkNewDeviceServiceProtocol { func linkNewDeviceService() -> LinkNewDeviceServiceProtocol {
LinkNewDeviceService(handler: client.newGrantLoginWithQrCodeHandler()) LinkNewDeviceService(handler: client.newGrantLoginWithQrCodeHandler())
} }
@@ -230,6 +230,7 @@ protocol ClientProxyProtocol: AnyObject {
func isUserStatusSupported() async -> Result<Bool, ClientProxyError> func isUserStatusSupported() async -> Result<Bool, ClientProxyError>
func setUserStatus(_ status: UserStatus.Raw) async -> Result<Void, ClientProxyError> func setUserStatus(_ status: UserStatus.Raw) async -> Result<Void, ClientProxyError>
func removeUserStatus() async -> Result<Void, ClientProxyError> func removeUserStatus() async -> Result<Void, ClientProxyError>
func removeCallStatus() async -> Result<Void, ClientProxyError>
func linkNewDeviceService() -> LinkNewDeviceServiceProtocol func linkNewDeviceService() -> LinkNewDeviceServiceProtocol
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:db0bccb08800ef06f6ae7b5521e8e40aa16dbd2970a06d011793edf51f99362b oid sha256:65f575cb43e5414644bb6a1fb18f3835963b854e79dec5933e1a98ee2720c555
size 109977 size 118179
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:73e00f26cd04d00add4ece3493d536e696296fc9fbd345ddca000ff9e9abea73 oid sha256:79d120979962c68981a690969b7dbca5778a20fb0dc93d83256208a94985fccc
size 112058 size 121012
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:797aba33bb05cf3f15e382125bb5f951bde4edcd9b2f1439b0c6cf3f37c1003c oid sha256:8bcecc78d5d75d8574819df65af3d1e524a5959488be16cd1e4ab0075a7f53fc
size 61947 size 68717
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a41e5ee59832811b7a3a95e3a526d8060246e37b8d434126b416144fde459ae5 oid sha256:5b5261aa94be9b9e99bf5d8dc19ebd64e0d9ac16507efc9218ba46024857e022
size 68057 size 77192
@@ -26,7 +26,7 @@ struct SettingsScreenViewModelTests {
setupViewModel() setupViewModel()
#expect(!context.viewState.bindings.isPresentingStatusPicker) #expect(!context.viewState.bindings.isPresentingStatusPicker)
#expect(!context.viewState.bindings.isShowingCustomStatusField) #expect(!context.viewState.bindings.isShowingCustomStatusField)
#expect(context.viewState.userStatusRowMode == .pick) #expect(context.viewState.userStatusRowMode == .pickStatusButton)
// When choosing to pick a status. // When choosing to pick a status.
context.send(viewAction: .userStatus(.pickStatus)) context.send(viewAction: .userStatus(.pickStatus))
@@ -34,7 +34,7 @@ struct SettingsScreenViewModelTests {
// Then the status picker should be presented. // Then the status picker should be presented.
#expect(context.viewState.bindings.isPresentingStatusPicker) #expect(context.viewState.bindings.isPresentingStatusPicker)
#expect(!context.viewState.bindings.isShowingCustomStatusField) #expect(!context.viewState.bindings.isShowingCustomStatusField)
#expect(context.viewState.userStatusRowMode == .pick) #expect(context.viewState.userStatusRowMode == .pickStatusButton)
// When choosing to enter a custom status. // When choosing to enter a custom status.
context.send(viewAction: .userStatus(.customStatus)) context.send(viewAction: .userStatus(.customStatus))
@@ -42,7 +42,7 @@ struct SettingsScreenViewModelTests {
// Then the picker should be dismissed and the custom status field shown. // Then the picker should be dismissed and the custom status field shown.
#expect(!context.viewState.bindings.isPresentingStatusPicker) #expect(!context.viewState.bindings.isPresentingStatusPicker)
#expect(context.viewState.bindings.isShowingCustomStatusField) #expect(context.viewState.bindings.isShowingCustomStatusField)
#expect(context.viewState.userStatusRowMode == .custom(emoji: "😄")) #expect(context.viewState.userStatusRowMode == .customStatusInput(emoji: "😄"))
// When cancelling. // When cancelling.
context.send(viewAction: .userStatus(.cancel)) context.send(viewAction: .userStatus(.cancel))
@@ -50,7 +50,7 @@ struct SettingsScreenViewModelTests {
// Then all status editing should be dismissed. // Then all status editing should be dismissed.
#expect(!context.viewState.bindings.isPresentingStatusPicker) #expect(!context.viewState.bindings.isPresentingStatusPicker)
#expect(!context.viewState.bindings.isShowingCustomStatusField) #expect(!context.viewState.bindings.isShowingCustomStatusField)
#expect(context.viewState.userStatusRowMode == .pick) #expect(context.viewState.userStatusRowMode == .pickStatusButton)
} }
@Test @Test
@@ -82,12 +82,36 @@ struct SettingsScreenViewModelTests {
return .success(()) return .success(())
} }
let statusRemoved = deferFulfillment(removeStream) { _ in true } let statusRemoved = deferFulfillment(removeStream) { _ in true }
context.send(viewAction: .userStatus(.set(nil))) context.send(viewAction: .userStatus(.clear(.userSet(status))))
// Then only the remove user status endpoint should be called.
try await statusRemoved.fulfill()
#expect(clientProxy.removeUserStatusCallsCount == 1)
#expect(!clientProxy.removeCallStatusCalled)
#expect(clientProxy.setUserStatusCallsCount == 1)
}
@Test
mutating func clearCallStatus() async throws {
// Given a screen where the user has a call status set.
let callStatus = UserStatus.mockCall
setupViewModel(status: callStatus)
let displayedStatus = try #require(callStatus.displayed)
// When clearing the status.
let (removeStream, removeContinuation) = AsyncStream<Void>.makeStream()
clientProxy.removeCallStatusClosure = {
removeContinuation.yield()
return .success(())
}
let statusRemoved = deferFulfillment(removeStream) { _ in true }
context.send(viewAction: .userStatus(.clear(displayedStatus)))
// Then only the remove endpoint should be called, leaving the set call untouched. // Then only the remove endpoint should be called, leaving the set call untouched.
try await statusRemoved.fulfill() try await statusRemoved.fulfill()
#expect(clientProxy.removeUserStatusCallsCount == 1) #expect(clientProxy.removeCallStatusCallsCount == 1)
#expect(clientProxy.setUserStatusCallsCount == 1) #expect(!clientProxy.removeUserStatusCalled)
#expect(!clientProxy.setUserStatusCalled)
} }
@Test @Test
@@ -97,8 +121,8 @@ struct SettingsScreenViewModelTests {
setupViewModel(status: status) setupViewModel(status: status)
// Then that status should be shown. // Then that status should be shown.
let rawStatus = try #require(status.raw) let displayedStatus = try #require(status.displayed)
#expect(context.viewState.userStatusRowMode == .show(rawStatus)) #expect(context.viewState.userStatusRowMode == .showingStatus(displayedStatus))
} }
@Test @Test
@@ -106,20 +130,20 @@ struct SettingsScreenViewModelTests {
// Given a custom status field showing the default emoji. // Given a custom status field showing the default emoji.
setupViewModel() setupViewModel()
context.send(viewAction: .userStatus(.customStatus)) context.send(viewAction: .userStatus(.customStatus))
#expect(context.viewState.userStatusRowMode == .custom(emoji: "😄")) #expect(context.viewState.userStatusRowMode == .customStatusInput(emoji: "😄"))
// When selecting an emoji from the picker. // When selecting an emoji from the picker.
try await selectCustomStatusEmoji("🎉") try await selectCustomStatusEmoji("🎉")
// Then the custom status field should show the selected emoji. // Then the custom status field should show the selected emoji.
#expect(context.viewState.userStatusRowMode == .custom(emoji: "🎉")) #expect(context.viewState.userStatusRowMode == .customStatusInput(emoji: "🎉"))
// When cancelling and returning to the custom status field. // When cancelling and returning to the custom status field.
context.send(viewAction: .userStatus(.cancel)) context.send(viewAction: .userStatus(.cancel))
context.send(viewAction: .userStatus(.customStatus)) context.send(viewAction: .userStatus(.customStatus))
// Then the emoji should have been reset to the default. // Then the emoji should have been reset to the default.
#expect(context.viewState.userStatusRowMode == .custom(emoji: "😄")) #expect(context.viewState.userStatusRowMode == .customStatusInput(emoji: "😄"))
} }
@Test @Test