mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
Hook up the emoji picker to the custom status row. (#5902)
* Refactor EmojiPickerScreen to take a continuation instead of a timeline/itemID. * Hook up the emoji picker to the custom status row.
This commit is contained in:
@@ -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<String>,
|
||||
timelineController: TimelineControllerProtocol,
|
||||
private func presentEmojiPicker(selectedEmojis: Set<String>,
|
||||
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
|
||||
|
||||
@@ -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<String>, previousState: State)
|
||||
case emojiPicker(selectedEmojis: Set<String>, 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<String>)
|
||||
case presentEmojiPicker(selectedEmojis: Set<String>)
|
||||
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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
typealias EmojiPickerScreenContinuation = AsyncStream<String>.Continuation
|
||||
|
||||
enum EmojiPickerScreenViewModelAction {
|
||||
case dismiss
|
||||
}
|
||||
|
||||
@@ -12,26 +12,28 @@ import SwiftUI
|
||||
typealias EmojiPickerScreenViewModelType = StateStoreViewModelV2<EmojiPickerScreenViewState, EmojiPickerScreenViewAction>
|
||||
|
||||
class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScreenViewModelProtocol {
|
||||
private let itemID: TimelineItemIdentifier
|
||||
private let emojiProvider: EmojiProviderProtocol
|
||||
private let timelineController: TimelineControllerProtocol
|
||||
private let continuation: EmojiPickerScreenContinuation
|
||||
|
||||
private var actionsSubject: PassthroughSubject<EmojiPickerScreenViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<EmojiPickerScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>, emojiProvider: EmojiProviderProtocol, timelineController: TimelineControllerProtocol) {
|
||||
init(selectedEmojis: Set<String>, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ import Combine
|
||||
protocol EmojiPickerScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<EmojiPickerScreenViewModelAction, Never> { get }
|
||||
var context: EmojiPickerScreenViewModelType.Context { get }
|
||||
|
||||
func stop()
|
||||
}
|
||||
|
||||
@@ -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<String>.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<String>.makeStream().continuation)
|
||||
|
||||
static var previews: some View {
|
||||
Text("Timeline view")
|
||||
|
||||
@@ -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<String>)
|
||||
case presentEmojiPicker(selectedEmojis: Set<String>, 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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>.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
|
||||
|
||||
+6
-7
@@ -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 {
|
||||
|
||||
@@ -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<String>)
|
||||
case presentEmojiPicker(selectedEmojis: Set<String>, 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:
|
||||
|
||||
@@ -12,7 +12,7 @@ import UIKit
|
||||
enum TimelineInteractionHandlerAction {
|
||||
case composer(action: TimelineComposerAction)
|
||||
|
||||
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||
case displayEmojiPicker(selectedEmojis: Set<String>, 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<String>.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 {
|
||||
|
||||
@@ -12,7 +12,7 @@ import OrderedCollections
|
||||
import SwiftUI
|
||||
|
||||
enum TimelineViewModelAction {
|
||||
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||
case displayEmojiPicker(selectedEmojis: Set<String>, continuation: EmojiPickerScreenContinuation)
|
||||
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
|
||||
case displayCameraPicker
|
||||
case displayMediaPicker
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -11,7 +11,7 @@ import Testing
|
||||
|
||||
@MainActor
|
||||
struct EmojiPickerScreenViewModelTests {
|
||||
var timelineProxy: TimelineProxyMock!
|
||||
var emojiPickerStream: AsyncStream<String>!
|
||||
|
||||
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<String> = []) {
|
||||
timelineProxy = TimelineProxyMock(.init())
|
||||
let (stream, continuation) = AsyncStream<String>.makeStream()
|
||||
emojiPickerStream = stream
|
||||
|
||||
viewModel = EmojiPickerScreenViewModel(itemID: .randomEvent,
|
||||
selectedEmojis: selectedEmojis,
|
||||
viewModel = EmojiPickerScreenViewModel(selectedEmojis: selectedEmojis,
|
||||
emojiProvider: EmojiProvider(appSettings: .volatile()),
|
||||
timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)))
|
||||
continuation: continuation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user