Use the Emoji/Map/Poll view models. (#4458)

* Toggle emojis in the EmojiPickerScreenViewModel.

* Send locations in the StaticLocationScreen.

* Send polls in the PollFormScreen.
This commit is contained in:
Doug
2025-09-03 16:52:28 +01:00
committed by GitHub
parent 34088f8423
commit 811f02962d
21 changed files with 398 additions and 255 deletions

View File

@@ -673,6 +673,7 @@
7A642EE5F1ADC5D520F21924 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */; };
7A71AEF419904209BB8C2833 /* UserAgentBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */; };
7A8B264506D3DDABC01B4EEB /* AppMediator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53AC78E49A297AC1D72A7CF /* AppMediator.swift */; };
7AE25D29734267271106D732 /* EmojiPickerScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1087DCC491CD4C027173DDA /* EmojiPickerScreenViewModelTests.swift */; };
7B1605C6FFD4D195F264A684 /* RoomPollsHistoryScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40233F2989AD49906BB310D /* RoomPollsHistoryScreenViewModelTests.swift */; };
7B3A59786DB2F741A1743ED0 /* PinnedEventsTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510E89B989477E5EE8E503C0 /* PinnedEventsTimelineScreenViewModelProtocol.swift */; };
7B66DA4E7E5FE4D1A0FCEAA4 /* JoinRoomScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEAB5662310AE73D93815134 /* JoinRoomScreenViewModelProtocol.swift */; };
@@ -2297,6 +2298,7 @@
A07B011547B201A836C03052 /* EncryptionResetFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetFlowCoordinator.swift; sourceTree = "<group>"; };
A0E7B059E84E7E374D3322A2 /* SpaceRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomCell.swift; sourceTree = "<group>"; };
A103580EBA06155B1343EF16 /* HomeScreenKnockedCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenKnockedCell.swift; sourceTree = "<group>"; };
A1087DCC491CD4C027173DDA /* EmojiPickerScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenViewModelTests.swift; sourceTree = "<group>"; };
A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerProtocol.swift; sourceTree = "<group>"; };
A130A2251A15A7AACC84FD37 /* RoomPollsHistoryScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreenViewModelProtocol.swift; sourceTree = "<group>"; };
A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenCoordinator.swift; sourceTree = "<group>"; };
@@ -4505,6 +4507,7 @@
DEBB74427E24AF30CDB131B7 /* DeferredFulfillmentTests.swift */,
6D0A27607AB09784C8501B5C /* DeveloperOptionsScreenViewModelTests.swift */,
906451FB8CF27C628152BF7A /* EditRoomAddressScreenViewModelTests.swift */,
A1087DCC491CD4C027173DDA /* EmojiPickerScreenViewModelTests.swift */,
099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */,
84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */,
1A7ED2EF5BDBAD2A7DBC4636 /* GeoURITests.swift */,
@@ -7223,6 +7226,7 @@
A583B70939707197B0B21DFC /* DeferredFulfillmentTests.swift in Sources */,
864C69CF951BF36D25BE0C03 /* DeveloperOptionsScreenViewModelTests.swift in Sources */,
EDB6915EC953BB2A44AA608E /* EditRoomAddressScreenViewModelTests.swift in Sources */,
7AE25D29734267271106D732 /* EmojiPickerScreenViewModelTests.swift in Sources */,
25618589E0DE0F1E95FC7B5C /* EmojiProviderTests.swift in Sources */,
71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */,
07756D532EFE33DD1FA258E5 /* GeoURITests.swift in Sources */,

View File

@@ -82,7 +82,7 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
case .displayUser(let userID):
actionsSubject.send(.displayUser(userID: userID))
case .presentLocationViewer(let geoURI, let description):
presentMapNavigator(geoURI: geoURI, description: description)
presentMapNavigator(geoURI: geoURI, description: description, timelineController: timelineController)
case .displayMessageForwarding(let forwardingItem):
presentMessageForwarding(with: forwardingItem)
case .displayRoomScreenWithFocussedPin(let eventID):
@@ -94,20 +94,20 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
navigationStackCoordinator.setRootCoordinator(coordinator)
}
private func presentMapNavigator(geoURI: GeoURI, description: String?) {
private func presentMapNavigator(geoURI: GeoURI, description: String?, timelineController: TimelineControllerProtocol) {
let stackCoordinator = NavigationStackCoordinator()
let params = StaticLocationScreenCoordinatorParameters(interactionMode: .viewOnly(geoURI: geoURI, description: description),
mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration,
appMediator: flowParameters.appMediator)
timelineController: timelineController,
appMediator: flowParameters.appMediator,
analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController)
let coordinator = StaticLocationScreenCoordinator(parameters: params)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .selectedLocation:
// We don't handle the sending/picker case in this flow
break
case .close:
self.navigationStackCoordinator.setSheetCoordinator(nil)
}

View File

@@ -1005,24 +1005,16 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
selectedEmoji: Set<String>,
timelineController: TimelineControllerProtocol,
animated: Bool) {
let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: flowParameters.emojiProvider,
itemID: itemID, selectedEmojis: selectedEmoji)
let params = EmojiPickerScreenCoordinatorParameters(itemID: itemID,
selectedEmojis: selectedEmoji,
emojiProvider: flowParameters.emojiProvider,
timelineController: timelineController)
let coordinator = EmojiPickerScreenCoordinator(parameters: params)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case let .emojiSelected(emoji: emoji, itemID: itemID):
MXLog.debug("Selected \(emoji) for \(itemID)")
navigationStackCoordinator.setSheetCoordinator(nil)
Task {
guard case let .event(_, eventOrTransactionID) = itemID else {
fatalError()
}
await self.timelineController?.toggleReaction(emoji, to: eventOrTransactionID)
}
case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil)
}
@@ -1041,27 +1033,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let params = StaticLocationScreenCoordinatorParameters(interactionMode: interactionMode,
mapURLBuilder: flowParameters.appSettings.mapTilerConfiguration,
appMediator: flowParameters.appMediator)
timelineController: timelineController,
appMediator: flowParameters.appMediator,
analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController)
let coordinator = StaticLocationScreenCoordinator(parameters: params)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .selectedLocation(let geoURI, let isUserLocation):
Task {
_ = await timelineController.sendLocation(body: geoURI.bodyMessage,
geoURI: geoURI,
description: nil,
zoomLevel: 15,
assetType: isUserLocation ? .sender : .pin)
self.navigationStackCoordinator.setSheetCoordinator(nil)
}
self.flowParameters.analytics.trackComposer(inThread: false,
isEditing: false,
isReply: false,
messageType: isUserLocation ? .LocationUser : .LocationPin,
startsThread: nil)
case .close:
self.navigationStackCoordinator.setSheetCoordinator(nil)
}
@@ -1077,36 +1057,19 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
private func presentPollForm(mode: PollFormMode, timelineController: TimelineControllerProtocol) {
let stackCoordinator = NavigationStackCoordinator()
let coordinator = PollFormScreenCoordinator(parameters: .init(mode: mode))
let coordinator = PollFormScreenCoordinator(parameters: .init(mode: mode,
timelineController: timelineController,
analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController))
stackCoordinator.setRootCoordinator(coordinator)
coordinator.actions
.sink { [weak self] action in
guard let self else {
return
}
self.navigationStackCoordinator.setSheetCoordinator(nil)
guard let self else { return }
switch action {
case .cancel:
break
case .delete:
deletePoll(mode: mode)
case let .submit(question, options, pollKind):
switch mode {
case .new:
createPoll(question: question,
options: options,
pollKind: pollKind,
timelineController: timelineController)
case .edit(let eventID, _):
editPoll(pollStartID: eventID,
question: question,
options: options,
pollKind: pollKind,
timelineController: timelineController)
}
case .close:
navigationStackCoordinator.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
@@ -1116,58 +1079,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func createPoll(question: String, options: [String], pollKind: Poll.Kind, timelineController: TimelineControllerProtocol) {
Task {
let result = await timelineController.createPoll(question: question, answers: options, pollKind: pollKind)
self.flowParameters.analytics.trackComposer(inThread: false,
isEditing: false,
isReply: false,
messageType: .Poll,
startsThread: nil)
self.flowParameters.analytics.trackPollCreated(isUndisclosed: pollKind == .undisclosed, numberOfAnswers: options.count)
switch result {
case .success:
break
case .failure:
self.flowParameters.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
}
}
}
private func editPoll(pollStartID: String, question: String, options: [String], pollKind: Poll.Kind, timelineController: TimelineControllerProtocol) {
Task {
let result = await timelineController.editPoll(original: pollStartID, question: question, answers: options, pollKind: pollKind)
switch result {
case .success:
break
case .failure:
self.flowParameters.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
}
}
}
private func deletePoll(mode: PollFormMode) {
Task {
guard case .edit(let pollStartID, _) = mode else {
self.flowParameters.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
return
}
let result = await roomProxy.redact(pollStartID)
switch result {
case .success:
break
case .failure:
self.flowParameters.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
}
}
}
private func presentRoomPollsHistory(animated: Bool) async {
let userID = userSession.clientProxy.userID

View File

@@ -22,6 +22,8 @@ extension TimelineProxyMock {
paginateBackwardsRequestSizeReturnValue = .success(())
paginateForwardsRequestSizeReturnValue = .success(())
sendReadReceiptForTypeReturnValue = .success(())
createPollQuestionAnswersPollKindReturnValue = .success(())
editPollOriginalQuestionAnswersPollKindReturnValue = .success(())
if configuration.isAutoUpdating {
underlyingTimelineItemProvider = AutoUpdatingTimelineItemProviderMock()

View File

@@ -12,12 +12,13 @@ struct PollFormScreenCoordinatorParameters {
let mode: PollFormMode
/// The max number of allowed options, if no value provided the default value of the view model will be used.
var maxNumberOfOptions: Int?
let timelineController: TimelineControllerProtocol
let analytics: AnalyticsService
let userIndicatorController: UserIndicatorControllerProtocol
}
enum PollFormScreenCoordinatorAction {
case cancel
case delete
case submit(question: String, options: [String], pollKind: Poll.Kind)
case close
}
final class PollFormScreenCoordinator: CoordinatorProtocol {
@@ -30,7 +31,11 @@ final class PollFormScreenCoordinator: CoordinatorProtocol {
}
init(parameters: PollFormScreenCoordinatorParameters) {
viewModel = PollFormScreenViewModel(mode: parameters.mode, maxNumberOfOptions: parameters.maxNumberOfOptions)
viewModel = PollFormScreenViewModel(mode: parameters.mode,
maxNumberOfOptions: parameters.maxNumberOfOptions,
timelineController: parameters.timelineController,
analytics: parameters.analytics,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
@@ -39,12 +44,8 @@ final class PollFormScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .cancel:
self.actionsSubject.send(.cancel)
case .delete:
self.actionsSubject.send(.delete)
case let .submit(question, options, pollKind):
self.actionsSubject.send(.submit(question: question, options: options, pollKind: pollKind))
case .close:
self.actionsSubject.send(.close)
}
}
.store(in: &cancellables)

View File

@@ -8,9 +8,7 @@
import Foundation
enum PollFormScreenViewModelAction: Equatable {
case cancel
case delete
case submit(question: String, options: [String], pollKind: Poll.Kind)
case close
}
struct PollFormScreenViewState: BindableState {

View File

@@ -11,13 +11,24 @@ import SwiftUI
typealias PollFormScreenViewModelType = StateStoreViewModelV2<PollFormScreenViewState, PollFormScreenViewAction>
class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewModelProtocol {
private var actionsSubject: PassthroughSubject<PollFormScreenViewModelAction, Never> = .init()
private let timelineController: TimelineControllerProtocol
private let analytics: AnalyticsService
private let userIndicatorController: UserIndicatorControllerProtocol
private var actionsSubject: PassthroughSubject<PollFormScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<PollFormScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(mode: PollFormMode, maxNumberOfOptions: Int? = nil) {
init(mode: PollFormMode,
maxNumberOfOptions: Int? = nil,
timelineController: TimelineControllerProtocol,
analytics: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol) {
self.timelineController = timelineController
self.analytics = analytics
self.userIndicatorController = userIndicatorController
super.init(initialViewState: .init(mode: mode, maxNumberOfOptions: maxNumberOfOptions ?? 20))
}
@@ -26,24 +37,33 @@ class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewMo
override func process(viewAction: PollFormScreenViewAction) {
switch viewAction {
case .submit:
actionsSubject.send(.submit(question: state.bindings.question,
options: state.bindings.options.map(\.text),
pollKind: state.bindings.isUndisclosed ? .undisclosed : .disclosed))
let question = state.bindings.question
let options = state.bindings.options.map(\.text)
let pollKind = state.bindings.isUndisclosed ? Poll.Kind.undisclosed : .disclosed
Task {
switch state.mode {
case .new:
await createPoll(question: question, options: options, pollKind: pollKind)
case .edit(let eventID, _):
await editPoll(pollStartID: eventID, question: question, options: options, pollKind: pollKind)
}
}
case .delete:
state.bindings.alertInfo = .init(id: .init(),
title: L10n.screenEditPollDeleteConfirmationTitle,
message: L10n.screenEditPollDeleteConfirmation,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.delete) })
secondaryButton: .init(title: L10n.actionOk) { Task { await self.deletePoll() } })
case .cancel:
if state.formContentHasChanged {
state.bindings.alertInfo = .init(id: .init(),
title: L10n.screenCreatePollCancelConfirmationTitleIos,
message: L10n.screenCreatePollCancelConfirmationContentIos,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.cancel) })
secondaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.close) })
} else {
actionsSubject.send(.cancel)
actionsSubject.send(.close)
}
case .deleteOption(let index):
// fixes a crash that caused an index out of range when an option with the keyboard focus was deleted
@@ -61,4 +81,45 @@ class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewMo
state.bindings.options.append(.init())
}
}
// MARK: - Private
private func createPoll(question: String, options: [String], pollKind: Poll.Kind) async {
guard case .success = await timelineController.createPoll(question: question, answers: options, pollKind: pollKind) else {
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
return
}
actionsSubject.send(.close)
analytics.trackComposer(inThread: false,
isEditing: false,
isReply: false,
messageType: .Poll,
startsThread: nil)
analytics.trackPollCreated(isUndisclosed: pollKind == .undisclosed, numberOfAnswers: options.count)
}
private func editPoll(pollStartID: String, question: String, options: [String], pollKind: Poll.Kind) async {
switch await timelineController.editPoll(original: pollStartID, question: question, answers: options, pollKind: pollKind) {
case .success:
actionsSubject.send(.close)
case .failure:
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
}
}
private func deletePoll() async {
// There aren't any local echoes for redactions, so dismiss the screen early
// until we have them: https://github.com/matrix-org/matrix-rust-sdk/issues/4162
actionsSubject.send(.close)
guard case .edit(let pollStartID, _) = state.mode else {
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
return
}
await timelineController.redact(.eventID(pollStartID))
}
}

View File

@@ -197,8 +197,8 @@ private struct PollFormOptionRow: View {
// MARK: - Previews
struct PollFormScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = PollFormScreenViewModel(mode: .new)
static let editViewModel = PollFormScreenViewModel(mode: .edit(eventID: "1234", poll: poll))
static let viewModel = makeViewModel(mode: .new)
static let editViewModel = makeViewModel(mode: .edit(eventID: "1234", poll: poll))
static let poll = Poll(question: "Cats or Dogs?",
kind: .disclosed,
maxSelections: 1,
@@ -222,6 +222,13 @@ struct PollFormScreen_Previews: PreviewProvider, TestablePreview {
}
.previewDisplayName("Edit")
}
static func makeViewModel(mode: PollFormMode) -> PollFormScreenViewModel {
PollFormScreenViewModel(mode: mode,
timelineController: MockTimelineController(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock())
}
}
private extension Binding where Value == String {

View File

@@ -9,13 +9,13 @@ import Combine
import SwiftUI
struct EmojiPickerScreenCoordinatorParameters {
let emojiProvider: EmojiProviderProtocol
let itemID: TimelineItemIdentifier
let selectedEmojis: Set<String>
let emojiProvider: EmojiProviderProtocol
let timelineController: TimelineControllerProtocol
}
enum EmojiPickerScreenCoordinatorAction {
case emojiSelected(emoji: String, itemID: TimelineItemIdentifier)
case dismiss
}
@@ -33,7 +33,10 @@ final class EmojiPickerScreenCoordinator: CoordinatorProtocol {
init(parameters: EmojiPickerScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = EmojiPickerScreenViewModel(emojiProvider: parameters.emojiProvider)
viewModel = EmojiPickerScreenViewModel(itemID: parameters.itemID,
selectedEmojis: parameters.selectedEmojis,
emojiProvider: parameters.emojiProvider,
timelineController: parameters.timelineController)
}
func start() {
@@ -42,8 +45,6 @@ final class EmojiPickerScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case let .emojiSelected(emoji: emoji):
actionsSubject.send(.emojiSelected(emoji: emoji, itemID: self.parameters.itemID))
case .dismiss:
actionsSubject.send(.dismiss)
}
@@ -52,6 +53,6 @@ final class EmojiPickerScreenCoordinator: CoordinatorProtocol {
}
func toPresentable() -> AnyView {
AnyView(EmojiPickerScreen(context: viewModel.context, selectedEmojis: parameters.selectedEmojis))
AnyView(EmojiPickerScreen(context: viewModel.context))
}
}

View File

@@ -8,12 +8,12 @@
import Foundation
enum EmojiPickerScreenViewModelAction {
case emojiSelected(emoji: String)
case dismiss
}
struct EmojiPickerScreenViewState: BindableState {
var categories: [EmojiPickerEmojiCategoryViewData]
var selectedEmojis: Set<String>
}
enum EmojiPickerScreenViewAction {

View File

@@ -11,17 +11,20 @@ import SwiftUI
typealias EmojiPickerScreenViewModelType = StateStoreViewModelV2<EmojiPickerScreenViewState, EmojiPickerScreenViewAction>
class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScreenViewModelProtocol {
private var actionsSubject: PassthroughSubject<EmojiPickerScreenViewModelAction, Never> = .init()
private let itemID: TimelineItemIdentifier
private let emojiProvider: EmojiProviderProtocol
private let timelineController: TimelineControllerProtocol
private var actionsSubject: PassthroughSubject<EmojiPickerScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<EmojiPickerScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private let emojiProvider: EmojiProviderProtocol
init(emojiProvider: EmojiProviderProtocol) {
let initialViewState = EmojiPickerScreenViewState(categories: [])
init(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>, emojiProvider: EmojiProviderProtocol, timelineController: TimelineControllerProtocol) {
let initialViewState = EmojiPickerScreenViewState(categories: [], selectedEmojis: selectedEmojis)
self.itemID = itemID
self.emojiProvider = emojiProvider
self.timelineController = timelineController
super.init(initialViewState: initialViewState)
loadEmojis()
}
@@ -36,8 +39,7 @@ class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScr
state.categories = convert(emojiCategories: categories)
}
case let .emojiTapped(emoji: emoji):
emojiProvider.markEmojiAsFrequentlyUsed(emoji.value)
actionsSubject.send(.emojiSelected(emoji: emoji.value))
Task { await selectEmoji(emoji) }
case .dismiss:
actionsSubject.send(.dismiss)
}
@@ -62,4 +64,17 @@ class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScr
return EmojiPickerEmojiCategoryViewData(id: emojiCategory.id, emojis: emojisViewData)
}
}
private func selectEmoji(_ emoji: EmojiPickerEmojiViewData) async {
MXLog.debug("Selected \(emoji) for \(itemID)")
emojiProvider.markEmojiAsFrequentlyUsed(emoji.value)
guard case let .event(_, eventOrTransactionID) = itemID else { fatalError("Attempted to react to a virtual item.") }
// 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)
}
}

View File

@@ -11,7 +11,6 @@ import SwiftUI
struct EmojiPickerScreen: View {
let context: EmojiPickerScreenViewModel.Context
var selectedEmojis = Set<String>()
@State var searchString = ""
@State private var isSearching = false
@@ -64,7 +63,7 @@ struct EmojiPickerScreen: View {
}
private func accessibilityLabel(for emoji: String) -> String {
if selectedEmojis.contains(emoji) {
if context.viewState.selectedEmojis.contains(emoji) {
return L10n.a11yRemoveReaction(emoji)
} else {
return L10n.a11yAddReaction(emoji)
@@ -72,7 +71,7 @@ struct EmojiPickerScreen: View {
}
private func emojiBackgroundColor(for emoji: String) -> Color {
if selectedEmojis.contains(emoji) {
if context.viewState.selectedEmojis.contains(emoji) {
return .compound.bgActionPrimaryRest
} else {
return .clear
@@ -101,22 +100,28 @@ struct EmojiPickerScreen: View {
// MARK: - Previews
struct EmojiPickerScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = EmojiPickerScreenViewModel(emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
static let viewModel = EmojiPickerScreenViewModel(itemID: .randomEvent,
selectedEmojis: ["😀", "😄"],
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineController: MockTimelineController())
static var previews: some View {
EmojiPickerScreen(context: viewModel.context, selectedEmojis: ["😀", "😄"])
EmojiPickerScreen(context: viewModel.context)
.previewDisplayName("Screen")
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.categories).map { !$0.isEmpty }.eraseToStream())
}
}
struct EmojiPickerScreenSheet_Previews: PreviewProvider {
static let viewModel = EmojiPickerScreenViewModel(emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
static let viewModel = EmojiPickerScreenViewModel(itemID: .randomEvent,
selectedEmojis: ["😀", "😄"],
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineController: MockTimelineController())
static var previews: some View {
Text("Timeline view")
.sheet(isPresented: .constant(true)) {
EmojiPickerScreen(context: viewModel.context, selectedEmojis: ["😀", "😄"])
EmojiPickerScreen(context: viewModel.context)
}
.previewDisplayName("Sheet")
}

View File

@@ -16,7 +16,6 @@ enum LocationSharingViewError: Error, Hashable {
enum StaticLocationScreenViewModelAction {
case close
case openSystemSettings
case sendLocation(GeoURI, isUserLocation: Bool)
}
enum StaticLocationInteractionMode: Hashable {

View File

@@ -11,12 +11,14 @@ import SwiftUI
struct StaticLocationScreenCoordinatorParameters {
let interactionMode: StaticLocationInteractionMode
let mapURLBuilder: MapTilerURLBuilderProtocol
let timelineController: TimelineControllerProtocol
let appMediator: AppMediatorProtocol
let analytics: AnalyticsService
let userIndicatorController: UserIndicatorControllerProtocol
}
enum StaticLocationScreenCoordinatorAction {
case close
case selectedLocation(GeoURI, isUserLocation: Bool)
}
final class StaticLocationScreenCoordinator: CoordinatorProtocol {
@@ -33,7 +35,11 @@ final class StaticLocationScreenCoordinator: CoordinatorProtocol {
init(parameters: StaticLocationScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = StaticLocationScreenViewModel(interactionMode: parameters.interactionMode, mapURLBuilder: parameters.mapURLBuilder)
viewModel = StaticLocationScreenViewModel(interactionMode: parameters.interactionMode,
mapURLBuilder: parameters.mapURLBuilder,
timelineController: parameters.timelineController,
analytics: parameters.analytics,
userIndicatorController: parameters.userIndicatorController)
}
// MARK: - Public
@@ -46,8 +52,6 @@ final class StaticLocationScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.close)
case .openSystemSettings:
parameters.appMediator.openAppSettings()
case .sendLocation(let geoURI, let isUserLocation):
actionsSubject.send(.selectedLocation(geoURI, isUserLocation: isUserLocation))
}
}
.store(in: &cancellables)

View File

@@ -11,13 +11,24 @@ import Foundation
typealias StaticLocationScreenViewModelType = StateStoreViewModelV2<StaticLocationScreenViewState, StaticLocationScreenViewAction>
class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLocationScreenViewModelProtocol {
private let actionsSubject: PassthroughSubject<StaticLocationScreenViewModelAction, Never> = .init()
private let timelineController: TimelineControllerProtocol
private let analytics: AnalyticsService
private let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<StaticLocationScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<StaticLocationScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(interactionMode: StaticLocationInteractionMode, mapURLBuilder: MapTilerURLBuilderProtocol) {
init(interactionMode: StaticLocationInteractionMode,
mapURLBuilder: MapTilerURLBuilderProtocol,
timelineController: TimelineControllerProtocol,
analytics: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol) {
self.timelineController = timelineController
self.analytics = analytics
self.userIndicatorController = userIndicatorController
super.init(initialViewState: .init(interactionMode: interactionMode, mapURLBuilder: mapURLBuilder))
}
@@ -28,7 +39,7 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo
case .selectLocation:
guard let coordinate = state.bindings.mapCenterLocation else { return }
let uncertainty = state.isSharingUserLocation ? context.geolocationUncertainty : nil
actionsSubject.send(.sendLocation(.init(coordinate: coordinate, uncertainty: uncertainty), isUserLocation: state.isSharingUserLocation))
Task { await sendLocation(.init(coordinate: coordinate, uncertainty: uncertainty), isUserLocation: state.isSharingUserLocation) }
case .userDidPan:
state.bindings.showsUserLocationMode = .show
case .centerToUser:
@@ -43,4 +54,34 @@ class StaticLocationScreenViewModel: StaticLocationScreenViewModelType, StaticLo
}
}
}
// MARK: - Private
private func sendLocation(_ geoURI: GeoURI, isUserLocation: Bool) async {
guard case .success = await timelineController.sendLocation(body: geoURI.bodyMessage,
geoURI: geoURI,
description: nil,
zoomLevel: 15,
assetType: isUserLocation ? .sender : .pin) else {
showErrorIndicator()
return
}
actionsSubject.send(.close)
analytics.trackComposer(inThread: false,
isEditing: false,
isReply: false,
messageType: isUserLocation ? .LocationUser : .LocationPin,
startsThread: nil)
}
private func showErrorIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID,
type: .toast,
title: L10n.errorUnknown,
iconName: "xmark"))
}
private var statusIndicatorID: String { "\(Self.self)-Status" }
}

View File

@@ -148,13 +148,22 @@ struct StaticLocationScreen: View {
struct StaticLocationScreenViewer_Previews: PreviewProvider, TestablePreview {
static let viewModel = StaticLocationScreenViewModel(interactionMode: .viewOnly(geoURI: .init(latitude: 41.9027835,
longitude: 12.4963655)),
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration)
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
timelineController: MockTimelineController(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock())
static let pickerViewModel = StaticLocationScreenViewModel(interactionMode: .picker,
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration)
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
timelineController: MockTimelineController(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock())
static let descriptionViewModel = StaticLocationScreenViewModel(interactionMode: .viewOnly(geoURI: .init(latitude: 41.9027835,
longitude: 12.4963655),
description: "Cool position"),
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration)
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
timelineController: MockTimelineController(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock())
static var previews: some View {
NavigationStack {

View File

@@ -109,7 +109,11 @@ class MockTimelineController: TimelineControllerProtocol {
func processItemDisappearance(_ itemID: TimelineItemIdentifier) async { }
func toggleReaction(_ reaction: String, to eventID: TimelineItemIdentifier.EventOrTransactionID) async { }
func toggleReaction(_ reaction: String, to eventID: TimelineItemIdentifier.EventOrTransactionID) async {
if let timelineProxy {
_ = await timelineProxy.toggleReaction(reaction, to: eventID)
}
}
func edit(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID,
message: String,
@@ -125,6 +129,9 @@ class MockTimelineController: TimelineControllerProtocol {
private(set) var redactCalled = false
func redact(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID) async {
if let timelineProxy {
_ = await timelineProxy.redact(eventOrTransactionID, reason: nil)
}
redactCalled = true
}
@@ -248,19 +255,31 @@ class MockTimelineController: TimelineControllerProtocol {
// MARK: - Polls
func createPoll(question: String, answers: [String], pollKind: Poll.Kind) async -> Result<Void, TimelineControllerError> {
.success(())
if let timelineProxy {
_ = await timelineProxy.createPoll(question: question, answers: answers, pollKind: pollKind)
}
return .success(())
}
func editPoll(original eventID: String, question: String, answers: [String], pollKind: Poll.Kind) async -> Result<Void, TimelineControllerError> {
.success(())
if let timelineProxy {
_ = await timelineProxy.editPoll(original: eventID, question: question, answers: answers, pollKind: pollKind)
}
return .success(())
}
func sendPollResponse(pollStartID: String, answers: [String]) async -> Result<Void, TimelineControllerError> {
.success(())
if let timelineProxy {
_ = await timelineProxy.sendPollResponse(pollStartID: pollStartID, answers: answers)
}
return .success(())
}
func endPoll(pollStartID: String, text: String) async -> Result<Void, TimelineControllerError> {
.success(())
if let timelineProxy {
_ = await timelineProxy.endPoll(pollStartID: pollStartID, text: text)
}
return .success(())
}
// MARK: - UI Test signalling

View File

@@ -685,7 +685,11 @@ class MockScreen: Identifiable {
return navigationStackCoordinator
case .createPoll:
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = PollFormScreenCoordinator(parameters: .init(mode: .new, maxNumberOfOptions: 10))
let coordinator = PollFormScreenCoordinator(parameters: .init(mode: .new,
maxNumberOfOptions: 10,
timelineController: MockTimelineController(),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .encryptionSettings, .encryptionSettingsOutOfSync:

View File

@@ -0,0 +1,45 @@
//
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import XCTest
@testable import ElementX
@MainActor
final class EmojiPickerScreenViewModelTests: XCTestCase {
var timelineProxy: TimelineProxyMock!
var viewModel: EmojiPickerScreenViewModel!
var context: EmojiPickerScreenViewModel.Context { viewModel.context }
func testToggleReaction() async throws {
setupViewModel()
let reaction = "👋"
let expectation = XCTestExpectation(description: "Toggle reaction")
let deferred = deferFulfillment(viewModel.actions) { $0 == .dismiss }
timelineProxy.toggleReactionToClosure = { toggledReaction, _ in
XCTAssertEqual(toggledReaction, reaction)
expectation.fulfill()
return .success(())
}
context.send(viewAction: .emojiTapped(emoji: .init(id: "wave", value: reaction)))
await fulfillment(of: [expectation], timeout: 1)
try await deferred.fulfill()
}
// MARK: - Helpers
private func setupViewModel(selectedEmojis: Set<String> = []) {
timelineProxy = TimelineProxyMock(.init())
viewModel = EmojiPickerScreenViewModel(itemID: .randomEvent,
selectedEmojis: selectedEmojis,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineController: MockTimelineController(timelineProxy: timelineProxy))
}
}

View File

@@ -11,17 +11,14 @@ import XCTest
@MainActor
class PollFormScreenViewModelTests: XCTestCase {
let timelineProxy = TimelineProxyMock(.init())
var viewModel: PollFormScreenViewModelProtocol!
var context: PollFormScreenViewModelType.Context {
viewModel.context
}
override func setUpWithError() throws {
viewModel = PollFormScreenViewModel(mode: .new)
}
var context: PollFormScreenViewModelType.Context { viewModel.context }
func testNewPollInitialState() async throws {
setupViewModel()
XCTAssertEqual(context.options.count, 2)
XCTAssertTrue(context.options.allSatisfy(\.text.isEmpty))
XCTAssertTrue(context.question.isEmpty)
@@ -33,11 +30,12 @@ class PollFormScreenViewModelTests: XCTestCase {
context.send(viewAction: .cancel)
let action = try await deferred.fulfill()
XCTAssertNil(context.alertInfo)
XCTAssertEqual(action, .cancel)
XCTAssertEqual(action, .close)
}
func testEditPollInitialState() async throws {
setupViewModel(mode: .edit(eventID: "foo", poll: .emptyDisclosed))
XCTAssertEqual(context.options.count, 3)
XCTAssertTrue(context.options.allSatisfy { !$0.text.isEmpty })
XCTAssertFalse(context.question.isEmpty)
@@ -49,10 +47,12 @@ class PollFormScreenViewModelTests: XCTestCase {
context.send(viewAction: .cancel)
let action = try await deferred.fulfill()
XCTAssertNil(context.alertInfo)
XCTAssertEqual(action, .cancel)
XCTAssertEqual(action, .close)
}
func testNewPollInvalidEmptyOption() {
setupViewModel()
context.question = "foo"
context.options[0].text = "bla"
context.options[1].text = "bla"
@@ -62,6 +62,7 @@ class PollFormScreenViewModelTests: XCTestCase {
func testEditPollInvalidEmptyOption() {
setupViewModel(mode: .edit(eventID: "foo", poll: .emptyDisclosed))
context.send(viewAction: .addOption)
XCTAssertTrue(context.viewState.isSubmitButtonDisabled)
@@ -72,6 +73,7 @@ class PollFormScreenViewModelTests: XCTestCase {
func testEditPollSubmitButtonState() {
setupViewModel(mode: .edit(eventID: "foo", poll: .emptyDisclosed))
XCTAssertTrue(context.viewState.isSubmitButtonDisabled)
context.options[0].text = "foo"
XCTAssertFalse(context.viewState.isSubmitButtonDisabled)
@@ -82,68 +84,89 @@ class PollFormScreenViewModelTests: XCTestCase {
}
func testNewPollSubmit() async throws {
setupViewModel()
context.question = "foo"
context.options[0].text = "bla1"
context.options[1].text = "bla2"
XCTAssertFalse(context.viewState.isSubmitButtonDisabled)
let deferred = deferFulfillment(viewModel.actions) { action in
switch action {
case .submit:
return true
default:
return false
}
let deferred = deferFulfillment(viewModel.actions) { $0 == .close }
let expectation = XCTestExpectation(description: "Create poll")
timelineProxy.createPollQuestionAnswersPollKindClosure = { question, options, kind in
XCTAssertEqual(question, "foo")
XCTAssertEqual(options.count, 2)
XCTAssertEqual(options[0], "bla1")
XCTAssertEqual(options[1], "bla2")
XCTAssertEqual(kind, .disclosed)
expectation.fulfill()
return .success(())
}
context.send(viewAction: .submit)
let action = try await deferred.fulfill()
guard case .submit(let question, let options, let kind) = action else {
XCTFail("Unexpected action")
return
}
XCTAssertEqual(question, "foo")
XCTAssertEqual(options.count, 2)
XCTAssertEqual(options[0], "bla1")
XCTAssertEqual(options[1], "bla2")
XCTAssertEqual(kind, .disclosed)
await fulfillment(of: [expectation], timeout: 1)
try await deferred.fulfill()
}
func testEditPollSubmit() async throws {
setupViewModel(mode: .edit(eventID: "foo", poll: .emptyDisclosed))
context.question = "What is your favorite country?"
context.options.append(.init(text: "France 🇫🇷"))
XCTAssertFalse(context.viewState.isSubmitButtonDisabled)
let deferred = deferFulfillment(viewModel.actions) { action in
switch action {
case .submit:
return true
default:
return false
}
let deferred = deferFulfillment(viewModel.actions) { $0 == .close }
let expectation = XCTestExpectation(description: "Edit poll")
timelineProxy.editPollOriginalQuestionAnswersPollKindClosure = { eventID, question, options, kind in
XCTAssertEqual(eventID, "foo")
XCTAssertEqual(question, "What is your favorite country?")
XCTAssertEqual(options.count, 4)
XCTAssertEqual(options[0], "Italy 🇮🇹")
XCTAssertEqual(options[1], "China 🇨🇳")
XCTAssertEqual(options[2], "USA 🇺🇸")
XCTAssertEqual(options[3], "France 🇫🇷")
XCTAssertEqual(kind, .disclosed)
expectation.fulfill()
return .success(())
}
context.send(viewAction: .submit)
let action = try await deferred.fulfill()
guard case .submit(let question, let options, let kind) = action else {
XCTFail("Unexpected action")
return
}
XCTAssertEqual(question, "What is your favorite country?")
XCTAssertEqual(options.count, 4)
XCTAssertEqual(options[0], "Italy 🇮🇹")
XCTAssertEqual(options[1], "China 🇨🇳")
XCTAssertEqual(options[2], "USA 🇺🇸")
XCTAssertEqual(options[3], "France 🇫🇷")
XCTAssertEqual(kind, .disclosed)
await fulfillment(of: [expectation], timeout: 1)
try await deferred.fulfill()
}
private func setupViewModel(mode: PollFormMode) {
viewModel = PollFormScreenViewModel(mode: mode)
func testDeletePoll() async throws {
setupViewModel(mode: .edit(eventID: "foo", poll: .emptyDisclosed))
context.question = "What is your favorite country?"
context.options.append(.init(text: "France 🇫🇷"))
XCTAssertFalse(context.viewState.isSubmitButtonDisabled)
let deferredFailure = deferFailure(viewModel.actions, timeout: 1, message: "The alert should be shown.") { $0 == .close }
context.send(viewAction: .delete)
try await deferredFailure.fulfill()
XCTAssertNotNil(context.alertInfo, "An alert should be shown before deleting the poll.")
let deferred = deferFulfillment(viewModel.actions) { $0 == .close }
let expectation = XCTestExpectation(description: "Delete poll")
timelineProxy.redactReasonClosure = { eventID, _ in
XCTAssertEqual(eventID, .eventID("foo"))
expectation.fulfill()
return .success(())
}
context.alertInfo?.secondaryButton?.action?()
await fulfillment(of: [expectation], timeout: 1)
try await deferred.fulfill()
}
// MARK: - Helpers
private func setupViewModel(mode: PollFormMode = .new) {
viewModel = PollFormScreenViewModel(mode: mode,
timelineController: MockTimelineController(timelineProxy: timelineProxy),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock())
}
}

View File

@@ -12,18 +12,20 @@ import XCTest
@MainActor
class StaticLocationScreenViewModelTests: XCTestCase {
let timelineProxy = TimelineProxyMock(.init())
var viewModel: StaticLocationScreenViewModelProtocol!
var context: StaticLocationScreenViewModel.Context { viewModel.context }
private var cancellables = Set<AnyCancellable>()
var context: StaticLocationScreenViewModel.Context {
viewModel.context
}
override func setUpWithError() throws {
cancellables.removeAll()
let viewModel = StaticLocationScreenViewModel(interactionMode: .picker,
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration)
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
timelineController: MockTimelineController(timelineProxy: timelineProxy),
analytics: ServiceLocator.shared.analytics,
userIndicatorController: UserIndicatorControllerMock())
viewModel.state.bindings.isLocationAuthorized = true
self.viewModel = viewModel
}
@@ -73,22 +75,18 @@ class StaticLocationScreenViewModelTests: XCTestCase {
context.mapCenterLocation = .init(latitude: 0, longitude: 0)
context.geolocationUncertainty = 10
let deferred = deferFulfillment(viewModel.actions) { action in
switch action {
case .sendLocation:
return true
default:
return false
}
let deferred = deferFulfillment(viewModel.actions) { $0 == .close }
let expectation = XCTestExpectation(description: "sendLocation")
timelineProxy.sendLocationBodyGeoURIDescriptionZoomLevelAssetTypeClosure = { _, geoURI, _, _, assetType in
XCTAssertEqual(geoURI.uncertainty, 10)
XCTAssertEqual(assetType, .sender)
expectation.fulfill()
return .success(())
}
context.send(viewAction: .selectLocation)
guard case .sendLocation(let geoUri, let isUserLocation) = try await deferred.fulfill() else {
XCTFail("Sent action should be 'sendLocation'")
return
}
XCTAssertEqual(geoUri.uncertainty, 10)
XCTAssertTrue(isUserLocation)
await fulfillment(of: [expectation], timeout: 1)
try await deferred.fulfill()
}
func testSendPickedLocation() async throws {
@@ -96,21 +94,17 @@ class StaticLocationScreenViewModelTests: XCTestCase {
context.isLocationAuthorized = nil
context.geolocationUncertainty = 10
let deferred = deferFulfillment(viewModel.actions) { action in
switch action {
case .sendLocation:
return true
default:
return false
}
let deferred = deferFulfillment(viewModel.actions) { $0 == .close }
let expectation = XCTestExpectation(description: "sendLocation")
timelineProxy.sendLocationBodyGeoURIDescriptionZoomLevelAssetTypeClosure = { _, geoURI, _, _, assetType in
XCTAssertEqual(geoURI.uncertainty, nil)
XCTAssertEqual(assetType, .pin)
expectation.fulfill()
return .success(())
}
context.send(viewAction: .selectLocation)
guard case .sendLocation(let geoUri, let isUserLocation) = try await deferred.fulfill() else {
XCTFail("Sent action should be 'sendLocation'")
return
}
XCTAssertEqual(geoUri.uncertainty, nil)
XCTAssertFalse(isUserLocation)
await fulfillment(of: [expectation], timeout: 1)
try await deferred.fulfill()
}
}