mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Render image and video in galleries as a grid (#5929)
This commit is contained in:
@@ -11,15 +11,20 @@ struct ContentScannerServiceMockConfiguration {
|
||||
/// The verdict reported for every source: `true` when safe, `false` when unsafe, or `nil` to
|
||||
/// never resolve the scan so that the scanning state remains visible.
|
||||
var scanResult: Bool?
|
||||
/// Overrides `scanResult` on a per-source basis, using the same `true`/`false`/`nil` semantics.
|
||||
var perSourceScanResult: (@Sendable (MediaSourceProxy) -> Bool?)?
|
||||
}
|
||||
|
||||
extension ContentScannerServiceMock {
|
||||
convenience init(_ configuration: ContentScannerServiceMockConfiguration) {
|
||||
self.init()
|
||||
|
||||
scanResultFromSourceReturnValue = configuration.scanResult
|
||||
loadScanResultFromSourceClosure = { _ in
|
||||
guard let scanResult = configuration.scanResult else {
|
||||
let blanketScanResult = configuration.scanResult
|
||||
let resolve: @Sendable (MediaSourceProxy) -> Bool? = configuration.perSourceScanResult ?? { _ in blanketScanResult }
|
||||
|
||||
scanResultFromSourceClosure = { resolve($0) }
|
||||
loadScanResultFromSourceClosure = { source in
|
||||
guard let scanResult = resolve(source) else {
|
||||
try? await Task.sleep(for: .seconds(3600))
|
||||
return .failure(.failedScanning)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ enum TestablePreviewsDictionary {
|
||||
"FormattedBodyText_Previews" : FormattedBodyText_Previews.self,
|
||||
"FormattingToolbar_Previews" : FormattingToolbar_Previews.self,
|
||||
"FullscreenDialog_Previews" : FullscreenDialog_Previews.self,
|
||||
"GalleryRoomTimelineView_Previews" : GalleryRoomTimelineView_Previews.self,
|
||||
"HighlightedTimelineItemModifier_Previews" : HighlightedTimelineItemModifier_Previews.self,
|
||||
"HomeScreenEmptyStateView_Previews" : HomeScreenEmptyStateView_Previews.self,
|
||||
"HomeScreenInviteCell_Previews" : HomeScreenInviteCell_Previews.self,
|
||||
|
||||
@@ -180,6 +180,10 @@ private extension TimelineItemSendInfo {
|
||||
switch message {
|
||||
case is ImageRoomTimelineItem, is VideoRoomTimelineItem:
|
||||
.overlay(capsuleStyle: !message.hasMediaCaption)
|
||||
case is GalleryRoomTimelineItem:
|
||||
// Without a caption, append the send info below the grid rather than overlaying
|
||||
// a capsule on top of the media.
|
||||
message.hasMediaCaption ? .overlay(capsuleStyle: false) : .vertical()
|
||||
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
|
||||
// swiftlint:disable:next void_function_in_ternary
|
||||
message.hasMediaCaption ? .overlay(capsuleStyle: false) : .horizontal(spacing: 0) // No spacing as the content already contains it.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct GalleryGridView: View {
|
||||
let items: [GalleryItem]
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
let contentScannerService: ContentScannerServiceProtocol?
|
||||
let onItemTap: (Int) -> Void
|
||||
|
||||
static let spacing: CGFloat = 4
|
||||
static let groupWidth: CGFloat = 264
|
||||
static let maxVisible = 5
|
||||
|
||||
private var visibleCount: Int {
|
||||
min(items.count, Self.maxVisible)
|
||||
}
|
||||
|
||||
/// Number shown on the overflow tile when items.count > maxVisible.
|
||||
private var overflowCount: Int {
|
||||
// Represents the last visible item + the rest.
|
||||
items.count > Self.maxVisible ? items.count - Self.maxVisible + 1 : 0
|
||||
}
|
||||
|
||||
private struct Row {
|
||||
let indices: [Int]
|
||||
let height: CGFloat
|
||||
}
|
||||
|
||||
/// Hero-style layout: 1 = full-width, 2 = two full-width stacked rows, 3 = full-width hero
|
||||
/// over two squares, 4 = 2×2 squares, 5+ = top row two squares, bottom row three squares.
|
||||
private var rows: [Row] {
|
||||
switch visibleCount {
|
||||
case 1: return [Row(indices: [0], height: 130)]
|
||||
case 2: return [Row(indices: [0], height: 130),
|
||||
Row(indices: [1], height: 130)]
|
||||
case 3: return [Row(indices: [0], height: 130),
|
||||
Row(indices: [1, 2], height: 130)]
|
||||
case 4: return [Row(indices: [0, 1], height: 130),
|
||||
Row(indices: [2, 3], height: 130)]
|
||||
case 5: return [Row(indices: [0, 1], height: 130),
|
||||
Row(indices: [2, 3, 4], height: 85)]
|
||||
default: return []
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Self.spacing) {
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||
let totalSpacing = CGFloat(row.indices.count - 1) * Self.spacing
|
||||
let tileWidth = (Self.groupWidth - totalSpacing) / CGFloat(row.indices.count)
|
||||
HStack(spacing: Self.spacing) {
|
||||
ForEach(row.indices, id: \.self) { index in
|
||||
tile(at: index, size: CGSize(width: tileWidth, height: row.height))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func tile(at index: Int, size: CGSize) -> some View {
|
||||
if items.indices.contains(index) {
|
||||
let isLastVisible = index == visibleCount - 1
|
||||
let overflow = (overflowCount > 0 && isLastVisible) ? overflowCount : 0
|
||||
|
||||
GalleryItemTileView(item: items[index],
|
||||
mediaProvider: mediaProvider,
|
||||
contentScannerService: contentScannerService,
|
||||
overflowCount: overflow) {
|
||||
onItemTap(index)
|
||||
}
|
||||
.frame(width: size.width, height: size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct GalleryItemTileView: View {
|
||||
let item: GalleryItem
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
let contentScannerService: ContentScannerServiceProtocol?
|
||||
let overflowCount: Int
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
// Overflow above the scanner: shows on any scan state, survives content updates.
|
||||
ZStack {
|
||||
// containerShowsFailure: false keeps an unsafe tile local, not the whole bubble.
|
||||
ContentScanningView(contentScannerService: contentScannerService,
|
||||
mediaSource: item.mediaSource,
|
||||
thumbnailSource: item.thumbnailSource,
|
||||
containerShowsFailure: false) {
|
||||
// Clear base so the image fills/clips to the grid's tile size, not its own.
|
||||
Color.clear
|
||||
.overlay { content }
|
||||
.clipped()
|
||||
.overlay(alignment: .bottom) {
|
||||
if item.isVideo {
|
||||
videoOverlay
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTap() }
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(item.filename)
|
||||
} scanningContent: {
|
||||
ScanningMediaEventsTimelineView()
|
||||
} unsafeContent: { failure in
|
||||
UnsafeMediaEventsTimelineView(failure: failure)
|
||||
}
|
||||
|
||||
if overflowCount > 0 {
|
||||
overflowOverlay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let source = item.thumbnailSource ?? item.mediaSource {
|
||||
LoadableImage(mediaSource: source,
|
||||
mediaType: .timelineItem(uniqueID: item.id.timelineItemUniqueID),
|
||||
blurhash: item.blurhash,
|
||||
size: item.size,
|
||||
mediaProvider: mediaProvider) { imageView in
|
||||
imageView
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
placeholder
|
||||
}
|
||||
} else {
|
||||
iconFallback
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
Color.compound.bgSubtleSecondary.opacity(0.9)
|
||||
}
|
||||
|
||||
private var iconFallback: some View {
|
||||
ZStack {
|
||||
Color.compound.bgSubtleSecondary
|
||||
CompoundIcon(item.isAudio ? \.audio : \.attachment)
|
||||
.foregroundStyle(.compound.iconPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
private var videoOverlay: some View {
|
||||
HStack(spacing: 0) {
|
||||
CompoundIcon(\.videoCallSolid,
|
||||
size: .xSmall,
|
||||
relativeTo: .compound.bodyXSSemibold)
|
||||
Spacer(minLength: 0)
|
||||
if let duration = item.duration {
|
||||
Text(formatted(duration: duration))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.font(.compound.bodyXSSemibold)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.background {
|
||||
LinearGradient(colors: [.clear, .compound.bgCanvasDefault],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
private var overflowOverlay: some View {
|
||||
ZStack {
|
||||
Color.compound.bgCanvasDefault.opacity(0.7)
|
||||
Text("+\(overflowCount)")
|
||||
.font(.compound.headingSMSemibold)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatted(duration: TimeInterval) -> String {
|
||||
let duration = Duration.seconds(duration)
|
||||
let pattern: Duration.TimeFormatStyle.Pattern = duration >= .seconds(3600) ? .hourMinuteSecond : .minuteSecond
|
||||
return duration.formatted(.time(pattern: pattern))
|
||||
}
|
||||
}
|
||||
+106
-2
@@ -9,14 +9,118 @@ import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct GalleryRoomTimelineView: View {
|
||||
@Environment(\.timelineContext) private var context
|
||||
let timelineItem: GalleryRoomTimelineItem
|
||||
|
||||
private var hasMediaCaption: Bool {
|
||||
timelineItem.content.caption?.isBlank == false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
// Placeholder rendering — the grid/list media views land in a follow-up.
|
||||
FormattedBodyText(text: timelineItem.content.caption ?? timelineItem.content.body,
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
GalleryGridView(items: timelineItem.content.items,
|
||||
mediaProvider: context?.mediaProvider,
|
||||
contentScannerService: context?.contentScannerService) { _ in
|
||||
// Tapping to open the full-screen preview lands in a follow-up.
|
||||
}
|
||||
|
||||
if hasMediaCaption {
|
||||
caption
|
||||
}
|
||||
}
|
||||
.frame(width: GalleryGridView.groupWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var caption: some View {
|
||||
if let attributedCaption = timelineItem.content.formattedCaption {
|
||||
FormattedBodyText(attributedString: attributedCaption,
|
||||
trailingReservedSize: timelineItem.trailingReservedSize,
|
||||
boostFontSize: timelineItem.shouldBoost)
|
||||
} else if let caption = timelineItem.content.caption {
|
||||
FormattedBodyText(text: caption,
|
||||
trailingReservedSize: timelineItem.trailingReservedSize,
|
||||
boostFontSize: timelineItem.shouldBoost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct GalleryRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
|
||||
// Fixed sources keyed by the mock scanner below. Safe is loadable (real thumbnail); others just need a distinct URL.
|
||||
nonisolated static let safeSource = ImageInfoProxy.mockImage.source
|
||||
// swiftlint:disable force_try
|
||||
nonisolated static let unsafeSource = try! MediaSourceProxy(url: .mockMXCUnsafe, mimeType: "image/jpeg")
|
||||
nonisolated static let scanningSource = try! MediaSourceProxy(url: .mockMXCScanning, mimeType: "image/jpeg")
|
||||
// swiftlint:enable force_try
|
||||
|
||||
static let mixedScanViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init { source in
|
||||
if source.url == unsafeSource.url {
|
||||
false
|
||||
} else if source.url == scanningSource.url {
|
||||
nil
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}))
|
||||
|
||||
static var previews: some View {
|
||||
makeView(makeItem(itemCount: 1), viewModel).previewDisplayName("1 image")
|
||||
makeView(makeItem(itemCount: 2), viewModel).previewDisplayName("2 images")
|
||||
makeView(makeItem(itemCount: 3), viewModel).previewDisplayName("3 images")
|
||||
makeView(makeItem(itemCount: 4), viewModel).previewDisplayName("4 images")
|
||||
makeView(makeItem(itemCount: 5), viewModel).previewDisplayName("5 images")
|
||||
makeView(makeItem(itemCount: 9, caption: "A trip to remember 🌅"), viewModel).previewDisplayName("6+ images with caption")
|
||||
makeView(makeMixedScanItem(), mixedScanViewModel).previewDisplayName("Mixed content scan")
|
||||
}
|
||||
|
||||
static func makeView(_ item: GalleryRoomTimelineItem, _ viewModel: TimelineViewModel) -> some View {
|
||||
GalleryRoomTimelineView(timelineItem: item)
|
||||
.padding(20)
|
||||
.environmentObject(viewModel.context)
|
||||
.environment(\.timelineContext, viewModel.context)
|
||||
}
|
||||
|
||||
private static func makeItem(itemCount: Int, caption: String? = nil) -> GalleryRoomTimelineItem {
|
||||
let items: [GalleryItem] = (0..<itemCount).map { index in
|
||||
index.isMultiple(of: 3)
|
||||
? .mockVideo(index: index, filename: "clip-\(index).mp4")
|
||||
: .mockImage(index: index, filename: "image-\(index).jpg")
|
||||
}
|
||||
|
||||
return GalleryRoomTimelineItem(id: .randomEvent,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "Gallery (\(itemCount) items)",
|
||||
caption: caption,
|
||||
items: items),
|
||||
properties: .init())
|
||||
}
|
||||
|
||||
private static func makeMixedScanItem() -> GalleryRoomTimelineItem {
|
||||
// >maxVisible items with the overflow tile (index 4) unsafe, to show "+N" over a failed scan.
|
||||
let sources = [safeSource, unsafeSource, scanningSource, safeSource, unsafeSource, safeSource, safeSource]
|
||||
let items: [GalleryItem] = sources.enumerated().map { index, source in
|
||||
.mockImage(index: index, filename: "image-\(index).jpg", source: source, thumbnailSource: source)
|
||||
}
|
||||
|
||||
return GalleryRoomTimelineItem(id: .randomEvent,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "Gallery (\(sources.count) items)",
|
||||
caption: "Mixed scan states",
|
||||
items: items),
|
||||
properties: .init())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +278,15 @@ nonisolated struct VideoInfoProxy: Hashable {
|
||||
mimeType: nil,
|
||||
fileSize: 45_167_000)
|
||||
}
|
||||
|
||||
static func mockVideo(duration: TimeInterval) -> VideoInfoProxy {
|
||||
.init(source: mockVideo.source,
|
||||
duration: duration,
|
||||
size: .init(width: 1920, height: 1080),
|
||||
aspectRatio: 1.78,
|
||||
mimeType: nil,
|
||||
fileSize: 45_167_000)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct ImageInfoProxy: Hashable {
|
||||
|
||||
+33
@@ -143,3 +143,36 @@ nonisolated enum GalleryItem: Hashable, Identifiable {
|
||||
isImage || thumbnailSource != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mocks
|
||||
|
||||
nonisolated extension GalleryItemID {
|
||||
static func mock(_ index: Int) -> GalleryItemID {
|
||||
.init(timelineItemUniqueID: .init("gallery-mock"), mediaIndex: index)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated extension GalleryItem {
|
||||
static func mockImage(index: Int = 0,
|
||||
filename: String = "image.jpg",
|
||||
source: MediaSourceProxy = ImageInfoProxy.mockImage.source,
|
||||
thumbnailSource: MediaSourceProxy? = ImageInfoProxy.mockThumbnail.source,
|
||||
blurhash: String? = "L%KUc%kqS$RP?Ks,WEf8OlrqaekW",
|
||||
contentType: UTType? = .jpeg) -> GalleryItem {
|
||||
let imageInfo = ImageInfoProxy(source: source, width: 1920, height: 1080, mimeType: nil, fileSize: nil)
|
||||
let thumbnailInfo = thumbnailSource.map { ImageInfoProxy(source: $0, width: 1920, height: 1080, mimeType: nil, fileSize: nil) }
|
||||
return .image(id: .mock(index),
|
||||
.init(filename: filename, imageInfo: imageInfo, thumbnailInfo: thumbnailInfo, blurhash: blurhash, contentType: contentType))
|
||||
}
|
||||
|
||||
static func mockVideo(index: Int = 0,
|
||||
filename: String = "clip.mp4",
|
||||
thumbnailSource: MediaSourceProxy? = ImageInfoProxy.mockVideoThumbnail.source,
|
||||
duration: TimeInterval = 42,
|
||||
blurhash: String? = "L%KUc%kqS$RP?Ks,WEf8OlrqaekW",
|
||||
contentType: UTType? = .mpeg4Movie) -> GalleryItem {
|
||||
let thumbnailInfo = thumbnailSource.map { ImageInfoProxy(source: $0, width: 1920, height: 1080, mimeType: nil, fileSize: nil) }
|
||||
return .video(id: .mock(index),
|
||||
.init(filename: filename, videoInfo: .mockVideo(duration: duration), thumbnailInfo: thumbnailInfo, blurhash: blurhash, contentType: contentType))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user