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? {
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)
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,
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) {

View File

@ -207,9 +207,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
case .settings, .chatBackupSettings:
settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
case .share(let payload):
switch payload {
case .mediaFile(let roomID, _):
if let roomID {
if let roomID = payload.roomID {
stateMachine.processEvent(.selectRoom(roomID: roomID,
via: [],
entryPoint: .share(payload)),
@ -219,7 +217,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
}
}
}
func attemptStartingOnboarding() {
if onboardingFlowCoordinator.shouldStart {
@ -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)

View File

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

View File

@ -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)")

View File

@ -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

View File

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

View File

@ -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()

View File

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

View File

@ -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 {

View File

@ -42,14 +42,18 @@ class ShareExtensionViewController: UIViewController {
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
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
}
}
private func openMainApp(payload: ShareExtensionPayload) async {

View File

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

View File

@ -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: "<strong>Hello</strong> 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<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 {

View File

@ -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 {

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.")
}
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 {