Open a full-screen preview when tapping a gallery item

Tapping a gallery tile/row opens the existing media preview scoped to that gallery's attachments, paging between them. Introduces a typed MediaPreviewItemID (replacing the stringly-typed preview identifier) and a gallery-scoped TimelineMediaPreviewDataSource; the tap routes through TimelineInteractionHandler/TimelineViewModel. Non-previewable (.other) items are skipped without shifting the tapped index.

Co-authored-by: svetoslav.mitov <svetoslav.mitov@sportinno.com>

just use id instead of previewID

removed horrible multiline ternary

pr suggestions

remove redundant code

remove empty caption display + gallery uses the appropriate media timeline actions
This commit is contained in:
Mauro Romito
2026-07-27 19:14:23 +02:00
committed by Mauro
parent b4f397ed6a
commit 7ea9aeb174
15 changed files with 343 additions and 112 deletions
@@ -29,6 +29,62 @@ struct TimelineMediaPreviewDataSourceTests {
try assertInitialDataSource()
}
@Test
func galleryPreview() throws {
// Given a gallery message with three previewable attachments.
let items = (0..<3).map { index in
GalleryItem.mockImage(index: index, filename: "image-\(index).jpg")
}
let gallery = GalleryRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(body: "Gallery", caption: nil, items: items),
properties: .init())
// When opening the preview scoped to that gallery on the second attachment.
let dataSource = TimelineMediaPreviewDataSource(galleryItem: gallery, initialIndex: 1)
// Then it contains exactly the gallery's attachments with no surrounding pagination…
#expect(dataSource.previewItems.count == 3)
#expect(dataSource.numberOfPreviewItems(in: previewController) == 3)
#expect(dataSource.initialItemIndex == 1)
// …and the initial item is the tapped attachment.
let media = try #require(dataSource.currentItem.mediaItem, "The current item should be a media item.")
#expect(media.id == .galleryItem(items[1].id))
}
@Test
func galleryPreviewIncludesNonPreviewableItems() throws {
// Given a gallery whose first attachment is an unknown type (QuickLook shows its default screen).
let items: [GalleryItem] = [
.other(id: .mock(0), filename: "unknown.bin"),
.mockImage(index: 1, filename: "image-1.jpg"),
.mockImage(index: 2, filename: "image-2.jpg")
]
let gallery = GalleryRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(body: "Gallery", caption: nil, items: items),
properties: .init())
// When tapping the first image (index 1 in the full items array).
let dataSource = TimelineMediaPreviewDataSource(galleryItem: gallery, initialIndex: 1)
// Then every attachment is kept — indices line up 1:1 — and the unknown item has no media source.
#expect(dataSource.previewItems.count == 3)
#expect(dataSource.previewItems[0].id == .galleryItem(items[0].id))
#expect(dataSource.previewItems[0].mediaSource == nil)
let media = try #require(dataSource.currentItem.mediaItem, "The current item should be a media item.")
#expect(media.id == .galleryItem(items[1].id))
}
@Test
func currentUpdateItem() throws {
// Given a data source built with the initial items.
@@ -69,8 +125,8 @@ struct TimelineMediaPreviewDataSourceTests {
let previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
let displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
#expect(displayedItem.id == initialMediaItems[initialItemIndex].previewID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].previewID, "The current item should not change.")
#expect(dataSource.previewItems.count == initialMediaViewStates.count, "The number of items should not change.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The padded number of items should not change.")
@@ -93,8 +149,8 @@ struct TimelineMediaPreviewDataSourceTests {
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
#expect(displayedItem.id == initialMediaItems[initialItemIndex].previewID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].previewID, "The current item should not change.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
// When more items are loaded in a forward pagination or sync.
@@ -109,8 +165,8 @@ struct TimelineMediaPreviewDataSourceTests {
previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
#expect(displayedItem.id == initialMediaItems[initialItemIndex].previewID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].previewID, "The current item should not change.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
}
@@ -133,8 +189,8 @@ struct TimelineMediaPreviewDataSourceTests {
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
#expect(displayedItem.id == initialMediaItems[initialItemIndex].previewID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].previewID, "The current item should not change.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
// When paginating forwards by more than the available padding.
@@ -149,8 +205,8 @@ struct TimelineMediaPreviewDataSourceTests {
previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = try #require(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
#expect(displayedItem.id == initialMediaItems[initialItemIndex].previewID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].previewID, "The current item should not change.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
}
@@ -173,8 +229,8 @@ struct TimelineMediaPreviewDataSourceTests {
#expect(previewItemCount == 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
#expect(dataSource.initialItemIndex == initialPadding, "The initial item index should be padded for the preview controller.")
#expect(displayedItem.id == initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.")
#expect(dataSource.currentMediaItemID == initialItem.id.eventOrTransactionID, "The current item should also be the initial item.")
#expect(displayedItem.id == initialItem.previewID, "The displayed item should be the initial item.")
#expect(dataSource.currentMediaItemID == initialItem.previewID, "The current item should also be the initial item.")
// When the timeline loads the initial items.
let deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
@@ -191,8 +247,8 @@ struct TimelineMediaPreviewDataSourceTests {
#expect(previewItemCount == 1 + (2 * initialPadding), "The item count should not change as the padding will be reduced.")
#expect(dataSource.initialItemIndex == initialPadding, "The item index should not change.")
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
#expect(displayedItem.id == initialMediaItems[initialItemIndex].previewID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].previewID, "The current item should not change.")
}
@Test
@@ -214,8 +270,8 @@ struct TimelineMediaPreviewDataSourceTests {
#expect(previewItemCount == 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
#expect(dataSource.initialItemIndex == initialPadding, "The initial item index should be padded for the preview controller.")
#expect(displayedItem.id == initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.")
#expect(dataSource.currentMediaItemID == initialItem.id.eventOrTransactionID, "The current item should also be the initial item.")
#expect(displayedItem.id == initialItem.previewID, "The displayed item should be the initial item.")
#expect(dataSource.currentMediaItemID == initialItem.previewID, "The current item should also be the initial item.")
// When the timeline loads more items but still doesn't include the initial item.
let failure = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: .seconds(1)) { _ in true }
@@ -232,8 +288,8 @@ struct TimelineMediaPreviewDataSourceTests {
#expect(previewItemCount == 1 + (2 * initialPadding), "The initial item count should not change.")
#expect(dataSource.initialItemIndex == initialPadding, "The initial item index should not change.")
#expect(displayedItem.id == initialItem.id.eventOrTransactionID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialItem.id.eventOrTransactionID, "The current item not change.")
#expect(displayedItem.id == initialItem.previewID, "The displayed item should not change.")
#expect(dataSource.currentMediaItemID == initialItem.previewID, "The current item not change.")
}
// MARK: Helpers
@@ -259,8 +315,8 @@ struct TimelineMediaPreviewDataSourceTests {
// Then the preview controller should be showing the initial item and the data source should reflect this.
#expect(dataSource.initialItemIndex == initialItemIndex + initialPadding, "The initial item index should be padded for the preview controller.")
#expect(displayedItem.id == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should be the initial item.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should also be the initial item.")
#expect(displayedItem.id == initialMediaItems[initialItemIndex].previewID, "The displayed item should be the initial item.")
#expect(dataSource.currentMediaItemID == initialMediaItems[initialItemIndex].previewID, "The current item should also be the initial item.")
#expect(dataSource.previewItems.count == initialMediaViewStates.count, "The initial count of preview items should be correct.")
#expect(previewItemCount == initialMediaViewStates.count + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
@@ -270,7 +326,15 @@ struct TimelineMediaPreviewDataSourceTests {
}
private extension TimelineMediaPreviewDataSource {
var currentMediaItemID: TimelineItemIdentifier.EventOrTransactionID? {
var currentMediaItemID: MediaPreviewItemID? {
currentItem.mediaItem?.id
}
}
private extension EventBasedMessageTimelineItemProtocol {
/// Test helper that derives the same preview ID used by `TimelineMediaPreviewItem.Media` —
/// avoids reaching into the private encoding from the tests.
var previewID: MediaPreviewItemID {
TimelineMediaPreviewItem.Media(timelineItem: self).id
}
}