mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-06 20:38:41 +00:00
Minor quicklook fixes. (#6112)
* Only show a spinner when opening the media viewer is slow * Keep a sent local echo's page in the media viewer * Fix the caption visibility. --------- Co-authored-by: Stefan Ceriu <stefan.ceriu@gmail.com> Co-authored-by: Matthew Hodgson <matthew@matrix.org>
This commit is contained in:
@@ -112,8 +112,9 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
|
|||||||
.flatMap { $0.previewableMedia(allowedGalleryItemTypes: allowedGalleryItemTypes) }
|
.flatMap { $0.previewableMedia(allowedGalleryItemTypes: allowedGalleryItemTypes) }
|
||||||
.map { newItem in
|
.map { newItem in
|
||||||
// If an item already exists use that instead to preserve the file handle, download error etc.
|
// If an item already exists use that instead to preserve the file handle, download error etc.
|
||||||
if let oldItem = previewItems.first(where: { $0.id == newItem.id }) {
|
if let oldItem = previewItems.first(where: { $0.id == newItem.id || $0.isLocalEcho(of: newItem) }) {
|
||||||
oldItem.content = newItem.content
|
oldItem.content = newItem.content
|
||||||
|
oldItem.id = newItem.id // Make sure a local echo's transaction ID → event ID.
|
||||||
return oldItem
|
return oldItem
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,9 +238,22 @@ enum TimelineMediaPreviewItem: Equatable {
|
|||||||
|
|
||||||
var downloadError: Error?
|
var downloadError: Error?
|
||||||
|
|
||||||
/// A stable identifier that's unique per preview item — including individual gallery
|
/// A stable identifier that's unique per preview item, even for gallery attachments that would
|
||||||
/// attachments that would otherwise share their parent event's ID.
|
/// otherwise share their parent event's ID. Not stable for local echoes however, those will
|
||||||
let id: MediaPreviewItemID
|
/// transition from transaction ID to event ID once sent.
|
||||||
|
fileprivate(set) var id: MediaPreviewItemID
|
||||||
|
|
||||||
|
/// Whether this is the local echo that `sentItem` is the remote echo of. Sending swaps the
|
||||||
|
/// transaction ID for an event ID but the unique ID remains stable.
|
||||||
|
fileprivate func isLocalEcho(of sentItem: Media) -> Bool {
|
||||||
|
guard timelineItem.id.transactionID != nil, sentItem.timelineItem.id.eventID != nil,
|
||||||
|
timelineItem.id.uniqueID == sentItem.timelineItem.id.uniqueID else { return false }
|
||||||
|
switch (content, sentItem.content) {
|
||||||
|
case (.timelineItem, .timelineItem): return true
|
||||||
|
case (.galleryItem(_, let item), .galleryItem(_, let sentGalleryItem)): return item.id.mediaIndex == sentGalleryItem.id.mediaIndex
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init(timelineItem: EventBasedMessageTimelineItemProtocol) {
|
init(timelineItem: EventBasedMessageTimelineItemProtocol) {
|
||||||
content = .timelineItem(timelineItem)
|
content = .timelineItem(timelineItem)
|
||||||
|
|||||||
+50
-4
@@ -73,6 +73,11 @@ class TimelineMediaPreviewController: QLPreviewController {
|
|||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
headerHostingController.rootView.onMoveToWindow = { [weak self] in
|
||||||
|
guard #available(iOS 26, *) else { return }
|
||||||
|
self?.updateCaptionVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
view.addSubview(captionView)
|
view.addSubview(captionView)
|
||||||
// Constraints added later as the toolbar isn't available yet.
|
// Constraints added later as the toolbar isn't available yet.
|
||||||
|
|
||||||
@@ -128,11 +133,9 @@ class TimelineMediaPreviewController: QLPreviewController {
|
|||||||
super.viewWillLayoutSubviews()
|
super.viewWillLayoutSubviews()
|
||||||
|
|
||||||
if let bottomBarItemsContainer {
|
if let bottomBarItemsContainer {
|
||||||
|
if #unavailable(iOS 26) {
|
||||||
// Using the toolbar's visibility doesn't work so check its frame.
|
// Using the toolbar's visibility doesn't work so check its frame.
|
||||||
captionView.isHidden = if #available(iOS 26, *) {
|
captionView.isHidden = bottomBarItemsContainer.frame.minY >= view.frame.maxY
|
||||||
navigationBar?.topItem?.leftBarButtonItem?.frame(in: view) == nil
|
|
||||||
} else {
|
|
||||||
bottomBarItemsContainer.frame.minY >= view.frame.maxY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if captionView.constraints.isEmpty {
|
if captionView.constraints.isEmpty {
|
||||||
@@ -176,6 +179,15 @@ class TimelineMediaPreviewController: QLPreviewController {
|
|||||||
barButtonTimer = nil
|
barButtonTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 26, *)
|
||||||
|
private func updateCaptionVisibility() {
|
||||||
|
// The caption should be hidden alongside the header.
|
||||||
|
let isHeaderHidden = headerHostingController.view.window == nil
|
||||||
|
if captionView.isHidden != isHeaderHidden {
|
||||||
|
captionView.isHidden = isHeaderHidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func updateBarButtons() {
|
private func updateBarButtons() {
|
||||||
guard let topItem = navigationBar?.topItem else { return }
|
guard let topItem = navigationBar?.topItem else { return }
|
||||||
|
|
||||||
@@ -306,11 +318,20 @@ class TimelineMediaPreviewController: QLPreviewController {
|
|||||||
|
|
||||||
private struct HeaderView: View {
|
private struct HeaderView: View {
|
||||||
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
|
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
|
||||||
|
/// Called whenever the header is added or removed from a window.
|
||||||
|
var onMoveToWindow: () -> Void = { }
|
||||||
|
|
||||||
private var currentItem: TimelineMediaPreviewItem {
|
private var currentItem: TimelineMediaPreviewItem {
|
||||||
context.viewState.currentItem
|
context.viewState.currentItem
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
content
|
||||||
|
.background(WindowTrackingView(onMoveToWindow: onMoveToWindow))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
if let mediaItem = currentItem.mediaItem {
|
if let mediaItem = currentItem.mediaItem {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text(mediaItem.sender.displayName ?? mediaItem.sender.id)
|
Text(mediaItem.sender.displayName ?? mediaItem.sender.id)
|
||||||
@@ -475,6 +496,31 @@ private struct DownloadIndicatorView: View {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// An invisible view that reports when it is added to or removed from a window.
|
||||||
|
private struct WindowTrackingView: UIViewRepresentable {
|
||||||
|
let onMoveToWindow: () -> Void
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> TrackingView {
|
||||||
|
let view = TrackingView()
|
||||||
|
view.isUserInteractionEnabled = false
|
||||||
|
view.onMoveToWindow = onMoveToWindow
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: TrackingView, context: Context) {
|
||||||
|
uiView.onMoveToWindow = onMoveToWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrackingView: UIView {
|
||||||
|
var onMoveToWindow: (() -> Void)?
|
||||||
|
|
||||||
|
override func didMoveToWindow() {
|
||||||
|
super.didMoveToWindow()
|
||||||
|
onMoveToWindow?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private extension UIView {
|
private extension UIView {
|
||||||
func firstScrollView() -> UIScrollView? {
|
func firstScrollView() -> UIScrollView? {
|
||||||
for view in subviews {
|
for view in subviews {
|
||||||
|
|||||||
@@ -708,8 +708,15 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleMediaTapped(with itemID: TimelineItemIdentifier, galleryIndex: Int? = nil) async {
|
private func handleMediaTapped(with itemID: TimelineItemIdentifier, galleryIndex: Int? = nil) async {
|
||||||
|
// Building the media timeline takes ~100ms from the event cache, however its possible that
|
||||||
|
// a focussed timeline may hit /context so we need to show a spinner when it's actually slow.
|
||||||
|
let spinner = Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
state.showLoading = true
|
state.showLoading = true
|
||||||
|
}
|
||||||
let action = await timelineInteractionHandler.processItemTap(itemID)
|
let action = await timelineInteractionHandler.processItemTap(itemID)
|
||||||
|
spinner.cancel()
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case .displayMediaPreview(let item, let timelineViewModelKind):
|
case .displayMediaPreview(let item, let timelineViewModelKind):
|
||||||
|
|||||||
@@ -343,6 +343,41 @@ struct TimelineMediaPreviewDataSourceTests {
|
|||||||
.filter { !($0 is GalleryRoomTimelineItem) } // Galleries are previewed through their own data source.
|
.filter { !($0 is GalleryRoomTimelineItem) } // Galleries are previewed through their own data source.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func sentLocalEchoKeepsItsPage() async throws {
|
||||||
|
// Given a viewer opened on an upload in flight: a local echo with a transaction ID.
|
||||||
|
let uniqueID = TimelineItemIdentifier.UniqueID(UUID().uuidString)
|
||||||
|
func makeUpload(id: TimelineItemIdentifier.EventOrTransactionID) -> ImageRoomTimelineItem {
|
||||||
|
ImageRoomTimelineItem(id: .event(uniqueID: uniqueID, eventOrTransactionID: id),
|
||||||
|
timestamp: .mock,
|
||||||
|
isOutgoing: true,
|
||||||
|
isEditable: false,
|
||||||
|
canBeRepliedTo: false,
|
||||||
|
sender: .init(id: "Alice"),
|
||||||
|
content: .init(filename: "upload.jpg", imageInfo: .mockImage, thumbnailInfo: nil, blurhash: nil))
|
||||||
|
}
|
||||||
|
let localEcho = makeUpload(id: .transactionID("t1"))
|
||||||
|
var items = newChunk()
|
||||||
|
items.append(localEcho)
|
||||||
|
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: items.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) },
|
||||||
|
initialItem: localEcho,
|
||||||
|
paginationState: .initial)
|
||||||
|
let page = try #require(dataSource.currentItem.mediaItem)
|
||||||
|
#expect(page.id == .timelineItem(.transactionID("t1")))
|
||||||
|
|
||||||
|
// When the upload completes and the timeline swaps the echo's transaction ID for its event ID.
|
||||||
|
items[items.count - 1] = makeUpload(id: .eventID("$e1"))
|
||||||
|
let deferred = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: .seconds(1)) { _ in true }
|
||||||
|
dataSource.updatePreviewItems(itemViewStates: items.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) })
|
||||||
|
try await deferred.fulfill()
|
||||||
|
|
||||||
|
// Then the same page carries on under the new ID (its file came from the local media cache), nothing rebuilt.
|
||||||
|
#expect(dataSource.currentItem.mediaItem === page)
|
||||||
|
#expect(page.id == .timelineItem(.eventID("$e1")))
|
||||||
|
#expect(dataSource.previewItems.map(\.id).last == .timelineItem(.eventID("$e1")))
|
||||||
|
#expect(dataSource.previewItems.count == items.count)
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func assertInitialDataSource() throws -> TimelineMediaPreviewDataSource {
|
private func assertInitialDataSource() throws -> TimelineMediaPreviewDataSource {
|
||||||
// Given a data source built with the initial items.
|
// Given a data source built with the initial items.
|
||||||
|
|||||||
Reference in New Issue
Block a user