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
@@ -2,4 +2,4 @@
"NSFaceIDUsageDescription" = "Face ID is used to access your app.";
"NSLocationWhenInUseUsageDescription" = "Grant location access so that Element X can share your location.";
"NSMicrophoneUsageDescription" = "To record and send messages with audio, Element X needs to access the microphone.";
"NSPhotoLibraryUsageDescription" = "Allows saving photos and videos to your library.";
"NSPhotoLibraryUsageDescription" = "This lets you save images and videos to your photo library.";
@@ -274,6 +274,7 @@
"dialog_permission_microphone_description_ios" = "Grant access so you can record and send messages with audio.";
"dialog_permission_microphone_title_ios" = "%1$@ needs permission to access your microphone.";
"dialog_permission_notification" = "In order to let the application display notifications, please grant the permission in the system settings.";
"dialog_permission_photo_library_title_ios" = "%1$@ does not have access to your photo library.";
"dialog_title_confirmation" = "Confirmation";
"dialog_title_warning" = "Warning";
"dialog_unsaved_changes_description_ios" = "Your changes won’t be saved";
@@ -109,7 +109,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
private func presentMediaPreview(for previewContext: TimelineMediaPreviewContext) {
let parameters = TimelineMediaPreviewCoordinatorParameters(context: previewContext,
mediaProvider: userSession.mediaProvider,
userIndicatorController: userIndicatorController)
userIndicatorController: userIndicatorController,
appMediator: appMediator)
let coordinator = TimelineMediaPreviewCoordinator(parameters: parameters)
coordinator.actionsPublisher
+4
View File
@@ -606,6 +606,10 @@ internal enum L10n {
}
/// In order to let the application display notifications, please grant the permission in the system settings.
internal static var dialogPermissionNotification: String { return L10n.tr("Localizable", "dialog_permission_notification") }
/// %1$@ does not have access to your photo library.
internal static func dialogPermissionPhotoLibraryTitleIos(_ p1: Any) -> String {
return L10n.tr("Localizable", "dialog_permission_photo_library_title_ios", String(describing: p1))
}
/// Confirmation
internal static var dialogTitleConfirmation: String { return L10n.tr("Localizable", "dialog_title_confirmation") }
/// Error
@@ -8,6 +8,7 @@ import Combine
import Foundation
import LocalAuthentication
import MatrixRustSDK
import Photos
import SwiftUI
class AnalyticsClientMock: AnalyticsClientProtocol {
var isRunning: Bool {
@@ -12327,6 +12328,79 @@ class PHGPostHogMock: PHGPostHogProtocol {
screenPropertiesClosure?(screenTitle, properties)
}
}
class PhotoLibraryManagerMock: PhotoLibraryManagerProtocol {
//MARK: - addResource
var addResourceAtUnderlyingCallsCount = 0
var addResourceAtCallsCount: Int {
get {
if Thread.isMainThread {
return addResourceAtUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = addResourceAtUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
addResourceAtUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
addResourceAtUnderlyingCallsCount = newValue
}
}
}
}
var addResourceAtCalled: Bool {
return addResourceAtCallsCount > 0
}
var addResourceAtReceivedArguments: (type: PHAssetResourceType, url: URL)?
var addResourceAtReceivedInvocations: [(type: PHAssetResourceType, url: URL)] = []
var addResourceAtUnderlyingReturnValue: Result<Void, PhotoLibraryManagerError>!
var addResourceAtReturnValue: Result<Void, PhotoLibraryManagerError>! {
get {
if Thread.isMainThread {
return addResourceAtUnderlyingReturnValue
} else {
var returnValue: Result<Void, PhotoLibraryManagerError>? = nil
DispatchQueue.main.sync {
returnValue = addResourceAtUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
addResourceAtUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
addResourceAtUnderlyingReturnValue = newValue
}
}
}
}
var addResourceAtClosure: ((PHAssetResourceType, URL) async -> Result<Void, PhotoLibraryManagerError>)?
func addResource(_ type: PHAssetResourceType, at url: URL) async -> Result<Void, PhotoLibraryManagerError> {
addResourceAtCallsCount += 1
addResourceAtReceivedArguments = (type: type, url: url)
DispatchQueue.main.async {
self.addResourceAtReceivedInvocations.append((type: type, url: url))
}
if let addResourceAtClosure = addResourceAtClosure {
return await addResourceAtClosure(type, url)
} else {
return addResourceAtReturnValue
}
}
}
class PollInteractionHandlerMock: PollInteractionHandlerProtocol {
//MARK: - sendPollResponse
@@ -0,0 +1,21 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
extension PhotoLibraryManagerMock {
struct Configuration {
var authorizationDenied = false
}
// swiftlint:disable:next cyclomatic_complexity
convenience init(_ configuration: Configuration) {
self.init()
addResourceAtReturnValue = configuration.authorizationDenied ? .failure(PhotoLibraryManagerError.notAuthorized) : .success(())
}
}
@@ -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())
}
}
@@ -16,7 +16,7 @@ struct CallInviteRoomTimelineView: View {
Label {
Text(L10n.screenRoomTimelineLegacyCall)
} icon: {
CompoundIcon(\.voiceCall, size: .medium, relativeTo: .compound.bodyMD)
CompoundIcon(\.voiceCallSolid, size: .medium, relativeTo: .compound.bodyMD)
}
.font(.compound.bodyMD)
.foregroundColor(.compound.textSecondary)
+1 -1
View File
@@ -73,7 +73,7 @@
<key>NSMicrophoneUsageDescription</key>
<string>To record and send messages with audio, $(APP_DISPLAY_NAME) needs to access the microphone.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Allows saving photos and videos to your library.</string>
<string>This lets you save images and videos to your photo library.</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
+1 -1
View File
@@ -87,7 +87,7 @@ targets:
]
NSCameraUsageDescription: To take pictures or videos and send them as a message $(APP_DISPLAY_NAME) needs access to the camera.
NSMicrophoneUsageDescription: To record and send messages with audio, $(APP_DISPLAY_NAME) needs to access the microphone.
NSPhotoLibraryAddUsageDescription: Allows saving photos and videos to your library.
NSPhotoLibraryAddUsageDescription: This lets you save images and videos to your photo library.
NSLocationWhenInUseUsageDescription: Grant location access so that $(APP_DISPLAY_NAME) can share your location.
NSFaceIDUsageDescription: Face ID is used to access your app.
UIBackgroundModes: [