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
@@ -0,0 +1,52 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import SwiftUI
struct RoomSelectionScreenCoordinatorParameters {
let clientProxy: ClientProxyProtocol
let roomSummaryProvider: RoomSummaryProviderProtocol
let mediaProvider: MediaProviderProtocol
}
enum RoomSelectionScreenCoordinatorAction {
case dismiss
case confirm(roomID: String)
}
final class RoomSelectionScreenCoordinator: CoordinatorProtocol {
private var viewModel: RoomSelectionScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<RoomSelectionScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actionsPublisher: AnyPublisher<RoomSelectionScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: RoomSelectionScreenCoordinatorParameters) {
viewModel = RoomSelectionScreenViewModel(clientProxy: parameters.clientProxy,
roomSummaryProvider: parameters.roomSummaryProvider,
mediaProvider: parameters.mediaProvider)
}
func start() {
viewModel.actionsPublisher.sink { [weak self] action in
switch action {
case .dismiss:
self?.actionsSubject.send(.dismiss)
case .confirm(let roomID):
self?.actionsSubject.send(.confirm(roomID: roomID))
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(RoomSelectionScreen(context: viewModel.context))
}
}
@@ -0,0 +1,39 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
import MatrixRustSDK
enum RoomSelectionScreenViewModelAction {
case dismiss
case confirm(roomID: String)
}
struct RoomSelectionScreenViewState: BindableState {
var rooms: [RoomSelectionRoom] = []
var selectedRoomID: String?
var bindings = RoomSelectionScreenViewStateBindings()
}
struct RoomSelectionScreenViewStateBindings {
var searchQuery = ""
}
enum RoomSelectionScreenViewAction {
case cancel
case confirm
case selectRoom(roomID: String)
case reachedTop
case reachedBottom
}
struct RoomSelectionRoom: Identifiable, Equatable {
let id: String
let title: String
let description: String
let avatar: RoomAvatar
}
@@ -0,0 +1,105 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import SwiftUI
typealias RoomSelectionScreenViewModelType = StateStoreViewModel<RoomSelectionScreenViewState, RoomSelectionScreenViewAction>
class RoomSelectionScreenViewModel: RoomSelectionScreenViewModelType, RoomSelectionScreenViewModelProtocol {
private let clientProxy: ClientProxyProtocol
private let roomSummaryProvider: RoomSummaryProviderProtocol
private var actionsSubject: PassthroughSubject<RoomSelectionScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<RoomSelectionScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(clientProxy: ClientProxyProtocol,
roomSummaryProvider: RoomSummaryProviderProtocol,
mediaProvider: MediaProviderProtocol) {
self.clientProxy = clientProxy
self.roomSummaryProvider = roomSummaryProvider
super.init(initialViewState: RoomSelectionScreenViewState(), mediaProvider: mediaProvider)
roomSummaryProvider.roomListPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updateRooms()
}
.store(in: &cancellables)
context.$viewState
.map(\.bindings.searchQuery)
.removeDuplicates()
.sink { [weak self] searchQuery in
if searchQuery.isEmpty {
self?.roomSummaryProvider.setFilter(.all(filters: []))
} else {
self?.roomSummaryProvider.setFilter(.search(query: searchQuery))
}
}
.store(in: &cancellables)
updateRooms()
}
override func process(viewAction: RoomSelectionScreenViewAction) {
switch viewAction {
case .cancel:
actionsSubject.send(.dismiss)
roomSummaryProvider.setFilter(.all(filters: []))
case .confirm:
guard let selectedRoomID = state.selectedRoomID else {
return
}
actionsSubject.send(.confirm(roomID: selectedRoomID))
case .selectRoom(let roomID):
state.selectedRoomID = roomID
case .reachedTop:
updateVisibleRange(edge: .top)
case .reachedBottom:
updateVisibleRange(edge: .bottom)
}
}
// MARK: - Private
private func updateRooms() {
var rooms = [RoomSelectionRoom]()
for summary in roomSummaryProvider.roomListPublisher.value {
rooms.append(.init(id: summary.id,
title: summary.name,
description: summary.roomListDescription,
avatar: summary.avatar))
}
state.rooms = rooms
}
/// The actual range values don't matter as long as they contain the lower
/// or upper bounds. updateVisibleRange is a hybrid API that powers both
/// sliding sync visible range update and list paginations
/// For lists other than the home screen one we don't care about visible ranges,
/// we just need the respective bounds to be there to trigger a next page load or
/// a reset to just one page
private func updateVisibleRange(edge: UIRectEdge) {
switch edge {
case .top:
roomSummaryProvider.updateVisibleRange(0..<0)
case .bottom:
let roomCount = roomSummaryProvider.roomListPublisher.value.count
roomSummaryProvider.updateVisibleRange(roomCount..<roomCount)
default:
break
}
}
}
@@ -0,0 +1,14 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
@MainActor
protocol RoomSelectionScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<RoomSelectionScreenViewModelAction, Never> { get }
var context: RoomSelectionScreenViewModelType.Context { get }
}
@@ -0,0 +1,104 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Compound
import SwiftUI
struct RoomSelectionScreen: View {
@ObservedObject var context: RoomSelectionScreenViewModel.Context
var body: some View {
Form {
Section {
ForEach(context.viewState.rooms) { room in
RoomSelectionListRow(room: room,
isSelected: context.viewState.selectedRoomID == room.id,
context: context)
}
// Replace these with ScrollView's `scrollPosition` when dropping iOS 16.
} header: {
emptyRectangle
.onAppear {
context.send(viewAction: .reachedTop)
}
} footer: {
emptyRectangle
.onAppear {
context.send(viewAction: .reachedBottom)
}
}
}
.compoundList()
.navigationTitle(L10n.screenRoomlistMainSpaceTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
ToolbarItem(placement: .confirmationAction) {
Button(L10n.actionShare) {
context.send(viewAction: .confirm)
}
.disabled(context.viewState.selectedRoomID == nil)
}
}
.searchController(query: $context.searchQuery, showsCancelButton: false)
.compoundSearchField()
.disableAutocorrection(true)
}
/// The greedy size of Rectangle can create an issue with the navigation bar when the search is highlighted, so is best to use a fixed frame instead of hidden() or EmptyView()
private var emptyRectangle: some View {
Rectangle()
.frame(width: 0, height: 0)
}
}
private struct RoomSelectionListRow: View {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
let room: RoomSelectionRoom
let isSelected: Bool
let context: RoomSelectionScreenViewModel.Context
var body: some View {
ListRow(label: .avatar(title: room.title,
description: room.description,
icon: avatar),
kind: .selection(isSelected: isSelected) {
context.send(viewAction: .selectRoom(roomID: room.id))
})
}
@ViewBuilder @MainActor
var avatar: some View {
if dynamicTypeSize < .accessibility3 {
RoomAvatarImage(avatar: room.avatar,
avatarSize: .room(on: .roomSelection),
mediaProvider: context.mediaProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true)
}
}
}
// MARK: - Previews
struct RoomSelectionScreen_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
let summaryProvider = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))
let viewModel = RoomSelectionScreenViewModel(clientProxy: ClientProxyMock(.init()),
roomSummaryProvider: summaryProvider,
mediaProvider: MediaProviderMock(configuration: .init()))
NavigationStack {
RoomSelectionScreen(context: viewModel.context)
}
}
}