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
14 changed files with 219 additions and 83 deletions
@@ -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
@@ -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 {