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.
This commit is contained in:
Doug 2024-11-21 14:48:38 +00:00 committed by GitHub
parent 3a9f54a9f6
commit c081e538b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 219 additions and 83 deletions

View File

@ -62,9 +62,18 @@ private enum PresentationAction: Hashable {
var focusedEvent: FocusEvent? { var focusedEvent: FocusEvent? {
switch self { switch self {
case .eventFocus(let focusEvent): case .eventFocus(let focusEvent):
return focusEvent focusEvent
default: 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)) roomScreenCoordinator?.focusOnEvent(.init(eventID: eventID, shouldSetPin: false))
} }
case .share(let payload): case .share(let payload):
guard case let .mediaFile(roomID, _) = payload else { guard let roomID = payload.roomID, roomID == self.roomID else {
return
}
guard let roomID, roomID == self.roomID else {
fatalError("Navigation route doesn't belong to this room flow.") fatalError("Navigation route doesn't belong to this room flow.")
} }
@ -615,8 +620,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .eventFocus(let focusedEvent): case .eventFocus(let focusedEvent):
roomScreenCoordinator?.focusOnEvent(focusedEvent) roomScreenCoordinator?.focusOnEvent(focusedEvent)
case .share(.mediaFile(_, let mediaFile)): case .share(.mediaFile(_, let mediaFile)):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url)) stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url), userInfo: EventUserInfo(animated: animated))
default: case .share(.text(_, let text)):
roomScreenCoordinator?.shareText(text)
case .none:
break 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 // Flag the room as read on entering, the timeline will take care of the read receipts
await roomProxy.flagAsUnread(false) 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()
} }
let userID = userSession.clientProxy.userID 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, let timelineItemFactory = RoomTimelineItemFactory(userID: userID,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID)) stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy, let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
initialFocussedEventID: presentationAction?.focusedEvent?.eventID, initialFocussedEventID: presentationAction?.focusedEvent?.eventID,
timelineItemFactory: timelineItemFactory, timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider) mediaProvider: userSession.mediaProvider)
self.timelineController = timelineController self.timelineController = timelineController
analytics.trackViewRoom(isDM: roomProxy.infoPublisher.value.isDirect, isSpace: roomProxy.infoPublisher.value.isSpace)
let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy) let completionSuggestionService = CompletionSuggestionService(roomProxy: roomProxy)
let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory) let composerDraftService = ComposerDraftService(roomProxy: roomProxy, timelineItemfactory: timelineItemFactory)
let parameters = RoomScreenCoordinatorParameters(clientProxy: userSession.clientProxy, let parameters = RoomScreenCoordinatorParameters(clientProxy: userSession.clientProxy,
roomProxy: roomProxy, roomProxy: roomProxy,
focussedEvent: presentationAction?.focusedEvent, focussedEvent: presentationAction?.focusedEvent,
sharedText: presentationAction?.sharedText,
timelineController: timelineController, timelineController: timelineController,
mediaProvider: userSession.mediaProvider, mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: MediaPlayerProvider(), mediaPlayerProvider: MediaPlayerProvider(),
@ -697,28 +729,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} }
.store(in: &cancellables) .store(in: &cancellables)
roomScreenCoordinator = coordinator return 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
}
} }
private func presentJoinRoomScreen(via: [String], animated: Bool) { private func presentJoinRoomScreen(via: [String], animated: Bool) {

View File

@ -207,9 +207,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
case .settings, .chatBackupSettings: case .settings, .chatBackupSettings:
settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated) settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
case .share(let payload): case .share(let payload):
switch payload { if let roomID = payload.roomID {
case .mediaFile(let roomID, _):
if let roomID {
stateMachine.processEvent(.selectRoom(roomID: roomID, stateMachine.processEvent(.selectRoom(roomID: roomID,
via: [], via: [],
entryPoint: .share(payload)), entryPoint: .share(payload)),
@ -219,7 +217,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
} }
} }
} }
}
func attemptStartingOnboarding() { func attemptStartingOnboarding() {
if onboardingFlowCoordinator.shouldStart { if onboardingFlowCoordinator.shouldStart {
@ -938,6 +935,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
let sharePayload = switch sharePayload { let sharePayload = switch sharePayload {
case .mediaFile(_, let mediaFile): case .mediaFile(_, let mediaFile):
ShareExtensionPayload.mediaFile(roomID: roomID, mediaFile: mediaFile) ShareExtensionPayload.mediaFile(roomID: roomID, mediaFile: mediaFile)
case .text(_, let text):
ShareExtensionPayload.text(roomID: roomID, text: text)
} }
navigationSplitCoordinator.setSheetCoordinator(nil) navigationSplitCoordinator.setSheetCoordinator(nil)

View File

@ -123,6 +123,7 @@ extension JoinedRoomProxyMock {
matrixToEventPermalinkReturnValue = .success(.homeDirectory) matrixToEventPermalinkReturnValue = .success(.homeDirectory)
loadDraftReturnValue = .success(nil) loadDraftReturnValue = .success(nil)
clearDraftReturnValue = .success(()) clearDraftReturnValue = .success(())
sendTypingNotificationIsTypingReturnValue = .success(())
} }
} }

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import UIKit import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
extension NSItemProvider { extension NSItemProvider {
@ -15,6 +15,19 @@ extension NSItemProvider {
let fileExtension: String let fileExtension: String
} }
func loadTransferable<T: Transferable>(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? { func storeData() async -> URL? {
guard let contentType = preferredContentType else { guard let contentType = preferredContentType else {
MXLog.error("Invalid NSItemProvider: \(self)") MXLog.error("Invalid NSItemProvider: \(self)")

View File

@ -15,6 +15,7 @@ import WysiwygComposer
typealias ComposerToolbarViewModelType = StateStoreViewModel<ComposerToolbarViewState, ComposerToolbarViewAction> typealias ComposerToolbarViewModelType = StateStoreViewModel<ComposerToolbarViewState, ComposerToolbarViewAction>
final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerToolbarViewModelProtocol { final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerToolbarViewModelProtocol {
private var initialText: String?
private let wysiwygViewModel: WysiwygComposerViewModel private let wysiwygViewModel: WysiwygComposerViewModel
private let completionSuggestionService: CompletionSuggestionServiceProtocol private let completionSuggestionService: CompletionSuggestionServiceProtocol
private let analyticsService: AnalyticsService private let analyticsService: AnalyticsService
@ -41,12 +42,14 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
private var replyLoadingTask: Task<Void, Never>? private var replyLoadingTask: Task<Void, Never>?
init(wysiwygViewModel: WysiwygComposerViewModel, init(initialText: String? = nil,
wysiwygViewModel: WysiwygComposerViewModel,
completionSuggestionService: CompletionSuggestionServiceProtocol, completionSuggestionService: CompletionSuggestionServiceProtocol,
mediaProvider: MediaProviderProtocol, mediaProvider: MediaProviderProtocol,
mentionDisplayHelper: MentionDisplayHelper, mentionDisplayHelper: MentionDisplayHelper,
analyticsService: AnalyticsService, analyticsService: AnalyticsService,
composerDraftService: ComposerDraftServiceProtocol) { composerDraftService: ComposerDraftServiceProtocol) {
self.initialText = initialText
self.wysiwygViewModel = wysiwygViewModel self.wysiwygViewModel = wysiwygViewModel
self.completionSuggestionService = completionSuggestionService self.completionSuggestionService = completionSuggestionService
self.analyticsService = analyticsService self.analyticsService = analyticsService
@ -206,6 +209,8 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
} else { } else {
set(text: plainText) set(text: plainText)
} }
case .setFocus:
state.bindings.composerFocused = true
case .removeFocus: case .removeFocus:
state.bindings.composerFocused = false state.bindings.composerFocused = false
case .clear: case .clear:
@ -219,8 +224,12 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
} }
} }
func loadDraft() { func loadDraft() async {
Task { if let initialText {
set(text: initialText)
set(mode: .default)
state.bindings.composerFocused = true
} else {
guard case let .success(draft) = await draftService.loadDraft(), guard case let .success(draft) = await draftService.loadDraft(),
let draft else { let draft else {
return return

View File

@ -15,6 +15,6 @@ protocol ComposerToolbarViewModelProtocol {
var keyCommands: [WysiwygKeyCommand] { get } var keyCommands: [WysiwygKeyCommand] { get }
func process(timelineAction: TimelineComposerAction) func process(timelineAction: TimelineComposerAction)
func loadDraft() func loadDraft() async
func saveDraft() func saveDraft()
} }

View File

@ -15,6 +15,7 @@ struct RoomScreenCoordinatorParameters {
let clientProxy: ClientProxyProtocol let clientProxy: ClientProxyProtocol
let roomProxy: JoinedRoomProxyProtocol let roomProxy: JoinedRoomProxyProtocol
var focussedEvent: FocusEvent? var focussedEvent: FocusEvent?
var sharedText: String?
let timelineController: RoomTimelineControllerProtocol let timelineController: RoomTimelineControllerProtocol
let mediaProvider: MediaProviderProtocol let mediaProvider: MediaProviderProtocol
let mediaPlayerProvider: MediaPlayerProviderProtocol let mediaPlayerProvider: MediaPlayerProviderProtocol
@ -88,7 +89,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
maxCompressedHeight: ComposerConstant.maxHeight, maxCompressedHeight: ComposerConstant.maxHeight,
maxExpandedHeight: ComposerConstant.maxHeight, maxExpandedHeight: ComposerConstant.maxHeight,
parserStyle: .elementX) parserStyle: .elementX)
let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, let composerViewModel = ComposerToolbarViewModel(initialText: parameters.sharedText,
wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: parameters.completionSuggestionService, completionSuggestionService: parameters.completionSuggestionService,
mediaProvider: parameters.mediaProvider, mediaProvider: parameters.mediaProvider,
mentionDisplayHelper: ComposerMentionDisplayHelper(timelineContext: timelineViewModel.context), mentionDisplayHelper: ComposerMentionDisplayHelper(timelineContext: timelineViewModel.context),
@ -172,7 +174,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
.store(in: &cancellables) .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. // 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) { func focusOnEvent(_ focussedEvent: FocusEvent) {
@ -183,6 +185,12 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
Task { await timelineViewModel.focusOnEvent(eventID: eventID) } 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() { func stop() {
composerViewModel.saveDraft() composerViewModel.saveDraft()
timelineViewModel.stop() timelineViewModel.stop()

View File

@ -79,6 +79,7 @@ enum TimelineViewAction {
enum TimelineComposerAction { enum TimelineComposerAction {
case setMode(mode: ComposerMode) case setMode(mode: ComposerMode)
case setText(plainText: String, htmlText: String?) case setText(plainText: String, htmlText: String?)
case setFocus
case removeFocus case removeFocus
case clear case clear
} }

View File

@ -13,6 +13,15 @@ enum ShareExtensionConstants {
enum ShareExtensionPayload: Hashable, Codable { enum ShareExtensionPayload: Hashable, Codable {
case mediaFile(roomID: String?, mediaFile: ShareExtensionMediaFile) 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 { struct ShareExtensionMediaFile: Hashable, Codable {

View File

@ -42,14 +42,18 @@ class ShareExtensionViewController: UIViewController {
return nil return nil
} }
guard let fileURL = await itemProvider.storeData() else {
MXLog.error("Failed storing NSItemProvider data \(itemProvider)")
return nil
}
let roomID = (extensionContext?.intent as? INSendMessageIntent)?.conversationIdentifier let roomID = (extensionContext?.intent as? INSendMessageIntent)?.conversationIdentifier
if let fileURL = await itemProvider.storeData() {
return .mediaFile(roomID: roomID, mediaFile: .init(url: fileURL, suggestedName: fileURL.lastPathComponent)) 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
}
} }
private func openMainApp(payload: ShareExtensionPayload) async { private func openMainApp(payload: ShareExtensionPayload) async {

View File

@ -36,6 +36,10 @@
<integer>1</integer> <integer>1</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key> <key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>1</integer> <integer>1</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict> </dict>
</dict> </dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>

View File

@ -23,17 +23,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
AppSettings.resetAllSettings() AppSettings.resetAllSettings()
appSettings = AppSettings() appSettings = AppSettings()
ServiceLocator.shared.register(appSettings: appSettings) ServiceLocator.shared.register(appSettings: appSettings)
wysiwygViewModel = WysiwygComposerViewModel() setUpViewModel()
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
} }
override func tearDown() { override func tearDown() {
@ -340,7 +330,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
return .success(nil) return .success(nil)
} }
viewModel.loadDraft() await viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10) await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled) XCTAssertFalse(viewModel.context.composerFormattingEnabled)
XCTAssertTrue(viewModel.state.composerEmpty) XCTAssertTrue(viewModel.state.composerEmpty)
@ -356,7 +346,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
htmlText: nil, htmlText: nil,
draftType: .newMessage)) draftType: .newMessage))
} }
viewModel.loadDraft() await viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10) await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled) XCTAssertFalse(viewModel.context.composerFormattingEnabled)
@ -373,7 +363,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
htmlText: "<strong>Hello</strong> world!", htmlText: "<strong>Hello</strong> world!",
draftType: .newMessage)) draftType: .newMessage))
} }
viewModel.loadDraft() await viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10) await fulfillment(of: [expectation], timeout: 10)
XCTAssertTrue(viewModel.context.composerFormattingEnabled) XCTAssertTrue(viewModel.context.composerFormattingEnabled)
@ -391,7 +381,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
htmlText: nil, htmlText: nil,
draftType: .edit(eventID: "testID"))) draftType: .edit(eventID: "testID")))
} }
viewModel.loadDraft() await viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10) await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled) XCTAssertFalse(viewModel.context.composerFormattingEnabled)
@ -424,7 +414,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
return .success(.init(details: loadedReply, return .success(.init(details: loadedReply,
isThreaded: true)) isThreaded: true))
} }
viewModel.loadDraft() await viewModel.loadDraft()
await fulfillment(of: [draftExpectation], timeout: 10) await fulfillment(of: [draftExpectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled) XCTAssertFalse(viewModel.context.composerFormattingEnabled)
@ -464,7 +454,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
return .success(.init(details: loadedReply, return .success(.init(details: loadedReply,
isThreaded: true)) isThreaded: true))
} }
viewModel.loadDraft() await viewModel.loadDraft()
await fulfillment(of: [draftExpectation], timeout: 10) await fulfillment(of: [draftExpectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled) XCTAssertFalse(viewModel.context.composerFormattingEnabled)
@ -622,6 +612,45 @@ class ComposerToolbarViewModelTests: XCTestCase {
viewModel.process(viewAction: .sendMessage) viewModel.process(viewAction: .sendMessage)
try await deferred.fulfill() 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<ComposerDraftProxy?, ComposerDraftServiceError>)? = 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 { private extension MentionSuggestionItem {

View File

@ -218,7 +218,7 @@ class RoomFlowCoordinatorTests: XCTestCase {
XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator) XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
} }
func testShareRoute() async throws { func testShareMediaRoute() async throws {
await setupRoomFlowCoordinator() await setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1", via: [])) try await process(route: .room(roomID: "1", via: []))
@ -243,6 +243,31 @@ class RoomFlowCoordinatorTests: XCTestCase {
XCTAssertTrue((navigationStackCoordinator.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) 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 // MARK: - Private
private func process(route: AppRoute) async throws { private func process(route: AppRoute) async throws {

View File

@ -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.") "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)) try await process(route: .settings, expectedState: .settingsScreen(selectedRoomID: nil))
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator) XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
@ -253,7 +253,7 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator) 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")) try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(selectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
@ -265,6 +265,29 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) 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 // MARK: - Private
private func process(route: AppRoute, expectedState: UserSessionFlowCoordinatorStateMachine.State) async throws { private func process(route: AppRoute, expectedState: UserSessionFlowCoordinatorStateMachine.State) async throws {