mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -123,6 +123,7 @@ extension JoinedRoomProxyMock {
|
||||
matrixToEventPermalinkReturnValue = .success(.homeDirectory)
|
||||
loadDraftReturnValue = .success(nil)
|
||||
clearDraftReturnValue = .success(())
|
||||
sendTypingNotificationIsTypingReturnValue = .success(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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? {
|
||||
guard let contentType = preferredContentType else {
|
||||
MXLog.error("Invalid NSItemProvider: \(self)")
|
||||
|
||||
@@ -15,6 +15,7 @@ import WysiwygComposer
|
||||
typealias ComposerToolbarViewModelType = StateStoreViewModel<ComposerToolbarViewState, ComposerToolbarViewAction>
|
||||
|
||||
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<Void, Never>?
|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -15,6 +15,6 @@ protocol ComposerToolbarViewModelProtocol {
|
||||
var keyCommands: [WysiwygKeyCommand] { get }
|
||||
|
||||
func process(timelineAction: TimelineComposerAction)
|
||||
func loadDraft()
|
||||
func loadDraft() async
|
||||
func saveDraft()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -79,6 +79,7 @@ enum TimelineViewAction {
|
||||
enum TimelineComposerAction {
|
||||
case setMode(mode: ComposerMode)
|
||||
case setText(plainText: String, htmlText: String?)
|
||||
case setFocus
|
||||
case removeFocus
|
||||
case clear
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user