mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Update the timeline media QuickLook modifier. (#3593)
Not hooked up to any flows yet.
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
+167
@@ -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
|
||||
}
|
||||
}
|
||||
+150
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user