mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user