Implements #745, #746, #747 - Initial media uploading flows

- room composer contextual menu for selecting source
- coordinator for presenting the different sources
- system picker handling and URL passback
- file preview after selection
- feature flag
This commit is contained in:
Stefan Ceriu 2023-03-28 17:47:34 +03:00 committed by Stefan Ceriu
parent 2359910857
commit 5f6edacc1a
27 changed files with 787 additions and 29 deletions

View File

@ -24,3 +24,11 @@
"soft_logout_clear_data_submit" = "Clear all data";
"soft_logout_clear_data_dialog_title" = "Clear data";
"soft_logout_clear_data_dialog_content" = "Clear all data currently stored on this device?\nSign in again to access your account data and messages.";
// MARK: - Media upload
"media_upload_camera_picker" = "Camera";
"media_upload_photo_and_video_picker" = "Photo & Video Library";
"media_upload_document_picker" = "Document";

View File

@ -28,6 +28,7 @@ final class AppSettings: ObservableObject {
case pusherProfileTag
case shouldCollapseRoomStateEvents
case showStartChatFlow
case mediaUploadingFlowEnabled
}
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@ -162,4 +163,9 @@ final class AppSettings: ObservableObject {
@UserSetting(key: UserDefaultsKeys.showStartChatFlow.rawValue, defaultValue: false, persistIn: store)
var startChatFlowFeatureFlag
// MARK: Media Uploading
@UserSetting(key: UserDefaultsKeys.mediaUploadingFlowEnabled.rawValue, defaultValue: false, persistIn: nil)
var mediaUploadingFlowEnabled
}

View File

@ -28,6 +28,12 @@ public enum UntranslatedL10n {
public static func analyticsOptInTitle(_ p1: Any) -> String {
return UntranslatedL10n.tr("Untranslated", "analytics_opt_in_title", String(describing: p1))
}
/// Camera
public static var mediaUploadCameraPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_camera_picker") }
/// Document
public static var mediaUploadDocumentPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_document_picker") }
/// Photo & Video Library
public static var mediaUploadPhotoAndVideoPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_photo_and_video_picker") }
/// Clear all data currently stored on this device?
/// Sign in again to access your account data and messages.
public static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") }

View File

@ -326,6 +326,27 @@ class RoomProxyMock: RoomProxyProtocol {
return sendReactionToReturnValue
}
}
//MARK: - sendImage
var sendImageBodyUrlCallsCount = 0
var sendImageBodyUrlCalled: Bool {
return sendImageBodyUrlCallsCount > 0
}
var sendImageBodyUrlReceivedArguments: (body: String, url: URL)?
var sendImageBodyUrlReceivedInvocations: [(body: String, url: URL)] = []
var sendImageBodyUrlReturnValue: Result<Void, RoomProxyError>!
var sendImageBodyUrlClosure: ((String, URL) async -> Result<Void, RoomProxyError>)?
func sendImage(body: String, url: URL) async -> Result<Void, RoomProxyError> {
sendImageBodyUrlCallsCount += 1
sendImageBodyUrlReceivedArguments = (body: body, url: url)
sendImageBodyUrlReceivedInvocations.append((body: body, url: url))
if let sendImageBodyUrlClosure = sendImageBodyUrlClosure {
return await sendImageBodyUrlClosure(body, url)
} else {
return sendImageBodyUrlReturnValue
}
}
//MARK: - editMessage
var editMessageOriginalCallsCount = 0

View File

@ -31,4 +31,21 @@ extension FileManager {
}
try createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories)
}
func copyFileToTemporaryLocation(url: URL) throws -> URL {
let newURL = URL.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
try? removeItem(at: newURL)
try copyItem(at: url, to: newURL)
return newURL
}
func writeDataToTemporaryLocation(data: Data, fileName: String) throws -> URL {
let newURL = URL.temporaryDirectory.appendingPathComponent(fileName)
try data.write(to: newURL)
return newURL
}
}

View File

@ -25,9 +25,11 @@ struct DeveloperOptionsScreenViewState: BindableState {
struct DeveloperOptionsScreenViewStateBindings {
var shouldCollapseRoomStateEvents: Bool
var showStartChatFlow: Bool
var mediaUploadFlowEnabled: Bool
}
enum DeveloperOptionsScreenViewAction {
case changedShouldCollapseRoomStateEvents
case changedShowStartChatFlow
case changedShowMediaUploadFlow
}

View File

@ -22,7 +22,9 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
var callback: ((DeveloperOptionsScreenViewModelAction) -> Void)?
init() {
let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents, showStartChatFlow: ServiceLocator.shared.settings.startChatFlowFeatureFlag)
let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents,
showStartChatFlow: ServiceLocator.shared.settings.startChatFlowFeatureFlag,
mediaUploadFlowEnabled: ServiceLocator.shared.settings.mediaUploadingFlowEnabled)
let state = DeveloperOptionsScreenViewState(bindings: bindings)
super.init(initialViewState: state)
@ -38,6 +40,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
ServiceLocator.shared.settings.shouldCollapseRoomStateEvents = state.bindings.shouldCollapseRoomStateEvents
case .changedShowStartChatFlow:
ServiceLocator.shared.settings.startChatFlowFeatureFlag = state.bindings.showStartChatFlow
case .changedShowMediaUploadFlow:
ServiceLocator.shared.settings.mediaUploadingFlowEnabled = state.bindings.mediaUploadFlowEnabled
}
}
}

View File

@ -31,12 +31,20 @@ struct DeveloperOptionsScreenScreen: View {
.onChange(of: context.shouldCollapseRoomStateEvents) { _ in
context.send(viewAction: .changedShouldCollapseRoomStateEvents)
}
Toggle(isOn: $context.showStartChatFlow) {
Text("Show Start Chat flow")
}
.onChange(of: context.showStartChatFlow) { _ in
context.send(viewAction: .changedShowStartChatFlow)
}
Toggle(isOn: $context.mediaUploadFlowEnabled) {
Text("Show Media Uploading flow")
}
.onChange(of: context.mediaUploadFlowEnabled) { _ in
context.send(viewAction: .changedShowMediaUploadFlow)
}
}
Section {

View File

@ -0,0 +1,81 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import UIKit
enum CameraPickerAction {
case selectFile(URL)
case cancel
case error(Error?)
}
struct CameraPicker: UIViewControllerRepresentable {
private let callback: (CameraPickerAction) -> Void
init(callback: @escaping (CameraPickerAction) -> Void) {
self.callback = callback
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let imagePicker = UIImagePickerController()
imagePicker.sourceType = .camera
imagePicker.allowsEditing = true
imagePicker.delegate = context.coordinator
if let mediaTypes = UIImagePickerController.availableMediaTypes(for: .camera) {
imagePicker.mediaTypes = mediaTypes
}
return imagePicker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { }
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
private var parent: CameraPicker
init(_ parent: CameraPicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let videoURL = info[.mediaURL] as? URL {
parent.callback(.selectFile(videoURL))
} else if let image = info[.originalImage] as? UIImage {
guard let jpegData = image.jpegData(compressionQuality: 1.0) else {
parent.callback(.error(nil))
return
}
let fileName = "\(UUID().uuidString).jpg"
do {
let url = try FileManager.default.writeDataToTemporaryLocation(data: jpegData, fileName: fileName)
parent.callback(.selectFile(url))
} catch {
parent.callback(.error(error))
}
} else {
parent.callback(.error(nil))
}
}
}
}

View File

@ -0,0 +1,69 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import UIKit
enum DocumentPickerAction {
case selectFile(URL)
case cancel
case error(Error?)
}
struct DocumentPicker: UIViewControllerRepresentable {
private let callback: (DocumentPickerAction) -> Void
init(callback: @escaping (DocumentPickerAction) -> Void) {
self.callback = callback
}
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.data])
documentPicker.allowsMultipleSelection = false
documentPicker.delegate = context.coordinator
return documentPicker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { }
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, UIDocumentPickerDelegate {
private var parent: DocumentPicker
init(_ parent: DocumentPicker) {
self.parent = parent
}
// MARK: UIDocumentPickerDelegate
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
parent.callback(.cancel)
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else {
parent.callback(.error(nil))
return
}
parent.callback(.selectFile(url))
}
}
}

View File

@ -0,0 +1,89 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
enum MediaPickerSource {
case camera
case photoLibrary
case documents
}
enum MediaPickerCoordinatorAction {
case selectMediaAtURL(URL)
case cancel
case error(Error?)
}
class MediaPickerCoordinator: CoordinatorProtocol {
private let source: MediaPickerSource
private let callback: ((MediaPickerCoordinatorAction) -> Void)?
init(source: MediaPickerSource, callback: @escaping (MediaPickerCoordinatorAction) -> Void) {
self.source = source
self.callback = callback
}
func toPresentable() -> AnyView {
AnyView(mediaPicker)
}
@ViewBuilder
private var mediaPicker: some View {
switch source {
case .camera:
cameraPicker
case .photoLibrary:
PhotoLibraryPicker { [weak self] action in
switch action {
case .cancel:
self?.callback?(.cancel)
case .error(let error):
self?.callback?(.error(error))
case .selectFile(let url):
self?.callback?(.selectMediaAtURL(url))
}
}
case .documents:
// The document picker automatically dismisses everything on selection
// Strongly retain self in the callback to forward actions correctly
DocumentPicker { action in
switch action {
case .cancel:
self.callback?(.cancel)
case .error(let error):
self.callback?(.error(error))
case .selectFile(let url):
self.callback?(.selectMediaAtURL(url))
}
}
}
}
private var cameraPicker: some View {
CameraPicker { [weak self] action in
switch action {
case .cancel:
self?.callback?(.cancel)
case .error(let error):
self?.callback?(.error(error))
case .selectFile(let url):
self?.callback?(.selectMediaAtURL(url))
}
}
.background(.black, ignoresSafeAreaEdges: .bottom)
}
}

View File

@ -0,0 +1,84 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import PhotosUI
import SwiftUI
enum PhotoLibraryPickerAction {
case selectFile(URL)
case cancel
case error(Error?)
}
struct PhotoLibraryPicker: UIViewControllerRepresentable {
private let callback: (PhotoLibraryPickerAction) -> Void
init(callback: @escaping (PhotoLibraryPickerAction) -> Void) {
self.callback = callback
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.selectionLimit = 1
let pickerViewController = PHPickerViewController(configuration: configuration)
pickerViewController.delegate = context.coordinator
return pickerViewController
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { }
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
private var parent: PhotoLibraryPicker
init(_ parent: PhotoLibraryPicker) {
self.parent = parent
}
// MARK: PHPickerViewControllerDelegate
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let provider = results.first?.itemProvider else {
parent.callback(.cancel)
return
}
provider.loadFileRepresentation(forTypeIdentifier: "public.item") { @MainActor [weak self] url, error in
guard let url else {
self?.parent.callback(.error(error))
return
}
do {
let _ = url.startAccessingSecurityScopedResource()
let newURL = try FileManager.default.copyFileToTemporaryLocation(url: url)
url.stopAccessingSecurityScopedResource()
Task { @MainActor in
self?.parent.callback(.selectFile(newURL))
}
} catch {
self?.parent.callback(.error(error))
}
}
}
}
}

View File

@ -0,0 +1,56 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct MediaPickerPreviewScreenCoordinatorParameters {
let url: URL
let title: String?
}
enum MediaPickerPreviewScreenCoordinatorAction {
case send
case cancel
}
final class MediaPickerPreviewScreenCoordinator: CoordinatorProtocol {
private let parameters: MediaPickerPreviewScreenCoordinatorParameters
private var viewModel: MediaPickerPreviewScreenViewModelProtocol
private let callback: (MediaPickerPreviewScreenCoordinatorAction) -> Void
init(parameters: MediaPickerPreviewScreenCoordinatorParameters, callback: @escaping (MediaPickerPreviewScreenCoordinatorAction) -> Void) {
self.parameters = parameters
self.callback = callback
viewModel = MediaPickerPreviewScreenViewModel(url: parameters.url, title: parameters.title)
}
func start() {
viewModel.callback = { [weak self] action in
switch action {
case .send:
self?.callback(.send)
case .cancel:
self?.callback(.cancel)
}
}
}
func toPresentable() -> AnyView {
AnyView(MediaPickerPreviewScreenScreen(context: viewModel.context))
}
}

View File

@ -14,18 +14,19 @@
// limitations under the License.
//
import SwiftUI
import Foundation
class ActivityCoordinator: CoordinatorProtocol {
let items: [Any]
init(items: [Any]) {
self.items = items
}
func toPresentable() -> AnyView {
AnyView(UIActivityViewControllerWrapper(activityItems: items)
.presentationDetents([.medium])
.ignoresSafeArea())
}
enum MediaPickerPreviewScreenViewModelAction {
case send
case cancel
}
struct MediaPickerPreviewScreenViewState: BindableState {
let url: URL
let title: String?
}
enum MediaPickerPreviewScreenViewAction {
case send
case cancel
}

View File

@ -0,0 +1,36 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
typealias MediaPickerPreviewScreenViewModelType = StateStoreViewModel<MediaPickerPreviewScreenViewState, MediaPickerPreviewScreenViewAction>
class MediaPickerPreviewScreenViewModel: MediaPickerPreviewScreenViewModelType, MediaPickerPreviewScreenViewModelProtocol {
var callback: ((MediaPickerPreviewScreenViewModelAction) -> Void)?
init(url: URL, title: String?) {
super.init(initialViewState: MediaPickerPreviewScreenViewState(url: url, title: title))
}
override func process(viewAction: MediaPickerPreviewScreenViewAction) async {
switch viewAction {
case .send:
callback?(.send)
case .cancel:
callback?(.cancel)
}
}
}

View File

@ -0,0 +1,23 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
@MainActor
protocol MediaPickerPreviewScreenViewModelProtocol {
var callback: ((MediaPickerPreviewScreenViewModelAction) -> Void)? { get set }
var context: MediaPickerPreviewScreenViewModelType.Context { get }
}

View File

@ -0,0 +1,110 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import QuickLook
import SwiftUI
struct MediaPickerPreviewScreenScreen: View {
@ObservedObject var context: MediaPickerPreviewScreenViewModel.Context
var body: some View {
NavigationStack {
PreviewView(context: context,
fileURL: context.viewState.url,
title: context.viewState.title)
.id(UUID())
.ignoresSafeArea(edges: .bottom)
.toolbar { toolbar }
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button { context.send(viewAction: .cancel) } label: {
Text(L10n.actionCancel)
}
}
ToolbarItem(placement: .confirmationAction) {
Button { context.send(viewAction: .send) } label: {
Text(L10n.actionSend)
}
}
}
}
private struct PreviewView: UIViewControllerRepresentable {
let context: MediaPickerPreviewScreenViewModel.Context
let fileURL: URL
let title: String?
func makeUIViewController(context: Context) -> UINavigationController {
let previewController = QLPreviewController()
previewController.dataSource = context.coordinator
previewController.delegate = context.coordinator
return UINavigationController(rootViewController: previewController)
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { }
func makeCoordinator() -> Coordinator {
Coordinator(view: self)
}
class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
let view: PreviewView
init(view: PreviewView) {
self.view = view
}
// MARK: - QLPreviewControllerDataSource
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
PreviewItem(previewItemURL: view.fileURL, previewItemTitle: view.title)
}
// MARK: - QLPreviewControllerDelegate
func previewController(_ controller: QLPreviewController, editingModeFor previewItem: QLPreviewItem) -> QLPreviewItemEditingMode {
.disabled
}
}
}
private class PreviewItem: NSObject, QLPreviewItem {
var previewItemURL: URL?
var previewItemTitle: String?
init(previewItemURL: URL?, previewItemTitle: String?) {
self.previewItemURL = previewItemURL
self.previewItemTitle = previewItemTitle
}
}
// MARK: - Previews
struct MediaPickerPreviewScreen_Previews: PreviewProvider {
static let viewModel = MediaPickerPreviewScreenViewModel(url: URL.picturesDirectory, title: nil)
static var previews: some View {
MediaPickerPreviewScreenScreen(context: viewModel.context)
}
}

View File

@ -62,6 +62,12 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
self.displayEmojiPickerScreen(for: itemId)
case .displayReportContent(let itemId):
self.displayReportContent(for: itemId)
case .displayCameraPicker:
self.displayMediaPickerWithSource(.camera)
case .displayMediaPicker:
self.displayMediaPickerWithSource(.photoLibrary)
case .displayDocumentPicker:
self.displayMediaPickerWithSource(.documents)
}
}
}
@ -74,8 +80,32 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
func toPresentable() -> AnyView {
AnyView(RoomScreen(context: viewModel.context))
}
// MARK: - Private
private func displayMediaPickerWithSource(_ source: MediaPickerSource) {
let mediaPickerCoordinator = MediaPickerCoordinator(source: source) { [weak self] action in
switch action {
case .cancel:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
case .error:
break
case .selectMediaAtURL(let url):
let mediaPickerPreviewScreenCoordinator = MediaPickerPreviewScreenCoordinator(parameters: .init(url: url, title: url.lastPathComponent)) { action in
switch action {
case .send:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
case .cancel:
self?.navigationStackCoordinator.setSheetCoordinator(nil)
}
}
self?.navigationStackCoordinator.setSheetCoordinator(mediaPickerPreviewScreenCoordinator)
}
}
navigationStackCoordinator.setSheetCoordinator(mediaPickerCoordinator)
}
private func displayFilePreview(for file: MediaFileHandleProxy, with title: String?) {
let params = FilePreviewCoordinatorParameters(mediaFile: file, title: title)

View File

@ -23,6 +23,9 @@ enum RoomScreenViewModelAction {
case displayMediaFile(file: MediaFileHandleProxy, title: String?)
case displayEmojiPicker(itemId: String)
case displayReportContent(itemId: String)
case displayCameraPicker
case displayMediaPicker
case displayDocumentPicker
}
enum RoomScreenComposerMode: Equatable {
@ -55,6 +58,10 @@ enum RoomScreenViewAction {
/// Mark the entire room as read - this is heavy handed as a starting point for now.
case markRoomAsRead
case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction)
case displayCameraPicker
case displayMediaPicker
case displayDocumentPicker
}
struct RoomScreenViewState: BindableState {
@ -66,6 +73,7 @@ struct RoomScreenViewState: BindableState {
var isBackPaginating = false
var showLoading = false
var timelineStyle: TimelineStyle
var mediaUploadingFlowEnabled: Bool
var bindings: RoomScreenViewStateBindings

View File

@ -39,6 +39,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
roomTitle: roomName ?? "Unknown room 💥",
roomAvatarURL: roomAvatarUrl,
timelineStyle: ServiceLocator.shared.settings.timelineStyle,
mediaUploadingFlowEnabled: ServiceLocator.shared.settings.mediaUploadingFlowEnabled,
bindings: .init(composerText: "", composerFocused: false)),
imageProvider: mediaProvider)
@ -74,6 +75,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
.weakAssign(to: \.state.timelineStyle, on: self)
.store(in: &cancellables)
ServiceLocator.shared.settings.$mediaUploadingFlowEnabled
.weakAssign(to: \.state.mediaUploadingFlowEnabled, on: self)
.store(in: &cancellables)
buildTimelineViews()
}
@ -111,6 +116,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
await markRoomAsRead()
case .contextMenuAction(let itemID, let action):
processContentMenuAction(action, itemID: itemID)
case .displayCameraPicker:
callback?(.displayCameraPicker)
case .displayMediaPicker:
callback?(.displayMediaPicker)
case .displayDocumentPicker:
callback?(.displayDocumentPicker)
}
}

View File

@ -23,7 +23,16 @@ struct RoomScreen: View {
var body: some View {
timeline
.background(Color.element.background.ignoresSafeArea()) // Kills the toolbar translucency.
.safeAreaInset(edge: .bottom, spacing: 0) { messageComposer }
.safeAreaInset(edge: .bottom, spacing: 0) {
HStack(spacing: 4.0) {
if context.viewState.mediaUploadingFlowEnabled {
sendAttachmentButton
}
messageComposer
}
.padding()
}
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.toolbarRole(.editor) // Hide the back button title.
@ -40,7 +49,7 @@ struct RoomScreen: View {
}
}
var timeline: some View {
private var timeline: some View {
TimelineView()
.id(context.viewState.roomId)
.environmentObject(context)
@ -48,7 +57,7 @@ struct RoomScreen: View {
.overlay(alignment: .bottomTrailing) { scrollToBottomButton }
}
var messageComposer: some View {
private var messageComposer: some View {
MessageComposer(text: $context.composerText,
focused: $context.composerFocused,
sendingDisabled: context.viewState.sendButtonDisabled,
@ -59,10 +68,9 @@ struct RoomScreen: View {
} editCancellationAction: {
context.send(viewAction: .cancelEdit)
}
.padding()
}
var scrollToBottomButton: some View {
private var scrollToBottomButton: some View {
Button { context.viewState.scrollToBottomPublisher.send(()) } label: {
Image(systemName: "chevron.down")
.font(.element.body)
@ -84,7 +92,7 @@ struct RoomScreen: View {
}
@ViewBuilder
var loadingIndicator: some View {
private var loadingIndicator: some View {
if context.viewState.showLoading {
ProgressView()
.progressViewStyle(.circular)
@ -96,7 +104,7 @@ struct RoomScreen: View {
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
private var toolbar: some ToolbarContent {
// .principal + .primaryAction works better than .navigation leading + trailing
// as the latter disables interaction in the action button for rooms with long names
ToolbarItem(placement: .principal) {
@ -104,6 +112,30 @@ struct RoomScreen: View {
}
}
private var sendAttachmentButton: some View {
Menu {
Button {
context.send(viewAction: .displayDocumentPicker)
} label: {
Label(UntranslatedL10n.mediaUploadDocumentPicker, systemImage: "doc")
}
Button {
context.send(viewAction: .displayMediaPicker)
} label: {
Label(UntranslatedL10n.mediaUploadPhotoAndVideoPicker, systemImage: "photo")
}
Button {
context.send(viewAction: .displayCameraPicker)
} label: {
Label(UntranslatedL10n.mediaUploadCameraPicker, systemImage: "camera")
}
} label: {
Image(systemName: "plus.circle")
.font(.element.title1)
.foregroundColor(.element.brand)
}
}
private func sendMessage() {
guard !context.viewState.sendButtonDisabled else { return }
context.send(viewAction: .sendMessage)

View File

@ -189,15 +189,15 @@ class RoomProxy: RoomProxyProtocol {
let transactionId = genTransactionId()
return await Task.dispatch(on: userInitiatedDispatchQueue) {
if let eventID {
do {
do {
if let eventID {
try self.room.sendReply(msg: message, inReplyToEventId: eventID, txnId: transactionId)
} catch {
return .failure(.failedSendingMessage)
} else {
let messageContent = messageEventContentFromMarkdown(md: message)
try self.room.send(msg: messageContent, txnId: transactionId)
}
} else {
let messageContent = messageEventContentFromMarkdown(md: message)
self.room.send(msg: messageContent, txnId: transactionId)
} catch {
return .failure(.failedSendingMessage)
}
return .success(())
}
@ -218,6 +218,10 @@ class RoomProxy: RoomProxyProtocol {
}
}
}
func sendImage(body: String, url: URL) async -> Result<Void, RoomProxyError> {
.failure(.failedSendingMedia)
}
func editMessage(_ newMessage: String, original eventID: String) async -> Result<Void, RoomProxyError> {
sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)

View File

@ -26,6 +26,7 @@ enum RoomProxyError: Error {
case failedSendingReadReceipt
case failedSendingMessage
case failedSendingReaction
case failedSendingMedia
case failedEditingMessage
case failedRedactingEvent
case failedReportingContent
@ -69,6 +70,8 @@ protocol RoomProxyProtocol {
func sendMessage(_ message: String, inReplyTo eventID: String?) async -> Result<Void, RoomProxyError>
func sendReaction(_ reaction: String, to eventID: String) async -> Result<Void, RoomProxyError>
func sendImage(body: String, url: URL) async -> Result<Void, RoomProxyError>
func editMessage(_ newMessage: String, original eventID: String) async -> Result<Void, RoomProxyError>

View File

@ -22,6 +22,10 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSCameraUsageDescription</key>
<string>The camera is used to take and upload photos and videos.</string>
<key>NSMicrophoneUsageDescription</key>
<string>The microphone is used to take videos.</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>

View File

@ -64,6 +64,9 @@ targets:
NSUserActivityTypes: [
INSendMessageIntent
]
NSCameraUsageDescription: The camera is used to take and upload photos and videos.
NSMicrophoneUsageDescription: The microphone is used to take videos.
settings:
base:

View File

@ -0,0 +1,20 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import ElementX
import XCTest
class MediaPickerPreviewScreenScreenUITests: XCTestCase { }

View File

@ -0,0 +1,22 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
@testable import ElementX
@MainActor
class MediaPickerPreviewScreenScreenViewModelTests: XCTestCase { }