From c081e538b4b5dd14cb10a5c7f92ee53f78513e5d Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Thu, 21 Nov 2024 14:48:38 +0000 Subject: [PATCH] Add support for sharing URLs and text. (#3546) * Add support for sharing URLs and text. * Fix a bug where a stored draft would overwrite the shared text. * Add tests. --- .../RoomFlowCoordinator.swift | 91 +++++++++++-------- .../UserSessionFlowCoordinator.swift | 19 ++-- .../Sources/Mocks/JoinedRoomProxyMock.swift | 1 + .../Other/Extensions/NSItemProvider.swift | 15 ++- .../ComposerToolbarViewModel.swift | 15 ++- .../ComposerToolbarViewModelProtocol.swift | 2 +- .../RoomScreen/RoomScreenCoordinator.swift | 12 ++- .../Screens/Timeline/TimelineModels.swift | 1 + .../ShareExtension/ShareExtensionModels.swift | 9 ++ .../ShareExtensionViewController.swift | 16 ++-- ShareExtension/SupportingFiles/Info.plist | 4 + .../ComposerToolbarViewModelTests.swift | 63 +++++++++---- .../Sources/RoomFlowCoordinatorTests.swift | 27 +++++- .../UserSessionFlowCoordinatorTests.swift | 27 +++++- 14 files changed, 219 insertions(+), 83 deletions(-) diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 459aebc86..8aaf6fc27 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -62,9 +62,18 @@ private enum PresentationAction: Hashable { var focusedEvent: FocusEvent? { switch self { case .eventFocus(let focusEvent): - return focusEvent + focusEvent default: - return nil + nil + } + } + + var sharedText: String? { + switch self { + case .share(.text(_, let text)): + text + default: + nil } } } @@ -196,11 +205,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { roomScreenCoordinator?.focusOnEvent(.init(eventID: eventID, shouldSetPin: false)) } case .share(let payload): - guard case let .mediaFile(roomID, _) = payload else { - return - } - - guard let roomID, roomID == self.roomID else { + guard let roomID = payload.roomID, roomID == self.roomID else { fatalError("Navigation route doesn't belong to this room flow.") } @@ -615,8 +620,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .eventFocus(let focusedEvent): roomScreenCoordinator?.focusOnEvent(focusedEvent) case .share(.mediaFile(_, let mediaFile)): - stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url)) - default: + stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url), userInfo: EventUserInfo(animated: animated)) + case .share(.text(_, let text)): + roomScreenCoordinator?.shareText(text) + case .none: break } @@ -624,32 +631,57 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } - Task { - // Flag the room as read on entering, the timeline will take care of the read receipts - await roomProxy.flagAsUnread(false) + // Flag the room as read on entering, the timeline will take care of the read receipts + Task { await roomProxy.flagAsUnread(false) } + + analytics.trackViewRoom(isDM: roomProxy.infoPublisher.value.isDirect, isSpace: roomProxy.infoPublisher.value.isSpace) + + let coordinator = makeRoomScreenCoordinator(presentationAction: presentationAction) + roomScreenCoordinator = coordinator + + if !isChildFlow { + let animated = UIDevice.current.userInterfaceIdiom == .phone ? animated : false + navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in + self?.stateMachine.tryEvent(.dismissFlow) + } + } else { + if joinRoomScreenCoordinator != nil { + navigationStackCoordinator.pop() + } + + navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in + self?.stateMachine.tryEvent(.dismissFlow) + } } - + + switch presentationAction { + case .share(.mediaFile(_, let mediaFile)): + stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url), userInfo: EventUserInfo(animated: animated)) + case .share(.text), .eventFocus: + break // These are both handled in the coordinator's init. + case .none: + break + } + } + + private func makeRoomScreenCoordinator(presentationAction: PresentationAction?) -> RoomScreenCoordinator { let userID = userSession.clientProxy.userID - let timelineItemFactory = RoomTimelineItemFactory(userID: userID, attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID)) - let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy, initialFocussedEventID: presentationAction?.focusedEvent?.eventID, timelineItemFactory: timelineItemFactory, mediaProvider: userSession.mediaProvider) self.timelineController = timelineController - analytics.trackViewRoom(isDM: roomProxy.infoPublisher.value.isDirect, isSpace: roomProxy.infoPublisher.value.isSpace) - let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy) - let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory) let parameters = RoomScreenCoordinatorParameters(clientProxy: userSession.clientProxy, roomProxy: roomProxy, focussedEvent: presentationAction?.focusedEvent, + sharedText: presentationAction?.sharedText, timelineController: timelineController, mediaProvider: userSession.mediaProvider, mediaPlayerProvider: MediaPlayerProvider(), @@ -697,28 +729,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } .store(in: &cancellables) - roomScreenCoordinator = coordinator - if !isChildFlow { - let animated = UIDevice.current.userInterfaceIdiom == .phone ? animated : false - navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in - self?.stateMachine.tryEvent(.dismissFlow) - } - } else { - if joinRoomScreenCoordinator != nil { - navigationStackCoordinator.pop() - } - - navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in - self?.stateMachine.tryEvent(.dismissFlow) - } - } - - switch presentationAction { - case .share(.mediaFile(_, let mediaFile)): - stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url), userInfo: EventUserInfo(animated: animated)) - default: - break - } + return coordinator } private func presentJoinRoomScreen(via: [String], animated: Bool) { diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index d6ff18538..0c7af9484 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -207,16 +207,13 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { case .settings, .chatBackupSettings: settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated) case .share(let payload): - switch payload { - case .mediaFile(let roomID, _): - if let roomID { - stateMachine.processEvent(.selectRoom(roomID: roomID, - via: [], - entryPoint: .share(payload)), - userInfo: .init(animated: animated)) - } else { - stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated)) - } + if let roomID = payload.roomID { + stateMachine.processEvent(.selectRoom(roomID: roomID, + via: [], + entryPoint: .share(payload)), + userInfo: .init(animated: animated)) + } else { + stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated)) } } } @@ -938,6 +935,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { let sharePayload = switch sharePayload { case .mediaFile(_, let mediaFile): ShareExtensionPayload.mediaFile(roomID: roomID, mediaFile: mediaFile) + case .text(_, let text): + ShareExtensionPayload.text(roomID: roomID, text: text) } navigationSplitCoordinator.setSheetCoordinator(nil) diff --git a/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift b/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift index ff385d912..0cd26ef42 100644 --- a/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift +++ b/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift @@ -123,6 +123,7 @@ extension JoinedRoomProxyMock { matrixToEventPermalinkReturnValue = .success(.homeDirectory) loadDraftReturnValue = .success(nil) clearDraftReturnValue = .success(()) + sendTypingNotificationIsTypingReturnValue = .success(()) } } diff --git a/ElementX/Sources/Other/Extensions/NSItemProvider.swift b/ElementX/Sources/Other/Extensions/NSItemProvider.swift index f567f7c28..67aedca11 100644 --- a/ElementX/Sources/Other/Extensions/NSItemProvider.swift +++ b/ElementX/Sources/Other/Extensions/NSItemProvider.swift @@ -6,7 +6,7 @@ // import Foundation -import UIKit +import SwiftUI import UniformTypeIdentifiers extension NSItemProvider { @@ -15,6 +15,19 @@ extension NSItemProvider { let fileExtension: String } + func loadTransferable(type transferableType: T.Type) async -> T? { + try? await withCheckedContinuation { continuation in + _ = loadTransferable(type: T.self) { result in + continuation.resume(returning: result) + } + } + .get() + } + + func loadString() async -> String? { + try? await loadItem(forTypeIdentifier: UTType.text.identifier) as? String + } + func storeData() async -> URL? { guard let contentType = preferredContentType else { MXLog.error("Invalid NSItemProvider: \(self)") diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift index 36471da72..06623b8c7 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModel.swift @@ -15,6 +15,7 @@ import WysiwygComposer typealias ComposerToolbarViewModelType = StateStoreViewModel final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerToolbarViewModelProtocol { + private var initialText: String? private let wysiwygViewModel: WysiwygComposerViewModel private let completionSuggestionService: CompletionSuggestionServiceProtocol private let analyticsService: AnalyticsService @@ -41,12 +42,14 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool private var replyLoadingTask: Task? - init(wysiwygViewModel: WysiwygComposerViewModel, + init(initialText: String? = nil, + wysiwygViewModel: WysiwygComposerViewModel, completionSuggestionService: CompletionSuggestionServiceProtocol, mediaProvider: MediaProviderProtocol, mentionDisplayHelper: MentionDisplayHelper, analyticsService: AnalyticsService, composerDraftService: ComposerDraftServiceProtocol) { + self.initialText = initialText self.wysiwygViewModel = wysiwygViewModel self.completionSuggestionService = completionSuggestionService self.analyticsService = analyticsService @@ -206,6 +209,8 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } else { set(text: plainText) } + case .setFocus: + state.bindings.composerFocused = true case .removeFocus: state.bindings.composerFocused = false case .clear: @@ -219,8 +224,12 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } } - func loadDraft() { - Task { + func loadDraft() async { + if let initialText { + set(text: initialText) + set(mode: .default) + state.bindings.composerFocused = true + } else { guard case let .success(draft) = await draftService.loadDraft(), let draft else { return diff --git a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModelProtocol.swift b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModelProtocol.swift index 88db5d5cb..f7d19700b 100644 --- a/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModelProtocol.swift +++ b/ElementX/Sources/Screens/RoomScreen/ComposerToolbar/ComposerToolbarViewModelProtocol.swift @@ -15,6 +15,6 @@ protocol ComposerToolbarViewModelProtocol { var keyCommands: [WysiwygKeyCommand] { get } func process(timelineAction: TimelineComposerAction) - func loadDraft() + func loadDraft() async func saveDraft() } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 500461ea0..b920ef687 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -15,6 +15,7 @@ struct RoomScreenCoordinatorParameters { let clientProxy: ClientProxyProtocol let roomProxy: JoinedRoomProxyProtocol var focussedEvent: FocusEvent? + var sharedText: String? let timelineController: RoomTimelineControllerProtocol let mediaProvider: MediaProviderProtocol let mediaPlayerProvider: MediaPlayerProviderProtocol @@ -88,7 +89,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { maxCompressedHeight: ComposerConstant.maxHeight, maxExpandedHeight: ComposerConstant.maxHeight, parserStyle: .elementX) - let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, + let composerViewModel = ComposerToolbarViewModel(initialText: parameters.sharedText, + wysiwygViewModel: wysiwygViewModel, completionSuggestionService: parameters.completionSuggestionService, mediaProvider: parameters.mediaProvider, mentionDisplayHelper: ComposerMentionDisplayHelper(timelineContext: timelineViewModel.context), @@ -172,7 +174,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol { .store(in: &cancellables) // Loading the draft requires the subscriptions to be set up first otherwise the room won't be be able to propagate the information to the composer. - composerViewModel.loadDraft() + Task { await composerViewModel.loadDraft() } } func focusOnEvent(_ focussedEvent: FocusEvent) { @@ -183,6 +185,12 @@ final class RoomScreenCoordinator: CoordinatorProtocol { Task { await timelineViewModel.focusOnEvent(eventID: eventID) } } + func shareText(_ string: String) { + composerViewModel.process(timelineAction: .setMode(mode: .default)) // Make sure we're not e.g. replying. + composerViewModel.process(timelineAction: .setText(plainText: string, htmlText: nil)) + composerViewModel.process(timelineAction: .setFocus) + } + func stop() { composerViewModel.saveDraft() timelineViewModel.stop() diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 0d407cf93..0f0d65d10 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -79,6 +79,7 @@ enum TimelineViewAction { enum TimelineComposerAction { case setMode(mode: ComposerMode) case setText(plainText: String, htmlText: String?) + case setFocus case removeFocus case clear } diff --git a/ElementX/Sources/ShareExtension/ShareExtensionModels.swift b/ElementX/Sources/ShareExtension/ShareExtensionModels.swift index 3324554c6..a5147b2c9 100644 --- a/ElementX/Sources/ShareExtension/ShareExtensionModels.swift +++ b/ElementX/Sources/ShareExtension/ShareExtensionModels.swift @@ -13,6 +13,15 @@ enum ShareExtensionConstants { enum ShareExtensionPayload: Hashable, Codable { case mediaFile(roomID: String?, mediaFile: ShareExtensionMediaFile) + case text(roomID: String?, text: String) + + var roomID: String? { + switch self { + case .mediaFile(let roomID, _), + .text(let roomID, _): + roomID + } + } } struct ShareExtensionMediaFile: Hashable, Codable { diff --git a/ShareExtension/Sources/ShareExtensionViewController.swift b/ShareExtension/Sources/ShareExtensionViewController.swift index 5ecafc5e3..96bfe173a 100644 --- a/ShareExtension/Sources/ShareExtensionViewController.swift +++ b/ShareExtension/Sources/ShareExtensionViewController.swift @@ -42,14 +42,18 @@ class ShareExtensionViewController: UIViewController { return nil } - guard let fileURL = await itemProvider.storeData() else { - MXLog.error("Failed storing NSItemProvider data \(itemProvider)") + let roomID = (extensionContext?.intent as? INSendMessageIntent)?.conversationIdentifier + + if let fileURL = await itemProvider.storeData() { + return .mediaFile(roomID: roomID, mediaFile: .init(url: fileURL, suggestedName: fileURL.lastPathComponent)) + } else if let url = await itemProvider.loadTransferable(type: URL.self) { + return .text(roomID: roomID, text: url.absoluteString) + } else if let string = await itemProvider.loadString() { + return .text(roomID: roomID, text: string) + } else { + MXLog.error("Failed loading NSItemProvider data: \(itemProvider)") return nil } - - let roomID = (extensionContext?.intent as? INSendMessageIntent)?.conversationIdentifier - - return .mediaFile(roomID: roomID, mediaFile: .init(url: fileURL, suggestedName: fileURL.lastPathComponent)) } private func openMainApp(payload: ShareExtensionPayload) async { diff --git a/ShareExtension/SupportingFiles/Info.plist b/ShareExtension/SupportingFiles/Info.plist index 863d9f93c..d8ad392ed 100644 --- a/ShareExtension/SupportingFiles/Info.plist +++ b/ShareExtension/SupportingFiles/Info.plist @@ -36,6 +36,10 @@ 1 NSExtensionActivationSupportsMovieWithMaxCount 1 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 NSExtensionPointIdentifier diff --git a/UnitTests/Sources/ComposerToolbarViewModelTests.swift b/UnitTests/Sources/ComposerToolbarViewModelTests.swift index 9779ae8bf..1c9327fd9 100644 --- a/UnitTests/Sources/ComposerToolbarViewModelTests.swift +++ b/UnitTests/Sources/ComposerToolbarViewModelTests.swift @@ -23,17 +23,7 @@ class ComposerToolbarViewModelTests: XCTestCase { AppSettings.resetAllSettings() appSettings = AppSettings() ServiceLocator.shared.register(appSettings: appSettings) - wysiwygViewModel = WysiwygComposerViewModel() - completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init()) - draftServiceMock = ComposerDraftServiceMock() - viewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, - completionSuggestionService: completionSuggestionServiceMock, - mediaProvider: MediaProviderMock(configuration: .init()), - mentionDisplayHelper: ComposerMentionDisplayHelper.mock, - analyticsService: ServiceLocator.shared.analytics, - composerDraftService: draftServiceMock) - - viewModel.context.composerFormattingEnabled = true + setUpViewModel() } override func tearDown() { @@ -340,7 +330,7 @@ class ComposerToolbarViewModelTests: XCTestCase { return .success(nil) } - viewModel.loadDraft() + await viewModel.loadDraft() await fulfillment(of: [expectation], timeout: 10) XCTAssertFalse(viewModel.context.composerFormattingEnabled) XCTAssertTrue(viewModel.state.composerEmpty) @@ -356,7 +346,7 @@ class ComposerToolbarViewModelTests: XCTestCase { htmlText: nil, draftType: .newMessage)) } - viewModel.loadDraft() + await viewModel.loadDraft() await fulfillment(of: [expectation], timeout: 10) XCTAssertFalse(viewModel.context.composerFormattingEnabled) @@ -373,7 +363,7 @@ class ComposerToolbarViewModelTests: XCTestCase { htmlText: "Hello world!", draftType: .newMessage)) } - viewModel.loadDraft() + await viewModel.loadDraft() await fulfillment(of: [expectation], timeout: 10) XCTAssertTrue(viewModel.context.composerFormattingEnabled) @@ -391,7 +381,7 @@ class ComposerToolbarViewModelTests: XCTestCase { htmlText: nil, draftType: .edit(eventID: "testID"))) } - viewModel.loadDraft() + await viewModel.loadDraft() await fulfillment(of: [expectation], timeout: 10) XCTAssertFalse(viewModel.context.composerFormattingEnabled) @@ -424,7 +414,7 @@ class ComposerToolbarViewModelTests: XCTestCase { return .success(.init(details: loadedReply, isThreaded: true)) } - viewModel.loadDraft() + await viewModel.loadDraft() await fulfillment(of: [draftExpectation], timeout: 10) XCTAssertFalse(viewModel.context.composerFormattingEnabled) @@ -464,7 +454,7 @@ class ComposerToolbarViewModelTests: XCTestCase { return .success(.init(details: loadedReply, isThreaded: true)) } - viewModel.loadDraft() + await viewModel.loadDraft() await fulfillment(of: [draftExpectation], timeout: 10) XCTAssertFalse(viewModel.context.composerFormattingEnabled) @@ -622,6 +612,45 @@ class ComposerToolbarViewModelTests: XCTestCase { viewModel.process(viewAction: .sendMessage) try await deferred.fulfill() } + + func testRestoreDoesntOverwriteInitialText() async { + let sharedText = "Some shared text" + let expectation = expectation(description: "Wait for draft to be restored") + expectation.isInverted = true + setUpViewModel(initialText: sharedText) { + defer { expectation.fulfill() } + return .success(.init(plainText: "Hello world!", + htmlText: nil, + draftType: .newMessage)) + } + viewModel.context.composerFormattingEnabled = false + await viewModel.loadDraft() + + await fulfillment(of: [expectation], timeout: 1) + XCTAssertFalse(viewModel.context.composerFormattingEnabled) + XCTAssertEqual(viewModel.state.composerMode, .default) + XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: sharedText)) + } + + // MARK: - Helpers + + private func setUpViewModel(initialText: String? = nil, loadDraftClosure: (() async -> Result)? = nil) { + wysiwygViewModel = WysiwygComposerViewModel() + completionSuggestionServiceMock = CompletionSuggestionServiceMock(configuration: .init()) + draftServiceMock = ComposerDraftServiceMock() + if let loadDraftClosure { + draftServiceMock.loadDraftClosure = loadDraftClosure + } + + viewModel = ComposerToolbarViewModel(initialText: initialText, + wysiwygViewModel: wysiwygViewModel, + completionSuggestionService: completionSuggestionServiceMock, + mediaProvider: MediaProviderMock(configuration: .init()), + mentionDisplayHelper: ComposerMentionDisplayHelper.mock, + analyticsService: ServiceLocator.shared.analytics, + composerDraftService: draftServiceMock) + viewModel.context.composerFormattingEnabled = true + } } private extension MentionSuggestionItem { diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index 6e2534b88..4ec720826 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -218,7 +218,7 @@ class RoomFlowCoordinatorTests: XCTestCase { XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) } - func testShareRoute() async throws { + func testShareMediaRoute() async throws { await setupRoomFlowCoordinator() try await process(route: .room(roomID: "1", via: [])) @@ -243,6 +243,31 @@ class RoomFlowCoordinatorTests: XCTestCase { XCTAssertTrue((navigationStackCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) } + func testShareTextRoute() async throws { + await setupRoomFlowCoordinator() + + try await process(route: .room(roomID: "1", via: [])) + XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + + let sharePayload: ShareExtensionPayload = .text(roomID: "1", text: "Important text") + try await process(route: .share(sharePayload)) + + XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator) + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + + XCTAssertNil(navigationStackCoordinator.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.") + + try await process(route: .childRoom(roomID: "2", via: [])) + XCTAssertNil(navigationStackCoordinator.sheetCoordinator) + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1) + + try await process(route: .share(sharePayload)) + + XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0) + XCTAssertNil(navigationStackCoordinator.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.") + } + // MARK: - Private private func process(route: AppRoute) async throws { diff --git a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift index 30ed61078..be295696a 100644 --- a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift +++ b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift @@ -242,7 +242,7 @@ class UserSessionFlowCoordinatorTests: XCTestCase { "A new timeline should be created for the same room ID, so that the screen isn't stale while loading.") } - func testShareRouteWithoutRoom() async throws { + func testShareMediaRouteWithoutRoom() async throws { try await process(route: .settings, expectedState: .settingsScreen(selectedRoomID: nil)) XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator) @@ -253,7 +253,7 @@ class UserSessionFlowCoordinatorTests: XCTestCase { XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator) } - func testShareRouteWithRoom() async throws { + func testShareMediaRouteWithRoom() async throws { try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(selectedRoomID: "1")) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) @@ -265,6 +265,29 @@ class UserSessionFlowCoordinatorTests: XCTestCase { XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) } + func testShareTextRouteWithoutRoom() async throws { + try await process(route: .settings, expectedState: .settingsScreen(selectedRoomID: nil)) + XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator) + + let sharePayload: ShareExtensionPayload = .text(roomID: nil, text: "Important Text") + try await process(route: .share(sharePayload), + expectedState: .shareExtensionRoomList(sharePayload: sharePayload)) + + XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator) + } + + func testShareTextRouteWithRoom() async throws { + try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(selectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + + let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text") + try await process(route: .share(sharePayload), + expectedState: .roomList(selectedRoomID: "2")) + + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNil(splitCoordinator?.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.") + } + // MARK: - Private private func process(route: AppRoute, expectedState: UserSessionFlowCoordinatorStateMachine.State) async throws {