Share extension (#3506)

* Setup simple share extension

* Switch the app url scheme to be the full bundle identifier

* Setup a share extension that show a SwiftUI view, uses rust tracing and redirects to the hosting aplication

* Move media as json through the custom scheme into the main app and deep link into the media upload preview screen

* Fix message forwarding and global search screen room summary provider filtering.

* Tweak the message forwarding and global search screen designs.

* Add a room selection screen to use after receiving a share request from the share extension

* Fix share extension entitlements

* Share the temporary directory between the main app and the extensions; rename the caches one.

* Remove the no longer needed notification avatar flipping fix.

* Extract the placeholder avatar image generator from the NSE

* Nest `AvatarSize` within the new `Avatars` enum

* Donate an `INSendMessageIntent` to the system every time we send a message so they appear as share suggestions

* Support suggestions in the share extension itself

* Improve sharing animations and fix presentation when room already on the stack

* Clear all routes when sharing without a preselected room.

* Fix broken unit tests

* Various initial tweaks following code review.

* Correctly clean up and dismiss the share extension for all paths.

* Move the share extension path to a constants enum

* Rename UserSessionFlowCoordinator specific share extension states and events

* Add UserSession and Room flow coordinator share route tests

* Tweak the share extension logic.
This commit is contained in:
Stefan Ceriu
2024-11-13 14:02:47 +02:00
committed by GitHub
parent 3a600a94d5
commit b122b02bee
69 changed files with 1583 additions and 272 deletions
@@ -83,7 +83,7 @@ class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol {
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias,
.roomDetails, .roomMemberDetails, .userProfile,
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias,
.call, .genericCallLink, .settings:
.call, .genericCallLink, .settings, .share:
// These routes aren't in this flow so clear the entire stack.
clearRoute(animated: animated)
case .chatBackupSettings:
@@ -293,9 +293,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
let coordinator = SecureBackupRecoveryKeyScreenCoordinator(parameters: parameters)
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
.sink { action in
switch action {
case .complete:
break // Moving to next state is Handled by the global session verification listener
@@ -65,7 +65,9 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))
guard let timelineController = await roomTimelineControllerFactory.buildRoomPinnedTimelineController(roomProxy: roomProxy, timelineItemFactory: timelineItemFactory) else {
guard let timelineController = await roomTimelineControllerFactory.buildRoomPinnedTimelineController(roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider) else {
fatalError("This can never fail because we allow this view to be presented only when the timeline is fully loaded and not nil")
}
@@ -34,6 +34,8 @@ enum RoomFlowCoordinatorEntryPoint: Hashable {
case eventID(String)
/// The flow will start by showing the room's details.
case roomDetails
/// An external media share request
case share(ShareExtensionPayload)
var isEventID: Bool {
guard case .eventID = self else { return false }
@@ -41,6 +43,32 @@ enum RoomFlowCoordinatorEntryPoint: Hashable {
}
}
struct FocusEvent: Hashable {
/// The event ID that the timeline should be focussed around
let eventID: String
/// if the focus is coming from the pinned timeline, this should also update the pin banner
let shouldSetPin: Bool
}
private enum PinnedEventsTimelineSource: Hashable {
case room
case details(isRoot: Bool)
}
private enum PresentationAction: Hashable {
case eventFocus(FocusEvent)
case share(ShareExtensionPayload)
var focusedEvent: FocusEvent? {
switch self {
case .eventFocus(let focusEvent):
return focusEvent
default:
return nil
}
}
}
// swiftlint:disable:next type_body_length
class RoomFlowCoordinator: FlowCoordinatorProtocol {
private let roomID: String
@@ -112,6 +140,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
fatalError("This flow coordinator expect a route")
}
// swiftlint:disable:next cyclomatic_complexity
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
guard stateMachine.state != .complete else {
fatalError("This flow coordinator is `finished` ☠️")
@@ -152,7 +181,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated))
}
case .event(let eventID, let roomID, let via):
Task { await handleRoomRoute(roomID: roomID, via: via, focussedEventID: eventID, animated: animated) }
Task {
await handleRoomRoute(roomID: roomID,
via: via,
presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: false)),
animated: animated)
}
case .childEvent(let eventID, let roomID, let via):
if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator {
childRoomFlowCoordinator.handleAppRoute(appRoute, animated: animated)
@@ -161,6 +195,21 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} else {
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 {
fatalError("Navigation route doesn't belong to this room flow.")
}
Task {
await handleRoomRoute(roomID: roomID,
via: [],
presentationAction: .share(payload),
animated: animated)
}
case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias:
break // These are converted to a room ID route one level above.
case .roomList, .userProfile, .call, .genericCallLink, .settings, .chatBackupSettings:
@@ -176,7 +225,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
}
private func handleRoomRoute(roomID: String, via: [String], focussedEventID: String? = nil, animated: Bool) async {
private func handleRoomRoute(roomID: String, via: [String], presentationAction: PresentationAction? = nil, animated: Bool) async {
guard roomID == self.roomID else { fatalError("Navigation route doesn't belong to this room flow.") }
guard let room = await userSession.clientProxy.roomForIdentifier(roomID) else {
@@ -187,8 +236,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch room {
case .joined(let roomProxy):
await storeAndSubscribeToRoomProxy(roomProxy)
let focussedEvent = focussedEventID.map { FocusEvent(eventID: $0, shouldSetPin: false) }
stateMachine.tryEvent(.presentRoom(focussedEvent: focussedEvent), userInfo: EventUserInfo(animated: animated))
stateMachine.tryEvent(.presentRoom(presentationAction: presentationAction), userInfo: EventUserInfo(animated: animated))
default:
stateMachine.tryEvent(.presentJoinRoomScreen(via: via), userInfo: EventUserInfo(animated: animated))
}
@@ -376,9 +424,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
presentJoinRoomScreen(via: via, animated: true)
case (_, .dismissJoinRoomScreen, .complete):
dismissFlow(animated: animated)
case (_, .presentRoom(let focussedEvent), .room):
Task { await self.presentRoom(fromState: context.fromState, focussedEvent: focussedEvent, animated: animated) }
case (_, .presentRoom(let presentationAction), .room):
Task {
await self.presentRoom(fromState: context.fromState,
presentationAction: presentationAction,
animated: animated)
}
case (_, .dismissFlow, .complete):
dismissFlow(animated: animated)
@@ -445,9 +497,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
break
case (.mediaUploadPicker, .presentMediaUploadPreview, .mediaUploadPreview(let fileURL)):
presentMediaUploadPreviewScreen(for: fileURL)
presentMediaUploadPreviewScreen(for: fileURL, animated: animated)
case (.room, .presentMediaUploadPreview, .mediaUploadPreview(let fileURL)):
presentMediaUploadPreviewScreen(for: fileURL)
presentMediaUploadPreviewScreen(for: fileURL, animated: animated)
case (.mediaUploadPreview, .dismissMediaUploadPreview, .room):
break
@@ -542,7 +594,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
/// - fromState: The state that asked for the room presentation.
/// - focussedEvent: An (optional) struct that contains the event ID that the timeline should be focussed around, and a boolean telling if such event should update the pinned events banner
/// - animated: whether it should animate the transition
private func presentRoom(fromState: State, focussedEvent: FocusEvent?, animated: Bool) async {
private func presentRoom(fromState: State, presentationAction: PresentationAction?, animated: Bool) async {
// If any sheets are presented dismiss them, rely on their dismissal callbacks to transition the state machine
// through the correct states before presenting the room
navigationStackCoordinator.setSheetCoordinator(nil)
@@ -559,9 +611,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
default:
// The room is already on the stack, no need to present it again
// Check if we need to focus on an event
if let focussedEvent {
roomScreenCoordinator?.focusOnEvent(focussedEvent)
switch presentationAction {
case .eventFocus(let focusedEvent):
roomScreenCoordinator?.focusOnEvent(focusedEvent)
case .share(.mediaFile(_, let mediaFile)):
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: mediaFile.url))
default:
break
}
return
@@ -580,8 +636,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
initialFocussedEventID: focussedEvent?.eventID,
timelineItemFactory: timelineItemFactory)
initialFocussedEventID: presentationAction?.focusedEvent?.eventID,
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider)
self.timelineController = timelineController
analytics.trackViewRoom(isDM: roomProxy.infoPublisher.value.isDirect, isSpace: roomProxy.infoPublisher.value.isSpace)
@@ -592,7 +649,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let parameters = RoomScreenCoordinatorParameters(clientProxy: userSession.clientProxy,
roomProxy: roomProxy,
focussedEvent: focussedEvent,
focussedEvent: presentationAction?.focusedEvent,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: MediaPlayerProvider(),
@@ -655,6 +712,13 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
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) {
@@ -679,7 +743,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) {
await storeAndSubscribeToRoomProxy(roomProxy)
stateMachine.tryEvent(.presentRoom(focussedEvent: nil), userInfo: EventUserInfo(animated: animated))
stateMachine.tryEvent(.presentRoom(presentationAction: nil), userInfo: EventUserInfo(animated: animated))
analytics.trackJoinedRoom(isDM: roomProxy.infoPublisher.value.isDirect,
isSpace: roomProxy.infoPublisher.value.isSpace,
@@ -894,7 +958,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentMediaUploadPreviewScreen(for url: URL) {
private func presentMediaUploadPreviewScreen(for url: URL, animated: Bool) {
let stackCoordinator = NavigationStackCoordinator()
let parameters = MediaUploadPreviewScreenCoordinatorParameters(userIndicatorController: userIndicatorController,
@@ -918,7 +982,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stackCoordinator.setRootCoordinator(mediaUploadPreviewScreenCoordinator)
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in
navigationStackCoordinator.setSheetCoordinator(stackCoordinator, animated: animated) { [weak self] in
self?.stateMachine.tryEvent(.dismissMediaUploadPreview)
}
}
@@ -1091,7 +1155,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let roomTimelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
initialFocussedEventID: nil,
timelineItemFactory: timelineItemFactory)
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider)
let parameters = RoomPollsHistoryScreenCoordinatorParameters(pollInteractionHandler: PollInteractionHandler(analyticsService: analytics, roomProxy: roomProxy),
roomTimelineController: roomTimelineController)
@@ -1361,7 +1426,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room))
case .displayRoomScreenWithFocussedPin(let eventID):
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.presentRoom(focussedEvent: .init(eventID: eventID, shouldSetPin: true)))
stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: true))))
}
}
.store(in: &cancellables)
@@ -1427,6 +1492,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
coordinator.handleAppRoute(.event(eventID: eventID, roomID: roomID, via: via), animated: true)
case .roomDetails:
coordinator.handleAppRoute(.roomDetails(roomID: roomID), animated: true)
case .share(let payload):
coordinator.handleAppRoute(.share(payload), animated: true)
}
}
}
@@ -1483,7 +1550,7 @@ private extension RoomFlowCoordinator {
case presentJoinRoomScreen(via: [String])
case dismissJoinRoomScreen
case presentRoom(focussedEvent: FocusEvent?)
case presentRoom(presentationAction: PresentationAction?)
case dismissFlow
case presentReportContent(itemID: TimelineItemIdentifier, senderID: String)
@@ -1559,15 +1626,3 @@ private extension Result {
}
}
}
private enum PinnedEventsTimelineSource: Hashable {
case room
case details(isRoot: Bool)
}
struct FocusEvent: Hashable {
/// The event ID that the timeline should be focussed around
let eventID: String
/// if the focus is coming from the pinned timeline, this should also update the pin banner
let shouldSetPin: Bool
}
@@ -206,6 +206,18 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
presentCallScreen(genericCallLink: url)
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))
}
}
}
}
@@ -243,6 +255,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
case .room: .room(roomID: roomID, via: via)
case .roomDetails: .roomDetails(roomID: roomID)
case .eventID(let eventID): .event(eventID: eventID, roomID: roomID, via: via) // ignored.
case .share(let payload): .share(payload)
}
roomFlowCoordinator.handleAppRoute(route, animated: animated)
} else {
@@ -296,6 +309,12 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
case (.userProfileScreen, .dismissedUserProfileScreen, .roomList):
break
case (.roomList, .showShareExtensionRoomList, .shareExtensionRoomList(let sharePayload)):
clearRoute(animated: animated)
presentRoomSelectionScreen(sharePayload: sharePayload, animated: animated)
case (.shareExtensionRoomList, .dismissedShareExtensionRoomList, .roomList):
dismissRoomSelectionScreen()
default:
fatalError("Unknown transition: \(context)")
}
@@ -583,6 +602,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
coordinator.handleAppRoute(.event(eventID: eventID, roomID: roomID, via: via), animated: animated)
case .roomDetails:
coordinator.handleAppRoute(.roomDetails(roomID: roomID), animated: animated)
case .share(let payload):
coordinator.handleAppRoute(.share(payload), animated: animated)
}
Task {
@@ -894,6 +915,52 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
}
// MARK: Sharing
private func presentRoomSelectionScreen(sharePayload: ShareExtensionPayload, animated: Bool) {
guard let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider else {
fatalError()
}
let stackCoordinator = NavigationStackCoordinator()
let coordinator = RoomSelectionScreenCoordinator(parameters: .init(clientProxy: userSession.clientProxy,
roomSummaryProvider: roomSummaryProvider,
mediaProvider: userSession.mediaProvider))
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
navigationSplitCoordinator.setSheetCoordinator(nil)
case .confirm(let roomID):
let sharePayload = switch sharePayload {
case .mediaFile(_, let mediaFile):
ShareExtensionPayload.mediaFile(roomID: roomID, mediaFile: mediaFile)
}
navigationSplitCoordinator.setSheetCoordinator(nil)
stateMachine.processEvent(.selectRoom(roomID: roomID,
via: [],
entryPoint: .share(sharePayload)),
userInfo: .init(animated: animated))
}
}
.store(in: &cancellables)
stackCoordinator.setRootCoordinator(coordinator)
navigationSplitCoordinator.setSheetCoordinator(stackCoordinator, animated: animated) { [weak self] in
self?.stateMachine.processEvent(.dismissedShareExtensionRoomList)
}
}
private func dismissRoomSelectionScreen() {
navigationSplitCoordinator.setSheetCoordinator(nil)
}
// MARK: Toasts and loading indicators
private static let loadingIndicatorIdentifier = "\(UserSessionFlowCoordinator.self)-Loading"
@@ -42,10 +42,12 @@ class UserSessionFlowCoordinatorStateMachine {
/// Showing the user profile screen. This screen clears the navigation.
case userProfileScreen
case shareExtensionRoomList(sharePayload: ShareExtensionPayload)
/// The selected room ID from the state if available.
var selectedRoomID: String? {
switch self {
case .initial, .userProfileScreen:
case .initial, .userProfileScreen, .shareExtensionRoomList:
nil
case .roomList(let selectedRoomID),
.feedbackScreen(let selectedRoomID),
@@ -116,6 +118,9 @@ class UserSessionFlowCoordinatorStateMachine {
case showUserProfileScreen(userID: String)
/// The user profile screen has been dismissed.
case dismissedUserProfileScreen
case showShareExtensionRoomList(sharePayload: ShareExtensionPayload)
case dismissedShareExtensionRoomList
}
private let stateMachine: StateMachine<State, Event>
@@ -184,6 +189,11 @@ class UserSessionFlowCoordinatorStateMachine {
case (.userProfileScreen, .dismissedUserProfileScreen):
return .roomList(selectedRoomID: nil)
case (.roomList, .showShareExtensionRoomList(let sharePayload)):
return .shareExtensionRoomList(sharePayload: sharePayload)
case (.shareExtensionRoomList, .dismissedShareExtensionRoomList):
return .roomList(selectedRoomID: nil)
default:
return nil
}