diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 8fc414104..53ae8e7da 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -430,13 +430,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } presentMediaUploadPickerWithMode(mode, caption: caption, timelineController: timelineController, animated: animated) - case (_, .presentEmojiPicker, .emojiPicker(let itemID, let selectedEmoji, _)): - guard let timelineController = (context.userInfo as? EventUserInfo)?.timelineController else { - fatalError("Missing required TimelineController") + case (_, .presentEmojiPicker, .emojiPicker(let selectedEmojis, _)): + guard let continuation = (context.userInfo as? EventUserInfo)?.emojiPickerContinuation else { + fatalError("Missing required emoji continuation") } - presentEmojiPicker(for: itemID, - selectedEmoji: selectedEmoji, - timelineController: timelineController, + presentEmojiPicker(selectedEmojis: selectedEmojis, + emojiPickerContinuation: continuation, animated: animated) case (_, .presentMessageForwarding(let forwardingItem), .messageForwarding): @@ -706,9 +705,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .presentMediaUploadPreviewScreen(let url, let caption): stateMachine.tryEvent(.presentMediaUploadPreview(mediaURLs: url, caption: caption), userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) - case .presentEmojiPicker(let itemID, let selectedEmojis): - stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis), - userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) + case .presentEmojiPicker(let selectedEmojis, let continuation): + stateMachine.tryEvent(.presentEmojiPicker(selectedEmojis: selectedEmojis), + userInfo: EventUserInfo(animated: animated, emojiPickerContinuation: continuation)) case .presentLocationPicker: stateMachine.tryEvent(.presentMapNavigator(interactionMode: .picker(shouldShowLiveLocationOption: true)), userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) @@ -831,9 +830,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewStatic(location)), userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) - case .presentEmojiPicker(let itemID, let selectedEmojis): - stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis), - userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) + case .presentEmojiPicker(let selectedEmojis, let continuation): + stateMachine.tryEvent(.presentEmojiPicker(selectedEmojis: selectedEmojis), + userInfo: EventUserInfo(animated: animated, emojiPickerContinuation: continuation)) case .presentRoomMemberDetails(let userID): stateMachine.tryEvent(.startMembersFlow(entryPoint: .roomMember(userID: userID))) case .presentMessageForwarding(let forwardingItem): @@ -1146,14 +1145,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } - private func presentEmojiPicker(for itemID: TimelineItemIdentifier, - selectedEmoji: Set, - timelineController: TimelineControllerProtocol, + private func presentEmojiPicker(selectedEmojis: Set, + emojiPickerContinuation: EmojiPickerScreenContinuation, animated: Bool) { - let params = EmojiPickerScreenCoordinatorParameters(itemID: itemID, - selectedEmojis: selectedEmoji, + let params = EmojiPickerScreenCoordinatorParameters(selectedEmojis: selectedEmojis, emojiProvider: flowParameters.emojiProvider, - timelineController: timelineController) + continuation: emojiPickerContinuation) let coordinator = EmojiPickerScreenCoordinator(parameters: params) coordinator.actions.sink { [weak self] action in diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift index ccf1d78fe..da33ca1bb 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift @@ -56,7 +56,7 @@ extension RoomFlowCoordinator { case inviteUsersScreen(flow: InviteUsersFlow, previousState: State) case mediaUploadPicker(mode: MediaPickerScreenMode, caption: NSAttributedString, previousState: State) case mediaUploadPreview(mediaURLs: [URL], caption: NSAttributedString?, previousState: State) - case emojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set, previousState: State) + case emojiPicker(selectedEmojis: Set, previousState: State) case mapNavigator(previousState: State) case messageForwarding(forwardingItem: MessageForwardingItem, previousState: State) case reportContent(itemID: TimelineItemIdentifier, senderID: String, previousState: State) @@ -90,6 +90,7 @@ extension RoomFlowCoordinator { var timelineController: TimelineControllerProtocol? var spaceRoomListProxy: SpaceRoomListProxyProtocol? var authorizedSpacesSelection: AuthorizedSpacesSelection? + var emojiPickerContinuation: EmojiPickerScreenContinuation? } enum Event: EventType { @@ -133,7 +134,7 @@ extension RoomFlowCoordinator { case presentMediaUploadPreview(mediaURLs: [URL], caption: NSAttributedString?) case dismissMediaUploadPreview - case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set) + case presentEmojiPicker(selectedEmojis: Set) case dismissEmojiPicker case presentMapNavigator(interactionMode: LocationSharingInteractionMode) @@ -204,8 +205,8 @@ extension RoomFlowCoordinator { case (.room, .presentMediaUploadPreview(let mediaURLs, let caption)): return .mediaUploadPreview(mediaURLs: mediaURLs, caption: caption, previousState: fromState) - case (.room, .presentEmojiPicker(let itemID, let selectedEmoji)): - return .emojiPicker(itemID: itemID, selectedEmojis: selectedEmoji, previousState: fromState) + case (.room, .presentEmojiPicker(let selectedEmojis)): + return .emojiPicker(selectedEmojis: selectedEmojis, previousState: fromState) case (.room, .presentMessageForwarding(let forwardingItem)): return .messageForwarding(forwardingItem: forwardingItem, previousState: fromState) @@ -253,8 +254,8 @@ extension RoomFlowCoordinator { case (.thread, .presentMediaUploadPreview(let mediaURLs, let caption)): return .mediaUploadPreview(mediaURLs: mediaURLs, caption: caption, previousState: fromState) - case (.thread, .presentEmojiPicker(let itemID, let selectedEmoji)): - return .emojiPicker(itemID: itemID, selectedEmojis: selectedEmoji, previousState: fromState) + case (.thread, .presentEmojiPicker(let selectedEmojis)): + return .emojiPicker(selectedEmojis: selectedEmojis, previousState: fromState) case (.thread, .presentMessageForwarding(let forwardingItem)): return .messageForwarding(forwardingItem: forwardingItem, previousState: fromState) @@ -273,7 +274,7 @@ extension RoomFlowCoordinator { case (.mediaUploadPicker(_, _, let previousState), .dismissMediaUploadPicker): return previousState - case (.emojiPicker(_, _, let previouState), .dismissEmojiPicker): + case (.emojiPicker(_, let previouState), .dismissEmojiPicker): return previouState case (.reportContent(_, _, let previousState), .dismissReportContent): diff --git a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift index 41457d0d9..b56c0fbcd 100644 --- a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift @@ -91,6 +91,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { startEncryptionSettingsFlow() case .userDetails: presentUserDetailsEditScreen() + case let .userStatusEmojiPicker(continuation): + presentEmojiPicker(emojiPickerContinuation: continuation) case .linkNewDevice: startLinkNewDeviceFlow() case let .manageAccount(url): @@ -159,6 +161,22 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { coordinator.start() } + private func presentEmojiPicker(emojiPickerContinuation: EmojiPickerScreenContinuation) { + let coordinator = EmojiPickerScreenCoordinator(parameters: .init(selectedEmojis: [], + emojiProvider: flowParameters.emojiProvider, + continuation: emojiPickerContinuation)) + coordinator.actions + .sink { [weak self] action in + switch action { + case .dismiss: + self?.navigationStackCoordinator.setSheetCoordinator(nil) + } + } + .store(in: &cancellables) + + navigationStackCoordinator.setSheetCoordinator(coordinator) + } + private func presentUserDetailsEditScreen() { let coordinator = UserDetailsEditScreenCoordinator(parameters: .init(orientationManager: flowParameters.windowManager, userSession: flowParameters.userSession, diff --git a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenCoordinator.swift b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenCoordinator.swift index d52fc74b0..e3f75e4e5 100644 --- a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenCoordinator.swift +++ b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenCoordinator.swift @@ -10,10 +10,11 @@ import Combine import SwiftUI struct EmojiPickerScreenCoordinatorParameters { - let itemID: TimelineItemIdentifier + /// Any emojis that should be displayed as already selected. let selectedEmojis: Set let emojiProvider: EmojiProviderProtocol - let timelineController: TimelineControllerProtocol + /// A continuation that yields the selected emoji. + let continuation: EmojiPickerScreenContinuation } enum EmojiPickerScreenCoordinatorAction { @@ -31,10 +32,9 @@ final class EmojiPickerScreenCoordinator: CoordinatorProtocol { } init(parameters: EmojiPickerScreenCoordinatorParameters) { - viewModel = EmojiPickerScreenViewModel(itemID: parameters.itemID, - selectedEmojis: parameters.selectedEmojis, + viewModel = EmojiPickerScreenViewModel(selectedEmojis: parameters.selectedEmojis, emojiProvider: parameters.emojiProvider, - timelineController: parameters.timelineController) + continuation: parameters.continuation) } func start() { @@ -50,6 +50,10 @@ final class EmojiPickerScreenCoordinator: CoordinatorProtocol { .store(in: &cancellables) } + func stop() { + viewModel.stop() + } + func toPresentable() -> AnyView { AnyView(EmojiPickerScreen(context: viewModel.context)) } diff --git a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenModels.swift b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenModels.swift index 2139ffc2b..df804c608 100644 --- a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenModels.swift +++ b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenModels.swift @@ -8,6 +8,8 @@ import Foundation +typealias EmojiPickerScreenContinuation = AsyncStream.Continuation + enum EmojiPickerScreenViewModelAction { case dismiss } diff --git a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModel.swift b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModel.swift index 4e35010dd..34be9ac6f 100644 --- a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModel.swift +++ b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModel.swift @@ -12,26 +12,28 @@ import SwiftUI typealias EmojiPickerScreenViewModelType = StateStoreViewModelV2 class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScreenViewModelProtocol { - private let itemID: TimelineItemIdentifier private let emojiProvider: EmojiProviderProtocol - private let timelineController: TimelineControllerProtocol + private let continuation: EmojiPickerScreenContinuation private var actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - init(itemID: TimelineItemIdentifier, selectedEmojis: Set, emojiProvider: EmojiProviderProtocol, timelineController: TimelineControllerProtocol) { + init(selectedEmojis: Set, emojiProvider: EmojiProviderProtocol, continuation: EmojiPickerScreenContinuation) { let initialViewState = EmojiPickerScreenViewState(categories: [], selectedEmojis: selectedEmojis) - self.itemID = itemID self.emojiProvider = emojiProvider - self.timelineController = timelineController + self.continuation = continuation super.init(initialViewState: initialViewState) loadEmojis() } // MARK: - Public + func stop() { + continuation.finish() // Ensure the continuation always finishes even without a selection. + } + override func process(viewAction: EmojiPickerScreenViewAction) { switch viewAction { case let .search(searchString: searchString): @@ -40,7 +42,7 @@ class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScr state.categories = convert(emojiCategories: categories) } case let .emojiTapped(emoji: emoji): - Task { await selectEmoji(emoji) } + selectEmoji(emoji) case .dismiss: actionsSubject.send(.dismiss) } @@ -66,16 +68,12 @@ class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScr } } - private func selectEmoji(_ emoji: EmojiPickerEmojiViewData) async { - MXLog.debug("Selected \(emoji) for \(itemID)") + private func selectEmoji(_ emoji: EmojiPickerEmojiViewData) { emojiProvider.markEmojiAsFrequentlyUsed(emoji.value) - guard case let .event(_, eventOrTransactionID) = itemID else { fatalError("Attempted to react to a virtual item.") } + continuation.yield(emoji.value) + continuation.finish() - // There aren't any local echoes when the toggle redacts, so dismiss the screen early - // until we have them: https://github.com/matrix-org/matrix-rust-sdk/issues/4162 actionsSubject.send(.dismiss) - - await timelineController.toggleReaction(emoji.value, to: eventOrTransactionID) } } diff --git a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModelProtocol.swift b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModelProtocol.swift index 4630126c7..d80436b6c 100644 --- a/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModelProtocol.swift +++ b/ElementX/Sources/Screens/EmojiPickerScreen/EmojiPickerScreenViewModelProtocol.swift @@ -11,4 +11,6 @@ import Combine protocol EmojiPickerScreenViewModelProtocol { var actions: AnyPublisher { get } var context: EmojiPickerScreenViewModelType.Context { get } + + func stop() } diff --git a/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift b/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift index 0a1492d09..4287b4c6d 100644 --- a/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift +++ b/ElementX/Sources/Screens/EmojiPickerScreen/View/EmojiPickerScreen.swift @@ -102,10 +102,9 @@ struct EmojiPickerScreen: View { @available(iOS 26.0, *) struct EmojiPickerScreen_Previews: PreviewProvider, TestablePreview { - static let viewModel = EmojiPickerScreenViewModel(itemID: .randomEvent, - selectedEmojis: ["😀", "😄"], + static let viewModel = EmojiPickerScreenViewModel(selectedEmojis: ["😀", "😄"], emojiProvider: EmojiProvider(appSettings: .volatile()), - timelineController: TimelineControllerMock(.init())) + continuation: AsyncStream.makeStream().continuation) static var previews: some View { EmojiPickerScreen(context: viewModel.context) @@ -115,10 +114,9 @@ struct EmojiPickerScreen_Previews: PreviewProvider, TestablePreview { } struct EmojiPickerScreenSheet_Previews: PreviewProvider { - static let viewModel = EmojiPickerScreenViewModel(itemID: .randomEvent, - selectedEmojis: ["😀", "😄"], + static let viewModel = EmojiPickerScreenViewModel(selectedEmojis: ["😀", "😄"], emojiProvider: EmojiProvider(appSettings: .volatile()), - timelineController: TimelineControllerMock(.init())) + continuation: AsyncStream.makeStream().continuation) static var previews: some View { Text("Timeline view") diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 06eea22ee..952df0fcb 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -41,7 +41,7 @@ enum RoomScreenCoordinatorAction { case presentPollForm(mode: PollFormMode) case presentLocationViewer(StaticLocationData) case presentLiveLocationViewer(sender: TimelineItemSender?, initialLiveLocationShare: LiveLocationShare?) - case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set) + case presentEmojiPicker(selectedEmojis: Set, continuation: EmojiPickerScreenContinuation) case presentRoomMemberDetails(userID: String) case presentMessageForwarding(forwardingItem: MessageForwardingItem) case presentCallScreen(isVoiceCall: Bool) @@ -117,8 +117,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { - case .displayEmojiPicker(let itemID, let selectedEmojis): - actionsSubject.send(.presentEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis)) + case .displayEmojiPicker(let selectedEmojis, let continuation): + actionsSubject.send(.presentEmojiPicker(selectedEmojis: selectedEmojis, continuation: continuation)) case .displayReportContent(let itemID, let senderID): actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID)) case .displayCameraPicker: diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift index ce95b286c..0eb5b8f76 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift @@ -22,6 +22,7 @@ enum SettingsScreenCoordinatorAction { case logout case secureBackup case userDetails + case userStatusEmojiPicker(EmojiPickerScreenContinuation) case analytics case appLock case bugReport @@ -64,6 +65,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.dismiss) case .userDetails: actionsSubject.send(.userDetails) + case let .userStatusEmojiPicker(continuation): + actionsSubject.send(.userStatusEmojiPicker(continuation)) case .linkNewDevice: actionsSubject.send(.linkNewDevice) case let .manageAccount(url): diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift index bbbe1d9bd..b26ee5f13 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift @@ -8,9 +8,10 @@ import SwiftUI -enum SettingsScreenViewModelAction: Equatable { +enum SettingsScreenViewModelAction { case close case userDetails + case userStatusEmojiPicker(EmojiPickerScreenContinuation) case linkNewDevice case manageAccount(url: URL) case analytics @@ -55,7 +56,7 @@ struct SettingsScreenViewState: BindableState { var userStatusRowMode: SettingsScreenUserStatusRow.Mode { if bindings.isShowingCustomStatusField { - .custom + .custom(emoji: bindings.customStatusEmoji) } else if let rawStatus = userProfile.status.raw { .show(rawStatus) } else { @@ -66,7 +67,15 @@ struct SettingsScreenViewState: BindableState { struct SettingsScreenViewStateBindings { var isPresentingStatusPicker = false - var isShowingCustomStatusField = false + var customStatusEmoji: Character = "😄" + var isShowingCustomStatusField = false { + didSet { + if !isShowingCustomStatusField { + customStatusEmoji = "😄" // Reset the emoji. + } + } + } + var isPresentingAccountDeactivationConfirmation = false } diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift index 3e8f11950..2a1cbe380 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift @@ -103,7 +103,7 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo state.bindings.isPresentingStatusPicker = false state.bindings.isShowingCustomStatusField = true case .userStatus(.pickCustomEmoji): - break // Hook-up the emoji picker here. + pickCustomEmoji() case .userStatus(.set(let status)): Task { await setUserStatus(status) } case .userStatus(.cancel): @@ -144,6 +144,19 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo // MARK: - Private + private var pickCustomEmojiCancellable: AnyCancellable? + private func pickCustomEmoji() { + let (stream, continuation) = AsyncStream.makeStream() + actionsSubject.send(.userStatusEmojiPicker(continuation)) + + pickCustomEmojiCancellable = Task { [weak self] in + for await emoji in stream { + self?.state.bindings.customStatusEmoji = Character(emoji) + } + } + .asCancellable() + } + func setUserStatus(_ status: UserStatus.Raw?) async { // Loading state tbc state.bindings.isPresentingStatusPicker = false diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift index 8e7199f6d..f9aef3a25 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreenUserStatusRow.swift @@ -9,11 +9,10 @@ import Compound import SwiftUI struct SettingsScreenUserStatusRow: View { - enum Mode: Equatable { case pick, custom, show(UserStatus.Raw) } + enum Mode: Equatable { case pick, custom(emoji: Character), show(UserStatus.Raw) } let mode: Mode let action: (SettingsScreenViewAction.UserStatusAction) -> Void - @State private var customEmoji = "😄" @State private var customText = "" var body: some View { @@ -21,15 +20,15 @@ struct SettingsScreenUserStatusRow: View { case .pick: ListRow(label: .default(title: L10n.screenSettingsUserStatusPlaceholder, icon: \.reaction), kind: .button { action(.pickStatus) }) - case .custom: + case .custom(let emoji): ListRow(kind: .custom { HStack(spacing: ListRowPadding.labelIconSpacing) { Button { action(.pickCustomEmoji) } label: { - Text(customEmoji) + Text(String(emoji)) .font(.compound.headingSM) .foregroundStyle(.compound.textPrimary) .accessibilityLabel(L10n.a11yCustomEmoji) - .accessibilityValue(customEmoji) + .accessibilityValue(String(emoji)) } .buttonStyle(EditEmojiButtonStyle()) @@ -81,7 +80,7 @@ struct SettingsScreenUserStatusRow: View { } private func saveCustomStatus() { - guard let emoji = customEmoji.first else { return } + guard case let .custom(emoji) = mode else { return } action(.set(.init(text: customText, emoji: emoji))) } @@ -109,7 +108,7 @@ struct SettingsScreenUserStatusRow_Previews: PreviewProvider, TestablePreview { } Section { - SettingsScreenUserStatusRow(mode: .custom) { _ in } + SettingsScreenUserStatusRow(mode: .custom(emoji: "😄")) { _ in } } Section { diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift index 5a9b24b5c..4c253fb0c 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift @@ -36,7 +36,7 @@ enum ThreadTimelineScreenCoordinatorAction { case presentLocationViewer(StaticLocationData) case presentLiveLocationViewer(sender: TimelineItemSender, initialLiveLocationShare: LiveLocationShare) case presentPollForm(mode: PollFormMode) - case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set) + case presentEmojiPicker(selectedEmojis: Set, continuation: EmojiPickerScreenContinuation) case presentRoomMemberDetails(userID: String) case presentMessageForwarding(forwardingItem: MessageForwardingItem) case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) @@ -102,8 +102,8 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { - case .displayEmojiPicker(let itemID, let selectedEmojis): - actionsSubject.send(.presentEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis)) + case .displayEmojiPicker(let selectedEmojis, let continuation): + actionsSubject.send(.presentEmojiPicker(selectedEmojis: selectedEmojis, continuation: continuation)) case .displayReportContent(let itemID, let senderID): actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID)) case .displayCameraPicker: diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index ecac2cbb3..8480c91c8 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -12,7 +12,7 @@ import UIKit enum TimelineInteractionHandlerAction { case composer(action: TimelineComposerAction) - case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set) + case displayEmojiPicker(selectedEmojis: Set, continuation: EmojiPickerScreenContinuation) case displayReportContent(itemID: TimelineItemIdentifier, senderID: String) case displayMessageForwarding(itemID: TimelineItemIdentifier) case displayMediaUploadPreviewScreen(mediaURLs: [URL]) @@ -59,6 +59,8 @@ class TimelineInteractionHandler { private var resumeVoiceMessagePlaybackAfterScrubbing = false + private var emojiPickerCancellable: AnyCancellable? + init(roomProxy: JoinedRoomProxyProtocol, timelineController: TimelineControllerProtocol, userSession: UserSessionProtocol, @@ -518,11 +520,21 @@ class TimelineInteractionHandler { func displayEmojiPicker(for itemID: TimelineItemIdentifier) { guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID), timelineItem.isReactable, - let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else { + let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol, + case let .event(_, eventOrTransactionID) = itemID else { return } let selectedEmojis = Set(eventTimelineItem.properties.reactions.compactMap { $0.isHighlighted ? $0.key : nil }) - actionsSubject.send(.displayEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis)) + + let (stream, continuation) = AsyncStream.makeStream() + actionsSubject.send(.displayEmojiPicker(selectedEmojis: selectedEmojis, continuation: continuation)) + + emojiPickerCancellable = Task { [weak self] in + for await emoji in stream { + await self?.timelineController.toggleReaction(emoji, to: eventOrTransactionID) + } + } + .asCancellable() } func processItemTap(_ itemID: TimelineItemIdentifier) async -> TimelineControllerAction { diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 51bf92408..6043d7f77 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -12,7 +12,7 @@ import OrderedCollections import SwiftUI enum TimelineViewModelAction { - case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set) + case displayEmojiPicker(selectedEmojis: Set, continuation: EmojiPickerScreenContinuation) case displayReportContent(itemID: TimelineItemIdentifier, senderID: String) case displayCameraPicker case displayMediaPicker diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index bacce1d17..0e33786d8 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -511,8 +511,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { displayAlert(.audioRecodingPermissionError) case .displayErrorToast(let title): displayErrorToast(title) - case .displayEmojiPicker(let itemID, let selectedEmojis): - actionsSubject.send(.displayEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis)) + case .displayEmojiPicker(let selectedEmojis, let continuation): + actionsSubject.send(.displayEmojiPicker(selectedEmojis: selectedEmojis, continuation: continuation)) case .displayMessageForwarding(let itemID): Task { await self.forwardMessage(itemID: itemID) } case .displayEditPollForm(let eventID, let poll): diff --git a/UnitTests/Sources/EmojiPickerScreenViewModelTests.swift b/UnitTests/Sources/EmojiPickerScreenViewModelTests.swift index b7a5d5c3e..12520222f 100644 --- a/UnitTests/Sources/EmojiPickerScreenViewModelTests.swift +++ b/UnitTests/Sources/EmojiPickerScreenViewModelTests.swift @@ -11,7 +11,7 @@ import Testing @MainActor struct EmojiPickerScreenViewModelTests { - var timelineProxy: TimelineProxyMock! + var emojiPickerStream: AsyncStream! var viewModel: EmojiPickerScreenViewModel! var context: EmojiPickerScreenViewModel.Context { @@ -19,33 +19,43 @@ struct EmojiPickerScreenViewModelTests { } @Test - mutating func toggleReaction() async throws { + mutating func selectEmoji() async throws { + // Given a freshly presented emoji picker. setupViewModel() let reaction = "👋" + // When the user taps an emoji. let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss } + context.send(viewAction: .emojiTapped(emoji: .init(id: "wave", value: reaction))) - await waitForConfirmation(timeout: .seconds(5)) { confirmation in - timelineProxy.toggleReactionToClosure = { toggledReaction, _ in - defer { confirmation() } - #expect(toggledReaction == reaction) - return .success(()) - } - - context.send(viewAction: .emojiTapped(emoji: .init(id: "wave", value: reaction))) - } - + // Then the screen should dismiss and yield the emoji before finishing the stream. try await deferred.fulfill() + var iterator = emojiPickerStream.makeAsyncIterator() + #expect(await iterator.next() == reaction) + #expect(await iterator.next() == nil) + } + + @Test + mutating func stopFinishesTheStream() async { + // Given a freshly presented emoji picker. + setupViewModel() + + // When it is stopped without a selection (e.g. the picker is dismissed). + viewModel.stop() + + // Then the stream should finish without yielding an emoji. + var iterator = emojiPickerStream.makeAsyncIterator() + #expect(await iterator.next() == nil) } // MARK: - Helpers private mutating func setupViewModel(selectedEmojis: Set = []) { - timelineProxy = TimelineProxyMock(.init()) + let (stream, continuation) = AsyncStream.makeStream() + emojiPickerStream = stream - viewModel = EmojiPickerScreenViewModel(itemID: .randomEvent, - selectedEmojis: selectedEmojis, + viewModel = EmojiPickerScreenViewModel(selectedEmojis: selectedEmojis, emojiProvider: EmojiProvider(appSettings: .volatile()), - timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy))) + continuation: continuation) } } diff --git a/UnitTests/Sources/SettingsScreenViewModelTests.swift b/UnitTests/Sources/SettingsScreenViewModelTests.swift index 4e9d14284..ceebcda12 100644 --- a/UnitTests/Sources/SettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/SettingsScreenViewModelTests.swift @@ -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) + #expect(context.viewState.userStatusRowMode == .custom(emoji: "😄")) // When cancelling. context.send(viewAction: .userStatus(.cancel)) @@ -101,11 +101,32 @@ struct SettingsScreenViewModelTests { #expect(context.viewState.userStatusRowMode == .show(rawStatus)) } + @Test + mutating func selectingCustomStatusEmoji() async throws { + // Given a custom status field showing the default emoji. + setupViewModel() + context.send(viewAction: .userStatus(.customStatus)) + #expect(context.viewState.userStatusRowMode == .custom(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: "🎉")) + + // 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: "😄")) + } + @Test mutating func reportBug() async throws { setupViewModel() - let deferred = deferFulfillment(viewModel.actions) { $0 == .reportBug } + let deferred = deferFulfillment(viewModel.actions) { $0.isReportBug } context.send(viewAction: .reportBug) try await deferred.fulfill() } @@ -114,7 +135,7 @@ struct SettingsScreenViewModelTests { mutating func analytics() async throws { setupViewModel() - let deferred = deferFulfillment(viewModel.actions) { $0 == .analytics } + let deferred = deferFulfillment(viewModel.actions) { $0.isAnalytics } context.send(viewAction: .analytics) try await deferred.fulfill() } @@ -123,7 +144,7 @@ struct SettingsScreenViewModelTests { mutating func logout() async throws { setupViewModel() - let deferred = deferFulfillment(viewModel.actions) { $0 == .logout } + let deferred = deferFulfillment(viewModel.actions) { $0.isLogout } context.send(viewAction: .logout) try await deferred.fulfill() } @@ -138,4 +159,48 @@ struct SettingsScreenViewModelTests { isInSecondaryWindow: false, userIndicatorController: UserIndicatorControllerMock()) } + + /// Shows the emoji picker and selects the provided emoji through its continuation. + private func selectCustomStatusEmoji(_ emoji: String) async throws { + let pickerPresented = deferFulfillment(viewModel.actions) { $0.isUserStatusEmojiPicker } + context.send(viewAction: .userStatus(.pickCustomEmoji)) + guard case let .userStatusEmojiPicker(continuation) = try await pickerPresented.fulfill() else { + Issue.record("Expected the emoji picker to be presented.") + return + } + + let emojiUpdated = deferFulfillment(context.observe(\.viewState.bindings.customStatusEmoji)) { $0 == Character(emoji) } + continuation.yield(emoji) + try await emojiUpdated.fulfill() + } +} + +private extension SettingsScreenViewModelAction { + var isUserStatusEmojiPicker: Bool { + switch self { + case .userStatusEmojiPicker: true + default: false + } + } + + var isReportBug: Bool { + switch self { + case .reportBug: true + default: false + } + } + + var isAnalytics: Bool { + switch self { + case .analytics: true + default: false + } + } + + var isLogout: Bool { + switch self { + case .logout: true + default: false + } + } }