Skip to content

Commit

Permalink
Update the timeline media QuickLook modifier. (#3593)
Browse files Browse the repository at this point in the history
Not hooked up to any flows yet.
  • Loading branch information
pixlwave authored Dec 9, 2024
1 parent 7254b6e commit b5605a5
Show file tree
Hide file tree
Showing 31 changed files with 1,052 additions and 20 deletions.
36 changes: 36 additions & 0 deletions ElementX.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions ElementX/Resources/Localizations/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
"common_developer_options" = "Developer options";
"common_device_id" = "Device ID";
"common_direct_chat" = "Direct chat";
"common_downloading" = "Downloading";
"common_edited_suffix" = "(edited)";
"common_editing" = "Editing";
"common_editing_caption" = "Editing caption";
Expand Down Expand Up @@ -389,6 +390,8 @@
"screen_knock_requests_list_title" = "Requests to join";
"screen_media_details_file_format" = "File format";
"screen_media_details_filename" = "File name";
"screen_media_details_redact_confirmation_message" = "This file will be removed from the room and members won’t have access to it.";
"screen_media_details_redact_confirmation_title" = "Delete file?";
"screen_media_details_uploaded_by" = "Uploaded by";
"screen_media_details_uploaded_on" = "Uploaded on";
"screen_media_upload_preview_caption_warning" = "Captions might not be visible to people using older apps.";
Expand Down
6 changes: 6 additions & 0 deletions ElementX/Sources/Generated/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion ElementX/Sources/Mocks/MediaProviderMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions ElementX/Sources/Other/Avatars.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ enum UserAvatarSizeOnScreen {
case knockingUsersBannerStack
case knockingUserBanner
case knockingUserList
case mediaPreviewDetails

var value: CGFloat {
switch self {
Expand Down Expand Up @@ -110,6 +111,8 @@ enum UserAvatarSizeOnScreen {
return 32
case .knockingUserList:
return 52
case .mediaPreviewDetails:
return 32
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion ElementX/Sources/Other/Extensions/Date.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit b5605a5

Please sign in to comment.