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:
Doug
2024-12-17 16:35:51 +00:00
committed by GitHub
parent 2b82b94500
commit 2a865ce5bf
23 changed files with 403 additions and 68 deletions
@@ -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)"
@@ -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())
}
}
@@ -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) { }
}
@@ -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())
}
}
@@ -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())
}
}