diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index cc6cb5c89..3218f8dff 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -3333,6 +3333,34 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { 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! + var removeCallStatusReturnValue: Result! { + get { removeCallStatusReturnValueLock.withLock { removeCallStatusUnderlyingReturnValue } } + set { removeCallStatusReturnValueLock.withLock { removeCallStatusUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var removeCallStatusClosure: (() async -> Result)? + + @concurrent func removeCallStatus() async -> Result { + removeCallStatusCallsCountLock.withLock { removeCallStatusUnderlyingCallsCount += 1 } + if let removeCallStatusClosure = removeCallStatusClosure { + return await removeCallStatusClosure() + } else { + return removeCallStatusReturnValue + } + } //MARK: - linkNewDeviceService private let linkNewDeviceServiceCallsCountLock = NSLock() diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift index 29c06a6f4..64211c664 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift @@ -56,11 +56,11 @@ struct SettingsScreenViewState: BindableState { var userStatusRowMode: SettingsScreenUserStatusRow.Mode { if bindings.isShowingCustomStatusField { - .custom(emoji: bindings.customStatusEmoji) - } else if let rawStatus = userProfile.status.raw { - .show(rawStatus) + .customStatusInput(emoji: bindings.customStatusEmoji) + } else if let displayedStatus = userProfile.status.displayed { + .showingStatus(displayedStatus) } else { - .pick + .pickStatusButton } } } @@ -107,7 +107,9 @@ enum SettingsScreenViewAction { /// 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?) + case set(UserStatus.Raw) + /// Clears the user's currently displayed status. + case clear(UserStatus.Displayed) /// 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 1ef4bd394..07439f12e 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift @@ -108,6 +108,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo pickCustomEmoji() case .userStatus(.set(let status)): Task { await setUserStatus(status) } + case .userStatus(.clear(let status)): + Task { await clearUserStatus(status) } case .userStatus(.cancel): state.bindings.isPresentingStatusPicker = false state.bindings.isShowingCustomStatusField = false @@ -159,15 +161,32 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo .asCancellable() } - func setUserStatus(_ status: UserStatus.Raw?) async { + 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 { + switch await clientProxy.setUserStatus(status) { + case .success: + 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() + case .inCall: + await clientProxy.removeCallStatus() } switch result { diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift index f9aef3a25..8f64f9b1a 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift @@ -9,7 +9,7 @@ import Compound import SwiftUI 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 action: (SettingsScreenViewAction.UserStatusAction) -> Void @@ -17,10 +17,10 @@ struct SettingsScreenUserStatusRow: View { var body: some View { switch mode { - case .pick: + case .pickStatusButton: ListRow(label: .default(title: L10n.screenSettingsUserStatusPlaceholder, icon: \.reaction), kind: .button { action(.pickStatus) }) - case .custom(let emoji): + case .customStatusInput(let emoji): ListRow(kind: .custom { HStack(spacing: ListRowPadding.labelIconSpacing) { Button { action(.pickCustomEmoji) } label: { @@ -47,7 +47,7 @@ struct SettingsScreenUserStatusRow: View { } .listRowBackground(Color.clear) }) - case .show(let status): + case .showingStatus(let status): ListRow(kind: .custom { HStack(spacing: ListRowPadding.labelIconSpacing) { Button { action(.pickStatus) } label: { @@ -71,7 +71,7 @@ struct SettingsScreenUserStatusRow: View { .accessibilityValue(Text(String(status.emoji)) + Text(status.text)) .buttonStyle(.plain) - Button(L10n.actionClear) { action(.set(nil)) } + Button(L10n.actionClear) { action(.clear(status)) } .buttonStyle(.compound(.textLink)) } .padding(ListRowPadding.insets) @@ -80,7 +80,7 @@ struct SettingsScreenUserStatusRow: View { } 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))) } @@ -104,15 +104,19 @@ struct SettingsScreenUserStatusRow_Previews: PreviewProvider, TestablePreview { static var previews: some View { Form { Section { - SettingsScreenUserStatusRow(mode: .pick) { _ in } + SettingsScreenUserStatusRow(mode: .pickStatusButton) { _ in } } Section { - SettingsScreenUserStatusRow(mode: .custom(emoji: "😄")) { _ in } + SettingsScreenUserStatusRow(mode: .customStatusInput(emoji: "😄")) { _ in } } 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() diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 9fd565ca1..d0edb5f25 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -766,7 +766,6 @@ class ClientProxy: ClientProxyProtocol { } } - // periphery:ignore - might be useful to have func setUserStatus(_ status: UserStatus.Raw) async -> Result { do { 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 { do { 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(()) } catch { MXLog.error("Failed removing user status with error: \(error)") @@ -790,6 +788,17 @@ class ClientProxy: ClientProxyProtocol { } } + func removeCallStatus() async -> Result { + 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 { LinkNewDeviceService(handler: client.newGrantLoginWithQrCodeHandler()) } diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 917867aed..79b63ffc8 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -230,6 +230,7 @@ protocol ClientProxyProtocol: AnyObject { func isUserStatusSupported() async -> Result func setUserStatus(_ status: UserStatus.Raw) async -> Result func removeUserStatus() async -> Result + func removeCallStatus() async -> Result func linkNewDeviceService() -> LinkNewDeviceServiceProtocol diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-en-GB-0.png index 96cbad543..16272f44a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:db0bccb08800ef06f6ae7b5521e8e40aa16dbd2970a06d011793edf51f99362b -size 109977 +oid sha256:65f575cb43e5414644bb6a1fb18f3835963b854e79dec5933e1a98ee2720c555 +size 118179 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-pseudo-0.png index bf47c0fb4..1b5b213f2 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73e00f26cd04d00add4ece3493d536e696296fc9fbd345ddca000ff9e9abea73 -size 112058 +oid sha256:79d120979962c68981a690969b7dbca5778a20fb0dc93d83256208a94985fccc +size 121012 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-en-GB-0.png index 54090acad..98fa17fdb 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:797aba33bb05cf3f15e382125bb5f951bde4edcd9b2f1439b0c6cf3f37c1003c -size 61947 +oid sha256:8bcecc78d5d75d8574819df65af3d1e524a5959488be16cd1e4ab0075a7f53fc +size 68717 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-pseudo-0.png index e4c8f6a51..a28a06ee7 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreenUserStatusRow.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a41e5ee59832811b7a3a95e3a526d8060246e37b8d434126b416144fde459ae5 -size 68057 +oid sha256:5b5261aa94be9b9e99bf5d8dc19ebd64e0d9ac16507efc9218ba46024857e022 +size 77192 diff --git a/UnitTests/Sources/SettingsScreenViewModelTests.swift b/UnitTests/Sources/SettingsScreenViewModelTests.swift index ceebcda12..fca6535e8 100644 --- a/UnitTests/Sources/SettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/SettingsScreenViewModelTests.swift @@ -26,7 +26,7 @@ struct SettingsScreenViewModelTests { setupViewModel() #expect(!context.viewState.bindings.isPresentingStatusPicker) #expect(!context.viewState.bindings.isShowingCustomStatusField) - #expect(context.viewState.userStatusRowMode == .pick) + #expect(context.viewState.userStatusRowMode == .pickStatusButton) // When choosing to pick a status. context.send(viewAction: .userStatus(.pickStatus)) @@ -34,7 +34,7 @@ struct SettingsScreenViewModelTests { // Then the status picker should be presented. #expect(context.viewState.bindings.isPresentingStatusPicker) #expect(!context.viewState.bindings.isShowingCustomStatusField) - #expect(context.viewState.userStatusRowMode == .pick) + #expect(context.viewState.userStatusRowMode == .pickStatusButton) // When choosing to enter a custom status. context.send(viewAction: .userStatus(.customStatus)) @@ -42,7 +42,7 @@ struct SettingsScreenViewModelTests { // 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(emoji: "😄")) + #expect(context.viewState.userStatusRowMode == .customStatusInput(emoji: "😄")) // When cancelling. context.send(viewAction: .userStatus(.cancel)) @@ -50,7 +50,7 @@ struct SettingsScreenViewModelTests { // Then all status editing should be dismissed. #expect(!context.viewState.bindings.isPresentingStatusPicker) #expect(!context.viewState.bindings.isShowingCustomStatusField) - #expect(context.viewState.userStatusRowMode == .pick) + #expect(context.viewState.userStatusRowMode == .pickStatusButton) } @Test @@ -82,12 +82,36 @@ struct SettingsScreenViewModelTests { return .success(()) } 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.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. try await statusRemoved.fulfill() - #expect(clientProxy.removeUserStatusCallsCount == 1) - #expect(clientProxy.setUserStatusCallsCount == 1) + #expect(clientProxy.removeCallStatusCallsCount == 1) + #expect(!clientProxy.removeUserStatusCalled) + #expect(!clientProxy.setUserStatusCalled) } @Test @@ -97,8 +121,8 @@ struct SettingsScreenViewModelTests { setupViewModel(status: status) // Then that status should be shown. - let rawStatus = try #require(status.raw) - #expect(context.viewState.userStatusRowMode == .show(rawStatus)) + let displayedStatus = try #require(status.displayed) + #expect(context.viewState.userStatusRowMode == .showingStatus(displayedStatus)) } @Test @@ -106,20 +130,20 @@ struct SettingsScreenViewModelTests { // Given a custom status field showing the default emoji. setupViewModel() context.send(viewAction: .userStatus(.customStatus)) - #expect(context.viewState.userStatusRowMode == .custom(emoji: "😄")) + #expect(context.viewState.userStatusRowMode == .customStatusInput(emoji: "😄")) // When selecting an emoji from the picker. try await selectCustomStatusEmoji("🎉") // 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. context.send(viewAction: .userStatus(.cancel)) context.send(viewAction: .userStatus(.customStatus)) // Then the emoji should have been reset to the default. - #expect(context.viewState.userStatusRowMode == .custom(emoji: "😄")) + #expect(context.viewState.userStatusRowMode == .customStatusInput(emoji: "😄")) } @Test