Skip to content

Commit

Permalink
Implement the save action when previewing media. (#3630)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
pixlwave authored Dec 17, 2024
1 parent 2b82b94 commit 2a865ce
Show file tree
Hide file tree
Showing 23 changed files with 403 additions and 68 deletions.
26 changes: 17 additions & 9 deletions ElementX.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/element-hq/compound-design-tokens",
"state" : {
"revision" : "f79e05011ec3402c29ded19bcff95b5ead180991",
"version" : "2.1.2"
"revision" : "a6e96fb4436a4945423a8c068001093af4b7b315",
"version" : "3.0.1"
}
},
{
"identity" : "compound-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/element-hq/compound-ios",
"state" : {
"revision" : "1a70bc7f3420647843b9c18748982c61ef7d2245"
"revision" : "9325643cb4d22150881c5bf79e1e6e3c5a87ea89"
}
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions ElementX/Sources/Generated/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions ElementX/Sources/Mocks/Generated/GeneratedMocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Combine
import Foundation
import LocalAuthentication
import MatrixRustSDK
import Photos
import SwiftUI
class AnalyticsClientMock: AnalyticsClientProtocol {
var isRunning: Bool {
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions ElementX/Sources/Mocks/PhotoLibraryManagerMock.swift
Original file line number Diff line number Diff line change
@@ -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(())
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct TimelineMediaPreviewCoordinatorParameters {
let context: TimelineMediaPreviewContext
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
let appMediator: AppMediatorProtocol
}

enum TimelineMediaPreviewCoordinatorAction {
Expand All @@ -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() {
Expand All @@ -69,6 +72,6 @@ final class TimelineMediaPreviewCoordinator: CoordinatorProtocol {
}

func toPresentable() -> AnyView {
AnyView(TimelineMediaPreviewView(context: viewModel.context))
AnyView(TimelineMediaPreviewScreen(context: viewModel.context))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand All @@ -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)

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Loading

0 comments on commit 2a865ce

Please sign in to comment.