mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
- 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:
parent
2359910857
commit
5f6edacc1a
@ -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";
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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") }
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
81
ElementX/Sources/Screens/MediaPicker/CameraPicker.swift
Normal file
81
ElementX/Sources/Screens/MediaPicker/CameraPicker.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
69
ElementX/Sources/Screens/MediaPicker/DocumentPicker.swift
Normal file
69
ElementX/Sources/Screens/MediaPicker/DocumentPicker.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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 }
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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:
|
||||
|
20
UITests/Sources/MediaPickerPreviewScreenScreenUITests.swift
Normal file
20
UITests/Sources/MediaPickerPreviewScreenScreenUITests.swift
Normal 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 { }
|
@ -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 { }
|
Loading…
x
Reference in New Issue
Block a user