Update the timeline media QuickLook modifier. (#3593)

Not hooked up to any flows yet.
This commit is contained in:
Doug
2024-12-09 15:23:12 +00:00
committed by GitHub
parent 7254b6e029
commit b5605a52e3
31 changed files with 1052 additions and 20 deletions
+6
View File
@@ -322,6 +322,8 @@ internal enum L10n {
internal static var commonDeviceId: String { return L10n.tr("Localizable", "common_device_id") }
/// Direct chat
internal static var commonDirectChat: String { return L10n.tr("Localizable", "common_direct_chat") }
/// Downloading
internal static var commonDownloading: String { return L10n.tr("Localizable", "common_downloading") }
/// (edited)
internal static var commonEditedSuffix: String { return L10n.tr("Localizable", "common_edited_suffix") }
/// Editing
@@ -1378,6 +1380,10 @@ internal enum L10n {
internal static var screenMediaDetailsFileFormat: String { return L10n.tr("Localizable", "screen_media_details_file_format") }
/// File name
internal static var screenMediaDetailsFilename: String { return L10n.tr("Localizable", "screen_media_details_filename") }
/// This file will be removed from the room and members won’t have access to it.
internal static var screenMediaDetailsRedactConfirmationMessage: String { return L10n.tr("Localizable", "screen_media_details_redact_confirmation_message") }
/// Delete file?
internal static var screenMediaDetailsRedactConfirmationTitle: String { return L10n.tr("Localizable", "screen_media_details_redact_confirmation_title") }
/// Uploaded by
internal static var screenMediaDetailsUploadedBy: String { return L10n.tr("Localizable", "screen_media_details_uploaded_by") }
/// Uploaded on
@@ -61,7 +61,13 @@ extension MediaProviderMock {
return .success(data)
}
loadFileFromSourceFilenameReturnValue = .failure(.failedRetrievingFile)
loadFileFromSourceFilenameClosure = { _, _ in
guard let url = Bundle.main.url(forResource: "preview_image", withExtension: "jpg") else {
return .failure(.failedRetrievingFile)
}
return .success(.unmanaged(url: url))
}
loadImageRetryingOnReconnectionSizeClosure = { _, _ in
Task {
+3
View File
@@ -75,6 +75,7 @@ enum UserAvatarSizeOnScreen {
case knockingUsersBannerStack
case knockingUserBanner
case knockingUserList
case mediaPreviewDetails
var value: CGFloat {
switch self {
@@ -110,6 +111,8 @@ enum UserAvatarSizeOnScreen {
return 32
case .knockingUserList:
return 52
case .mediaPreviewDetails:
return 32
}
}
}
+1 -1
View File
@@ -40,6 +40,6 @@ extension Date {
/// A fixed date used for mocks, previews etc.
static var mock: Date {
Calendar.current.startOfDay(for: .now).addingTimeInterval((9 * 60 * 60) + (41 * 60)) // 9:41 am
DateComponents(calendar: .current, year: 2007, month: 1, day: 9, hour: 9, minute: 41).date ?? .now
}
}
@@ -0,0 +1,149 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import Compound
import QuickLook
import SwiftUI
class TimelineMediaPreviewController: QLPreviewController, QLPreviewControllerDataSource {
private let viewModel: TimelineMediaPreviewViewModel
private var cancellables: Set<AnyCancellable> = []
private let headerHostingController: UIHostingController<HeaderView>
private let captionHostingController: UIHostingController<CaptionView>
private let detailsHostingController: UIHostingController<TimelineMediaPreviewDetailsView>
private var navigationBar: UINavigationBar? { view.subviews.first?.subviews.first { $0 is UINavigationBar } as? UINavigationBar }
private var toolbar: UIToolbar? { view.subviews.first?.subviews.last { $0 is UIToolbar } as? UIToolbar }
private var captionView: UIView { captionHostingController.view }
init(viewModel: TimelineMediaPreviewViewModel) {
self.viewModel = viewModel
headerHostingController = UIHostingController(rootView: HeaderView(context: viewModel.context))
headerHostingController.view.backgroundColor = .clear
captionHostingController = UIHostingController(rootView: CaptionView(context: viewModel.context))
captionHostingController.view.backgroundColor = .clear
detailsHostingController = UIHostingController(rootView: TimelineMediaPreviewDetailsView(context: viewModel.context))
detailsHostingController.view.backgroundColor = .compound.bgCanvasDefault
// let materialView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
// captionHostingController.view.insertMatchedSubview(materialView, at: 0)
super.init(nibName: nil, bundle: nil)
view.addSubview(captionView)
// Observation of currentPreviewItem doesn't work, so use the index instead.
publisher(for: \.currentPreviewItemIndex)
.sink { [weak self] _ in
guard let self, let currentPreviewItem = currentPreviewItem as? TimelineMediaPreviewItem else { return }
Task { await self.viewModel.updateCurrentItem(currentPreviewItem) }
}
.store(in: &cancellables)
viewModel.actions
.sink { [weak self] action in
switch action {
case .loadedMediaFile:
self?.refreshCurrentPreviewItem()
case .viewInTimeline:
self?.dismiss(animated: true) // Dismiss the details sheet.
// Errrr, hmmmmm, do something else here.
}
}
.store(in: &cancellables)
dataSource = self
}
@available(*, unavailable) required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
overrideUserInterfaceStyle = .dark
if let toolbar {
captionView.isHidden = toolbar.alpha == 0
if captionView.constraints.isEmpty {
captionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
captionView.bottomAnchor.constraint(equalTo: toolbar.topAnchor),
captionView.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor),
captionView.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor)
])
}
}
navigationBar?.topItem?.titleView = headerHostingController.view
if navigationBar?.topItem?.rightBarButtonItems?.count == 1 {
navigationBar?.topItem?.rightBarButtonItems?.append(UIBarButtonItem(image: UIImage(systemSymbol: .infoCircle), style: .plain, target: self, action: #selector(presentMediaDetails)))
}
}
// MARK: QLPreviewControllerDataSource
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
viewModel.state.previewItems.count
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
viewModel.state.previewItems[index]
}
// MARK: Private
@objc func presentMediaDetails() {
detailsHostingController.overrideUserInterfaceStyle = .dark
detailsHostingController.sheetPresentationController?.detents = [.medium()]
detailsHostingController.sheetPresentationController?.prefersGrabberVisible = true
present(detailsHostingController, animated: true)
}
}
// MARK: - Subviews
private struct HeaderView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
var body: some View {
VStack(spacing: 0) {
Text(context.viewState.currentItem?.sender.displayName ?? context.viewState.currentItem?.sender.id ?? L10n.commonLoading)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
Text(context.viewState.currentItem?.timestamp.formatted(date: .abbreviated, time: .omitted) ?? "")
.font(.compound.bodyXS)
.foregroundStyle(.compound.textPrimary)
.textCase(.uppercase)
}
}
}
private struct CaptionView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
var body: some View {
if let caption = context.viewState.currentItem?.caption {
Text(caption)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
.lineLimit(5)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.padding(16)
.background(.ultraThinMaterial)
}
}
}
@@ -0,0 +1,162 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import QuickLook
enum TimelineMediaPreviewViewModelAction {
case loadedMediaFile
case viewInTimeline
}
struct TimelineMediaPreviewViewState: BindableState {
var previewItems: [TimelineMediaPreviewItem]
var currentItem: TimelineMediaPreviewItem?
}
/// Wraps a media file and title to be previewed with QuickLook.
class TimelineMediaPreviewItem: NSObject, QLPreviewItem {
private let timelineItem: EventBasedMessageTimelineItemProtocol
var fileHandle: MediaFileHandleProxy?
init(timelineItem: EventBasedMessageTimelineItemProtocol) {
self.timelineItem = timelineItem
}
var id: TimelineItemIdentifier { timelineItem.id }
// MARK: QLPreviewItem
var previewItemURL: URL? {
fileHandle?.url
}
var previewItemTitle: String? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.filename
case let fileItem as FileRoomTimelineItem:
fileItem.content.filename
case let imageItem as ImageRoomTimelineItem:
imageItem.content.filename
case let videoItem as VideoRoomTimelineItem:
videoItem.content.filename
default:
nil
}
}
// MARK: Event details
var sender: TimelineItemSender {
timelineItem.sender
}
var timestamp: Date {
timelineItem.timestamp
}
// MARK: Media details
var mediaSource: MediaSourceProxy? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.source
case let fileItem as FileRoomTimelineItem:
fileItem.content.source
case let imageItem as ImageRoomTimelineItem:
imageItem.content.imageInfo.source
case let videoItem as VideoRoomTimelineItem:
videoItem.content.videoInfo.source
default:
nil
}
}
var thumbnailMediaSource: MediaSourceProxy? {
switch timelineItem {
case let fileItem as FileRoomTimelineItem:
fileItem.content.thumbnailSource
case let imageItem as ImageRoomTimelineItem:
imageItem.content.thumbnailInfo?.source
case let videoItem as VideoRoomTimelineItem:
videoItem.content.thumbnailInfo?.source
default:
nil
}
}
var filename: String? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.filename
case let fileItem as FileRoomTimelineItem:
fileItem.content.filename
case let imageItem as ImageRoomTimelineItem:
imageItem.content.filename
case let videoItem as VideoRoomTimelineItem:
videoItem.content.filename
default:
nil
}
}
var fileSize: Double? {
previewItemURL.flatMap { try? FileManager.default.sizeForItem(at: $0) } ?? expectedFileSize
}
private var expectedFileSize: Double? {
let fileSize: UInt? = switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.fileSize
case let fileItem as FileRoomTimelineItem:
fileItem.content.fileSize
case let imageItem as ImageRoomTimelineItem:
imageItem.content.imageInfo.fileSize
case let videoItem as VideoRoomTimelineItem:
videoItem.content.videoInfo.fileSize
default:
nil
}
return fileSize.map(Double.init)
}
var caption: String? {
timelineItem.mediaCaption
}
var contentType: String? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.contentType?.localizedDescription
case let fileItem as FileRoomTimelineItem:
fileItem.content.contentType?.localizedDescription
case let imageItem as ImageRoomTimelineItem:
imageItem.content.contentType?.localizedDescription
case let videoItem as VideoRoomTimelineItem:
videoItem.content.contentType?.localizedDescription
default:
nil
}
}
var blurhash: String? {
switch timelineItem {
case let imageItem as ImageRoomTimelineItem:
imageItem.content.blurhash
case let videoItem as VideoRoomTimelineItem:
videoItem.content.blurhash
default:
nil
}
}
}
enum TimelineMediaPreviewViewAction {
case viewInTimeline
case redact
}
@@ -0,0 +1,75 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import Foundation
typealias TimelineMediaPreviewViewModelType = StateStoreViewModel<TimelineMediaPreviewViewState, TimelineMediaPreviewViewAction>
class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private let mediaProvider: MediaProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<TimelineMediaPreviewViewModelAction, Never> = .init()
var actions: AnyPublisher<TimelineMediaPreviewViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(previewItems: [EventBasedMessageTimelineItemProtocol], mediaProvider: MediaProviderProtocol, userIndicatorController: UserIndicatorControllerProtocol) {
self.mediaProvider = mediaProvider
// We might not want to inject this, instead creating a new instance with a custom position and colour scheme 🤔
self.userIndicatorController = userIndicatorController
super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: previewItems.map(TimelineMediaPreviewItem.init)), mediaProvider: mediaProvider)
}
override func process(viewAction: TimelineMediaPreviewViewAction) {
switch viewAction {
case .viewInTimeline:
actionsSubject.send(.viewInTimeline)
case .redact:
break // Do it here??
}
}
func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
state.currentItem = previewItem
if previewItem.fileHandle == nil, let source = previewItem.mediaSource {
showDownloadingIndicator(itemID: previewItem.id)
defer { hideDownloadingIndicator(itemID: previewItem.id) }
switch await mediaProvider.loadFileFromSource(source) {
case .success(let handle):
previewItem.fileHandle = handle
actionsSubject.send(.loadedMediaFile)
case .failure(let error):
MXLog.error("Failed loading media: \(error)")
#warning("Show the error!")
}
}
}
private func showDownloadingIndicator(itemID: TimelineItemIdentifier) {
let indicatorID = makeDownloadIndicatorID(itemID: itemID)
userIndicatorController.submitIndicator(UserIndicator(id: indicatorID,
type: .toast(progress: .indeterminate),
title: L10n.commonDownloading,
persistent: true),
delay: .seconds(0.1)) // Don't show the indicator when the SDK loads the file from the store.
}
private func hideDownloadingIndicator(itemID: TimelineItemIdentifier) {
let indicatorID = makeDownloadIndicatorID(itemID: itemID)
userIndicatorController.retractIndicatorWithId(indicatorID)
}
private func makeDownloadIndicatorID(itemID: TimelineItemIdentifier) -> String {
"\(TimelineMediaPreviewViewModel.self)-Download-\(itemID.uniqueID.id)"
}
}
@@ -0,0 +1,155 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import QuickLook
import SwiftUI
extension View {
/// Preview a media file using a QuickLook Preview Controller. The preview is interactive with
/// the dismiss gesture working as expected if it was presented from UIKit.
func timelineMediaQuickLook(viewModel: Binding<TimelineMediaPreviewViewModel?>) -> some View {
modifier(TimelineMediaQuickLookModifier(viewModel: viewModel))
}
}
private struct TimelineMediaQuickLookModifier: ViewModifier {
@Binding var viewModel: TimelineMediaPreviewViewModel?
@State private var dismissalPublisher = PassthroughSubject<Void, Never>()
func body(content: Content) -> some View {
content.background {
if let viewModel {
EmbeddedQuickLookPresenter(viewModel: viewModel, dismissalPublisher: dismissalPublisher) {
self.viewModel = nil
}
} else {
// Work around QLPreviewController dismissal issues, see below.
let _ = dismissalPublisher.send(())
}
}
}
}
/// When this view is put in the background of a SwiftUI view hierarchy,
/// it will present a QLPreviewController on top of the entire app.
private struct EmbeddedQuickLookPresenter: UIViewControllerRepresentable {
let viewModel: TimelineMediaPreviewViewModel
let dismissalPublisher: PassthroughSubject<Void, Never>
let onDismiss: () -> Void
func makeUIViewController(context: Context) -> PresentingController {
PresentingController(viewModel: viewModel, dismissalPublisher: dismissalPublisher, onDismiss: onDismiss)
}
func updateUIViewController(_ uiViewController: PresentingController, context: Context) { }
/// A view controller that hosts the QuickLook preview.
///
/// This wrapper somehow allows the preview controller to do presentation/dismissal
/// animations and interactions which don't work if you represent it directly to SwiftUI 🤷‍♂️
class PresentingController: UIViewController, QLPreviewControllerDelegate {
private let previewController: QLPreviewController
private let sourceView = UIView()
private var hasPresented = false
private let onDismiss: () -> Void
private var dismissalObserver: AnyCancellable?
init(viewModel: TimelineMediaPreviewViewModel,
dismissalPublisher: PassthroughSubject<Void, Never>,
onDismiss: @escaping () -> Void) {
previewController = TimelineMediaPreviewController(viewModel: viewModel)
self.onDismiss = onDismiss
super.init(nibName: nil, bundle: nil)
// The QLPreviewController will not automatically dismiss itself when the underlying view is removed
// (e.g. switching rooms from a notification) and it continues to hold on to the whole hierarcy.
// Manually tell it to dismiss itself here.
dismissalObserver = dismissalPublisher.sink { [weak self] _ in
// Dispatching on main.async with weak self we avoid doing an extra dismiss if the view is presented on top of another modal
DispatchQueue.main.async { [weak self] in
self?.dismiss(animated: true)
}
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
view.backgroundColor = .clear
view.addSubview(sourceView)
sourceView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
sourceView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
sourceView.centerYAnchor.constraint(equalTo: view.bottomAnchor),
sourceView.widthAnchor.constraint(equalToConstant: 200),
sourceView.heightAnchor.constraint(equalToConstant: 200)
])
}
// Don't use viewWillAppear due to the following warning:
// Presenting view controller <QLPreviewController> from detached view controller <HostingController> is not supported,
// and may result in incorrect safe area insets and a corrupt root presentation. Make sure <HostingController> is in
// the view controller hierarchy before presenting from it. Will become a hard exception in a future release.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !hasPresented else { return }
previewController.delegate = self
present(previewController, animated: true)
hasPresented = true
}
// MARK: QLPreviewControllerDelegate
func previewController(_ controller: QLPreviewController, transitionViewFor item: any QLPreviewItem) -> UIView? {
sourceView
}
func previewControllerDidDismiss(_ controller: QLPreviewController) {
onDismiss()
}
}
}
// MARK: - Previews
struct TimelineMediaQuickLook_Previews: PreviewProvider {
static let viewModel = makeViewModel()
static var previews: some View {
EmbeddedQuickLookPresenter(viewModel: viewModel, dismissalPublisher: .init()) { }
}
static func makeViewModel() -> TimelineMediaPreviewViewModel {
let previewItem = FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "", displayName: "Sally Sanderson"),
content: .init(filename: "Important document.pdf",
caption: "A caption goes right here.",
source: try? .init(url: .mockMXCFile, mimeType: nil),
fileSize: 3 * 1024 * 1024,
thumbnailSource: nil,
contentType: .pdf))
return TimelineMediaPreviewViewModel(previewItems: [previewItem],
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
}
}
@@ -0,0 +1,167 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Compound
import SwiftUI
struct TimelineMediaPreviewDetailsView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
@State private var isPresentingRedactConfirmation = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
details
actions
}
.frame(maxWidth: .infinity)
}
.padding(.top, 19) // For the drag indicator
.sheet(isPresented: $isPresentingRedactConfirmation) {
TimelineMediaPreviewRedactConfirmationView(context: context)
}
}
private var details: some View {
VStack(alignment: .leading, spacing: 24) {
DetailsRow(title: L10n.screenMediaDetailsUploadedBy) {
HStack(spacing: 8) {
if let sender = context.viewState.currentItem?.sender {
LoadableAvatarImage(url: sender.avatarURL,
name: sender.displayName,
contentID: sender.id,
avatarSize: .user(on: .mediaPreviewDetails),
mediaProvider: context.mediaProvider)
VStack(alignment: .leading, spacing: 0) {
if let displayName = sender.displayName {
Text(displayName)
.font(.compound.bodyMDSemibold)
.foregroundStyle(.compound.decorativeColor(for: sender.id).text)
}
Text(sender.id)
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
}
} else {
Text(L10n.commonLoading)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
}
}
DetailsRow(title: L10n.screenMediaDetailsUploadedOn) {
Text(context.viewState.currentItem?.timestamp.formatted(date: .abbreviated, time: .shortened) ?? "")
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
DetailsRow(title: L10n.screenMediaDetailsFilename) {
Text(context.viewState.currentItem?.filename ?? "")
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
if let contentType = context.viewState.currentItem?.contentType {
DetailsRow(title: L10n.screenMediaDetailsFileFormat) {
Group {
if let fileSize = context.viewState.currentItem?.fileSize {
Text(contentType) + Text(" – ") + Text(UInt(fileSize).formatted(.byteCount(style: .file)))
} else {
Text(contentType)
}
}
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
}
}
.padding(.top, 24)
.padding(.bottom, 32)
.padding(.horizontal, 16)
}
private var actions: some View {
VStack(spacing: 0) {
Divider()
.background(Color.compound.bgSubtlePrimary)
Button { context.send(viewAction: .viewInTimeline) } label: {
Label(L10n.actionViewInTimeline, icon: \.visibilityOn)
}
.buttonStyle(.menuSheet)
Divider()
.background(Color.compound.bgSubtlePrimary)
Button(role: .destructive) { isPresentingRedactConfirmation = true } label: {
Label(L10n.actionRemove, icon: \.delete)
}
.buttonStyle(.menuSheet)
}
}
private struct DetailsRow<Content: View>: View {
let title: String
let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.compound.bodyXS)
.foregroundStyle(.compound.textSecondary)
.textCase(.uppercase)
content()
}
}
}
}
// MARK: - Previews
import UniformTypeIdentifiers
struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel(contentType: .jpeg)
static let unknownTypeViewModel = makeViewModel()
static var previews: some View {
TimelineMediaPreviewDetailsView(context: viewModel.context)
.previewDisplayName("Image")
TimelineMediaPreviewDetailsView(context: unknownTypeViewModel.context)
.previewDisplayName("Unknown type")
}
static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel {
let previewItems = [
ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: true,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "@alice:matrix.org",
displayName: "Alice",
avatarURL: .mockMXCUserAvatar),
content: .init(filename: "Amazing Image.jpeg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail,
contentType: contentType))
]
let viewModel = TimelineMediaPreviewViewModel(previewItems: previewItems,
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
viewModel.state.currentItem = viewModel.state.previewItems.first
return viewModel
}
}
@@ -0,0 +1,150 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Compound
import SwiftUI
struct TimelineMediaPreviewRedactConfirmationView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
var body: some View {
ScrollView {
VStack(spacing: 0) {
header
preview
buttons
}
}
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
.padding(.top, 19) // For the drag indicator
.presentationBackground(.compound.bgCanvasDefault)
.preferredColorScheme(.dark)
}
private var header: some View {
VStack(spacing: 16) {
BigIcon(icon: \.delete, style: .alertSolid)
VStack(spacing: 8) {
Text(L10n.screenMediaDetailsRedactConfirmationTitle)
.font(.compound.headingMDBold)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
Text(L10n.screenMediaDetailsRedactConfirmationMessage)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
}
}
.padding(.top, 24)
.padding(.bottom, 32)
.padding(.horizontal, 24)
}
@ViewBuilder
private var preview: some View {
if let currentItem = context.viewState.currentItem {
HStack(spacing: 12) {
if let mediaSource = currentItem.thumbnailMediaSource {
Color.clear
.scaledFrame(size: 40)
.background {
LoadableImage(mediaSource: mediaSource,
mediaType: .timelineItem(uniqueID: currentItem.id.uniqueID.id),
blurhash: currentItem.blurhash,
mediaProvider: context.mediaProvider) {
Color.compound.bgSubtleSecondary
}
.aspectRatio(contentMode: .fill)
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
VStack(alignment: .leading, spacing: 4) {
Text(currentItem.filename ?? "")
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
if let contentType = currentItem.contentType {
Group {
if let fileSize = currentItem.fileSize {
Text(contentType) + Text(" – ") + Text(UInt(fileSize).formatted(.byteCount(style: .file)))
} else {
Text(contentType)
}
}
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
}
private var buttons: some View {
VStack(spacing: 16) {
Button(L10n.actionRemove, role: .destructive) {
context.send(viewAction: .redact)
}
.buttonStyle(.compound(.primary))
Button {
dismiss()
} label: {
Text(L10n.actionCancel)
.padding(.vertical, 14)
}
.buttonStyle(.compound(.plain))
}
.padding(.bottom, 16)
.padding(.horizontal, 16)
}
}
// MARK: - Previews
import UniformTypeIdentifiers
struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel(contentType: .jpeg)
static var previews: some View {
TimelineMediaPreviewRedactConfirmationView(context: viewModel.context)
}
static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel {
let previewItems = [
ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: true,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "@alice:matrix.org",
displayName: "Alice",
avatarURL: .mockMXCUserAvatar),
content: .init(filename: "Amazing Image.jpeg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail,
contentType: contentType))
]
let viewModel = TimelineMediaPreviewViewModel(previewItems: previewItems,
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
viewModel.state.currentItem = viewModel.state.previewItems.first
return viewModel
}
}
@@ -96,6 +96,7 @@ struct RoomScreen: View {
.environmentObject(timelineContext)
}
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem)
.timelineMediaQuickLook(viewModel: $timelineContext.mediaPreviewViewModel)
.track(screen: .Room)
.onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in
guard let provider = providers.first,
@@ -127,6 +127,8 @@ struct TimelineViewStateBindings {
/// A media item that will be previewed with QuickLook.
var mediaPreviewItem: MediaPreviewItem?
var mediaPreviewViewModel: TimelineMediaPreviewViewModel?
var alertInfo: AlertInfo<RoomScreenAlertInfoType>?
var debugInfo: TimelineItemDebugInfo?
@@ -221,8 +221,9 @@ struct VideoInfoProxy: Hashable {
private(set) var size: CGSize?
private(set) var aspectRatio: CGFloat?
private(set) var mimeType: String?
private(set) var fileSize: UInt?
init(source: MediaSource, duration: TimeInterval, width: UInt64?, height: UInt64?, mimeType: String?) {
init(source: MediaSource, duration: TimeInterval, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) {
self.source = MediaSourceProxy(source: source, mimeType: mimeType)
self.duration = duration
@@ -230,16 +231,18 @@ struct VideoInfoProxy: Hashable {
size = mediaInfo.size
aspectRatio = mediaInfo.aspectRatio
self.mimeType = mediaInfo.mimeType
self.fileSize = fileSize
}
// MARK: - Mocks
private init(source: MediaSourceProxy, duration: TimeInterval, size: CGSize?, aspectRatio: CGFloat?, mimeType: String?) {
private init(source: MediaSourceProxy, duration: TimeInterval, size: CGSize?, aspectRatio: CGFloat?, mimeType: String?, fileSize: UInt?) {
self.source = source
self.duration = duration
self.size = size
self.aspectRatio = aspectRatio
self.mimeType = mimeType
self.fileSize = fileSize
}
static var mockVideo: VideoInfoProxy {
@@ -251,7 +254,8 @@ struct VideoInfoProxy: Hashable {
duration: 100,
size: .init(width: 1920, height: 1080),
aspectRatio: 1.78,
mimeType: nil)
mimeType: nil,
fileSize: 45_167_000)
}
}
@@ -260,35 +264,38 @@ struct ImageInfoProxy: Hashable {
private(set) var size: CGSize?
private(set) var aspectRatio: CGFloat?
private(set) var mimeType: String?
private(set) var fileSize: UInt?
init?(source: MediaSource?, width: UInt64?, height: UInt64?, mimeType: String?) {
init?(source: MediaSource?, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) {
guard let source else {
return nil
}
self.init(source: .init(source: source, mimeType: mimeType), width: width, height: height, mimeType: mimeType)
self.init(source: .init(source: source, mimeType: mimeType), width: width, height: height, mimeType: mimeType, fileSize: fileSize)
}
init(source: MediaSource, width: UInt64?, height: UInt64?, mimeType: String?) {
self.init(source: .init(source: source, mimeType: mimeType), width: width, height: height, mimeType: mimeType)
init(source: MediaSource, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) {
self.init(source: .init(source: source, mimeType: mimeType), width: width, height: height, mimeType: mimeType, fileSize: fileSize)
}
init(source: MediaSourceProxy, width: UInt64?, height: UInt64?, mimeType: String?) {
init(source: MediaSourceProxy, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) {
self.source = source
let mediaInfo = MediaInfoProxy(width: width, height: height, mimeType: mimeType)
size = mediaInfo.size
aspectRatio = mediaInfo.aspectRatio
self.mimeType = mediaInfo.mimeType
self.fileSize = fileSize
}
// MARK: - Mocks
private init(source: MediaSourceProxy, size: CGSize?, aspectRatio: CGFloat?) {
private init(source: MediaSourceProxy, size: CGSize?, aspectRatio: CGFloat?, fileSize: UInt?) {
self.source = source
self.size = size
self.aspectRatio = aspectRatio
mimeType = source.mimeType
self.fileSize = fileSize
}
static var mockImage: ImageInfoProxy {
@@ -296,7 +303,7 @@ struct ImageInfoProxy: Hashable {
fatalError("Invalid mock media source URL")
}
return .init(source: mediaSource, size: .init(width: 2730, height: 2048), aspectRatio: 4 / 3)
return .init(source: mediaSource, size: .init(width: 2730, height: 2048), aspectRatio: 4 / 3, fileSize: 717_000)
}
static var mockThumbnail: ImageInfoProxy {
@@ -304,7 +311,7 @@ struct ImageInfoProxy: Hashable {
fatalError("Invalid mock media source URL")
}
return .init(source: mediaSource, size: .init(width: 800, height: 600), aspectRatio: 4 / 3)
return .init(source: mediaSource, size: .init(width: 800, height: 600), aspectRatio: 4 / 3, fileSize: 84000)
}
static var mockVideoThumbnail: ImageInfoProxy {
@@ -312,11 +319,11 @@ struct ImageInfoProxy: Hashable {
fatalError("Invalid mock media source URL")
}
return .init(source: mediaSource, size: .init(width: 800, height: 450), aspectRatio: 16 / 9)
return .init(source: mediaSource, size: .init(width: 800, height: 450), aspectRatio: 16 / 9, fileSize: 98000)
}
}
struct MediaInfoProxy: Hashable {
private struct MediaInfoProxy: Hashable {
private(set) var size: CGSize?
private(set) var mimeType: String?
private(set) var aspectRatio: CGFloat?
@@ -116,7 +116,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
_ info: MatrixRustSDK.ImageInfo,
_ mediaSource: MediaSource,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
let imageInfo = ImageInfoProxy(source: mediaSource, width: info.width, height: info.height, mimeType: info.mimetype)
let imageInfo = ImageInfoProxy(source: mediaSource, width: info.width, height: info.height, mimeType: info.mimetype, fileSize: info.size.map(UInt.init))
return StickerRoomTimelineItem(id: eventItemProxy.id,
body: body,
@@ -518,12 +518,14 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
let thumbnailInfo = ImageInfoProxy(source: messageContent.info?.thumbnailSource,
width: messageContent.info?.thumbnailInfo?.width,
height: messageContent.info?.thumbnailInfo?.height,
mimeType: messageContent.info?.thumbnailInfo?.mimetype)
mimeType: messageContent.info?.thumbnailInfo?.mimetype,
fileSize: messageContent.info?.size.map(UInt.init))
let imageInfo = ImageInfoProxy(source: messageContent.source,
width: messageContent.info?.width,
height: messageContent.info?.height,
mimeType: messageContent.info?.mimetype)
mimeType: messageContent.info?.mimetype,
fileSize: messageContent.info?.size.map(UInt.init))
return .init(filename: messageContent.filename,
caption: messageContent.caption,
@@ -542,13 +544,15 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
let thumbnailInfo = ImageInfoProxy(source: messageContent.info?.thumbnailSource,
width: messageContent.info?.thumbnailInfo?.width,
height: messageContent.info?.thumbnailInfo?.height,
mimeType: messageContent.info?.thumbnailInfo?.mimetype)
mimeType: messageContent.info?.thumbnailInfo?.mimetype,
fileSize: messageContent.info?.size.map(UInt.init))
let videoInfo = VideoInfoProxy(source: messageContent.source,
duration: messageContent.info?.duration ?? 0,
width: messageContent.info?.width,
height: messageContent.info?.height,
mimeType: messageContent.info?.mimetype)
mimeType: messageContent.info?.mimetype,
fileSize: messageContent.info?.size.map(UInt.init))
return .init(filename: messageContent.filename,
caption: messageContent.caption,