mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Implement the save action when previewing media. (#3630)
* Implement the save action on the media preview. * Update Compound and use the correct icon. Also fixes an icon that has been renamed. * Update the add to photo library usage description to match the designs. * PR comments.
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Photos
|
||||
|
||||
enum PhotoLibraryManagerError: Error {
|
||||
case notAuthorized
|
||||
case unknown(Error)
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol PhotoLibraryManagerProtocol {
|
||||
func addResource(_ type: PHAssetResourceType, at url: URL) async -> Result<Void, PhotoLibraryManagerError>
|
||||
}
|
||||
|
||||
struct PhotoLibraryManager: PhotoLibraryManagerProtocol {
|
||||
func addResource(_ type: PHAssetResourceType, at url: URL) async -> Result<Void, PhotoLibraryManagerError> {
|
||||
do {
|
||||
try await PHPhotoLibrary.shared().performChanges {
|
||||
let request = PHAssetCreationRequest.forAsset()
|
||||
let options = PHAssetResourceCreationOptions()
|
||||
request.addResource(with: type, fileURL: url, options: options)
|
||||
}
|
||||
return .success(())
|
||||
} catch {
|
||||
if (error as NSError).code == PHPhotosError.accessUserDenied.rawValue {
|
||||
return .failure(.notAuthorized)
|
||||
} else {
|
||||
return .failure(.unknown(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ struct TimelineMediaPreviewCoordinatorParameters {
|
||||
let context: TimelineMediaPreviewContext
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let appMediator: AppMediatorProtocol
|
||||
}
|
||||
|
||||
enum TimelineMediaPreviewCoordinatorAction {
|
||||
@@ -50,7 +51,9 @@ final class TimelineMediaPreviewCoordinator: CoordinatorProtocol {
|
||||
|
||||
viewModel = TimelineMediaPreviewViewModel(context: parameters.context,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
photoLibraryManager: PhotoLibraryManager(),
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
appMediator: parameters.appMediator)
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -69,6 +72,6 @@ final class TimelineMediaPreviewCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(TimelineMediaPreviewView(context: viewModel.context))
|
||||
AnyView(TimelineMediaPreviewScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,18 @@ struct TimelineMediaPreviewViewState: BindableState {
|
||||
}
|
||||
|
||||
struct TimelineMediaPreviewViewStateBindings {
|
||||
/// A binding that will present the Details view for the specified item.
|
||||
var mediaDetailsItem: TimelineMediaPreviewItem?
|
||||
/// A binding that will present a confirmation to redact the specified item.
|
||||
var redactConfirmationItem: TimelineMediaPreviewItem?
|
||||
/// A binding that will present a document picker to export the specified file.
|
||||
var fileToExport: TimelineMediaPreviewFileExportPicker.File?
|
||||
|
||||
var alertInfo: AlertInfo<TimelineMediaPreviewAlertType>?
|
||||
}
|
||||
|
||||
enum TimelineMediaPreviewAlertType {
|
||||
case authorizationRequired
|
||||
}
|
||||
|
||||
/// Wraps a media file and title to be previewed with QuickLook.
|
||||
|
||||
@@ -13,7 +13,9 @@ typealias TimelineMediaPreviewViewModelType = StateStoreViewModel<TimelineMediaP
|
||||
class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
private let timelineViewModel: TimelineViewModelProtocol
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private let photoLibraryManager: PhotoLibraryManagerProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let appMediator: AppMediatorProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<TimelineMediaPreviewViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<TimelineMediaPreviewViewModelAction, Never> {
|
||||
@@ -22,12 +24,14 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
|
||||
init(context: TimelineMediaPreviewContext,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
photoLibraryManager: PhotoLibraryManagerProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
appMediator: AppMediatorProtocol) {
|
||||
timelineViewModel = context.viewModel
|
||||
self.mediaProvider = mediaProvider
|
||||
|
||||
// We might not want to inject this, instead creating a new instance with a custom position and colour scheme 🤔
|
||||
self.photoLibraryManager = photoLibraryManager
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appMediator = appMediator
|
||||
|
||||
let currentItem = TimelineMediaPreviewItem(timelineItem: context.item)
|
||||
|
||||
@@ -64,10 +68,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
MXLog.error("Received unexpected action: \(action)")
|
||||
}
|
||||
case .redactConfirmation(let item):
|
||||
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact))
|
||||
state.bindings.redactConfirmationItem = nil
|
||||
state.bindings.mediaDetailsItem = nil
|
||||
actionsSubject.send(.dismiss)
|
||||
redactItem(item)
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
}
|
||||
@@ -108,12 +109,43 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
}
|
||||
|
||||
private func saveCurrentItem() async {
|
||||
guard let url = state.currentItem.fileHandle?.url else {
|
||||
guard let fileURL = state.currentItem.fileHandle?.url else {
|
||||
MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.")
|
||||
return
|
||||
}
|
||||
|
||||
showErrorIndicator()
|
||||
do {
|
||||
switch state.currentItem.timelineItem {
|
||||
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
|
||||
state.bindings.fileToExport = .init(url: fileURL)
|
||||
return // Don't show the indicator.
|
||||
case is ImageRoomTimelineItem:
|
||||
try await photoLibraryManager.addResource(.photo, at: fileURL).get()
|
||||
case is VideoRoomTimelineItem:
|
||||
try await photoLibraryManager.addResource(.video, at: fileURL).get()
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
showSavedIndicator()
|
||||
} catch PhotoLibraryManagerError.notAuthorized {
|
||||
MXLog.error("Not authorised to save item to photo library")
|
||||
state.bindings.alertInfo = .init(id: .authorizationRequired,
|
||||
title: L10n.dialogPermissionPhotoLibraryTitleIos(InfoPlistReader.main.bundleDisplayName),
|
||||
primaryButton: .init(title: L10n.commonSettings) { self.appMediator.openAppSettings() },
|
||||
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
|
||||
} catch {
|
||||
MXLog.error("Failed saving item: \(error)")
|
||||
showErrorIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
private func redactItem(_ item: TimelineMediaPreviewItem) {
|
||||
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact))
|
||||
state.bindings.redactConfirmationItem = nil
|
||||
state.bindings.mediaDetailsItem = nil
|
||||
actionsSubject.send(.dismiss)
|
||||
showRedactedIndicator()
|
||||
}
|
||||
|
||||
// MARK: - Indicators
|
||||
@@ -132,22 +164,38 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
userIndicatorController.retractIndicatorWithId(indicatorID)
|
||||
}
|
||||
|
||||
// FIXME: Add the strings and correct indicator types
|
||||
private func showDownloadErrorIndicator() {
|
||||
// FIXME: Add the correct string and indicator type??
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: downloadErrorIndicatorID,
|
||||
type: .modal,
|
||||
title: L10n.errorUnknown,
|
||||
iconName: "exclamationmark.circle.fill"))
|
||||
}
|
||||
|
||||
private func showRedactedIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID,
|
||||
type: .toast,
|
||||
title: "File deleted",
|
||||
iconName: "checkmark"))
|
||||
}
|
||||
|
||||
private func showSavedIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID,
|
||||
type: .toast,
|
||||
title: "File saved",
|
||||
iconName: "checkmark"))
|
||||
}
|
||||
|
||||
private func showErrorIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: errorIndicatorID,
|
||||
type: .modal,
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID,
|
||||
type: .toast,
|
||||
title: L10n.errorUnknown,
|
||||
iconName: "xmark"))
|
||||
}
|
||||
|
||||
private var errorIndicatorID: String { "\(Self.self)-Error" }
|
||||
private var statusIndicatorID: String { "\(Self.self)-Status" }
|
||||
|
||||
// Separate indicator IDs for downloads as these can be triggered in the background when swiping between items
|
||||
private var downloadErrorIndicatorID: String { "\(Self.self)-DownloadError" }
|
||||
private func makeDownloadIndicatorID(itemID: TimelineItemIdentifier) -> String {
|
||||
"\(Self.self)-Download-\(itemID.uniqueID.id)"
|
||||
|
||||
+3
-1
@@ -184,6 +184,8 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
|
||||
viewModel: TimelineViewModel.mock(timelineKind: timelineKind),
|
||||
namespace: previewNamespace),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
photoLibraryManager: PhotoLibraryManagerMock(.init()),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appMediator: AppMediatorMock())
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineMediaPreviewFileExportPicker: UIViewControllerRepresentable {
|
||||
struct File: Identifiable {
|
||||
let url: URL
|
||||
var id: String { url.absoluteString }
|
||||
}
|
||||
|
||||
let file: File
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
UIDocumentPickerViewController(forExporting: [file.url], asCopy: true)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { }
|
||||
}
|
||||
+3
-1
@@ -142,6 +142,8 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
|
||||
viewModel: TimelineViewModel.mock,
|
||||
namespace: previewNamespace),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
photoLibraryManager: PhotoLibraryManagerMock(.init()),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appMediator: AppMediatorMock())
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -10,7 +10,7 @@ import Compound
|
||||
import QuickLook
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineMediaPreviewView: View {
|
||||
struct TimelineMediaPreviewScreen: View {
|
||||
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
|
||||
|
||||
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
|
||||
@@ -33,6 +33,11 @@ struct TimelineMediaPreviewView: View {
|
||||
.sheet(item: $context.mediaDetailsItem) { item in
|
||||
TimelineMediaPreviewDetailsView(item: item, context: context)
|
||||
}
|
||||
.sheet(item: $context.fileToExport) { file in
|
||||
TimelineMediaPreviewFileExportPicker(file: file)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.alert(item: $context.alertInfo)
|
||||
.preferredColorScheme(.dark)
|
||||
.zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace)
|
||||
}
|
||||
@@ -102,7 +107,7 @@ struct TimelineMediaPreviewView: View {
|
||||
Spacer()
|
||||
|
||||
Button { context.send(viewAction: .saveCurrentItem) } label: {
|
||||
CompoundIcon(\.download)
|
||||
CompoundIcon(\.downloadIos)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,13 +184,13 @@ private struct QuickLookView: UIViewControllerRepresentable {
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct TimelineMediaPreviewView_Previews: PreviewProvider {
|
||||
struct TimelineMediaPreviewScreen_Previews: PreviewProvider {
|
||||
@Namespace private static var namespace
|
||||
|
||||
static let viewModel = makeViewModel()
|
||||
|
||||
static var previews: some View {
|
||||
QuickLookView(viewModelContext: viewModel.context)
|
||||
TimelineMediaPreviewScreen(context: viewModel.context)
|
||||
}
|
||||
|
||||
static func makeViewModel() -> TimelineMediaPreviewViewModel {
|
||||
@@ -207,6 +212,8 @@ struct TimelineMediaPreviewView_Previews: PreviewProvider {
|
||||
viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen)),
|
||||
namespace: namespace),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
photoLibraryManager: PhotoLibraryManagerMock(.init()),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appMediator: AppMediatorMock())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user