mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
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:
parent
3a9f54a9f6
commit
c081e538b4
@ -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) {
|
||||||
|
@ -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)
|
||||||
|
@ -123,6 +123,7 @@ extension JoinedRoomProxyMock {
|
|||||||
matrixToEventPermalinkReturnValue = .success(.homeDirectory)
|
matrixToEventPermalinkReturnValue = .success(.homeDirectory)
|
||||||
loadDraftReturnValue = .success(nil)
|
loadDraftReturnValue = .success(nil)
|
||||||
clearDraftReturnValue = .success(())
|
clearDraftReturnValue = .success(())
|
||||||
|
sendTypingNotificationIsTypingReturnValue = .success(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)")
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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>
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user