Render galleries with non-visual attachments as a list (#5932)

* Render galleries with non-visual attachments as a list

Adds GalleryListView (with GalleryDivider) for galleries containing a file or audio item, extracting the shared FileTypeIconView from FileRoomTimelineView. GalleryRoomTimelineView picks the list vs the grid via usesListLayout. Tapping to open the preview lands in a follow-up.

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

* pr suggestions

---------

Co-authored-by: svetoslav.mitov <svetoslav.mitov@sportinno.com>
This commit is contained in:
Mauro
2026-07-27 18:46:15 +02:00
committed by GitHub
parent a8c79fdd46
commit 4d7d61dae7
13 changed files with 266 additions and 22 deletions
@@ -102,24 +102,35 @@ struct MediaFileRoomTimelineContent: View {
.foregroundStyle(.compound.textPrimary)
.lineLimit(2)
} icon: {
Group {
if isScanning {
ProgressView()
.scaledFrame(size: CompoundIcon.Size.medium.value, relativeTo: .body)
} else {
CompoundIcon(icon, size: .medium, relativeTo: .body)
.foregroundColor(.compound.iconPrimary)
}
}
.scaledPadding(6)
.background(.compound.iconOnSolidPrimary,
in: RoundedRectangle(cornerRadius: 4, style: .continuous))
FileTypeIconView(icon: icon, isScanning: isScanning)
}
.labelStyle(.custom(spacing: 8, alignment: .center))
.padding(.horizontal, 4) // Add to the styler's padding of 8, as we use the default insets for the caption.
}
}
/// The rounded icon badge used as the leading accessory of a file/audio row, either showing the
/// file-type icon or a scanning spinner.
struct FileTypeIconView: View {
let icon: KeyPath<CompoundIcons, Image>
var isScanning = false
var body: some View {
Group {
if isScanning {
ProgressView()
.scaledFrame(size: CompoundIcon.Size.medium.value, relativeTo: .compound.bodyLG)
} else {
CompoundIcon(icon)
.foregroundColor(.compound.iconPrimary)
}
}
.scaledPadding(6)
.background(.compound.iconOnSolidPrimary,
in: RoundedRectangle(cornerRadius: 4))
}
}
// MARK: - Previews
struct FileRoomTimelineView_Previews: PreviewProvider, TestablePreview {
@@ -0,0 +1,132 @@
//
// 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
/// A vertical list of file rows for a gallery message. Used when a gallery has a non-visual
/// attachment (file/audio), where the grid layout doesn't read well.
struct GalleryListView: View {
let items: [GalleryItem]
let mediaProvider: MediaProviderProtocol?
let contentScannerService: ContentScannerServiceProtocol?
let onItemTap: (Int) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
if index > 0 {
GalleryDivider()
}
GalleryListRow(item: item,
mediaProvider: mediaProvider,
contentScannerService: contentScannerService) {
onItemTap(index)
}
}
}
}
}
/// A hairline divider used between gallery file-list rows (and above the caption).
struct GalleryDivider: View {
@Environment(\.pixelLength) private var pixelLength
var body: some View {
Rectangle()
.fill(Color.compound.borderInteractiveSecondary)
.frame(height: pixelLength)
}
}
private struct GalleryListRow: View {
let item: GalleryItem
let mediaProvider: MediaProviderProtocol?
let contentScannerService: ContentScannerServiceProtocol?
let onTap: () -> Void
private var fileDescription: String {
item.filename.validatedFileExtension.uppercased()
}
private var iconKeyPath: KeyPath<CompoundIcons, Image> {
item.isAudio ? \.audio : \.attachment
}
var body: some View {
ContentScanningView(contentScannerService: contentScannerService,
mediaSource: item.mediaSource,
thumbnailSource: item.thumbnailSource,
containerShowsFailure: false) {
row(icon: icon)
.contentShape(Rectangle())
.onTapGesture { onTap() }
} scanningContent: {
row(icon: scanningIcon)
} unsafeContent: { failure in
failureRow(failure)
}
}
private func row(icon: some View) -> some View {
Label {
VStack(alignment: .leading, spacing: 0) {
Text(item.filename)
.foregroundStyle(.compound.textPrimary)
.font(.compound.bodyLG)
Text(fileDescription)
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
}
.lineLimit(2)
} icon: {
icon
}
.labelStyle(.custom(spacing: 8, alignment: .center))
.padding(.vertical, 12)
}
/// The critical row shown when an item fails content scanning. Reuses ``ContentScanningFailureView``
/// (icon + title + message) inside its own critical container as the surrounding bubble stays regular.
private func failureRow(_ failure: ContentScanningFailure) -> some View {
ContentScanningFailureView(failure: failure)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Color.compound.bgCriticalSubtle, in: RoundedRectangle(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(Color.compound.borderCriticalSubtle)
}
.padding(.vertical, 8)
}
@ViewBuilder
private var icon: some View {
if item.hasThumbnail, 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: {
Color.compound.bgSubtleSecondary
}
.frame(width: 36, height: 36)
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
} else {
FileTypeIconView(icon: iconKeyPath)
}
}
private var scanningIcon: some View {
FileTypeIconView(icon: iconKeyPath, isScanning: true)
}
}
@@ -16,16 +16,32 @@ struct GalleryRoomTimelineView: View {
timelineItem.content.caption?.isBlank == false
}
// List layout when any item is non-visual (file/audio); a grid reads badly for documents.
private var usesListLayout: Bool {
timelineItem.content.items.contains { !$0.isImage && !$0.isVideo }
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
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 usesListLayout {
GalleryListView(items: timelineItem.content.items,
mediaProvider: context?.mediaProvider,
contentScannerService: context?.contentScannerService) { _ in
// Tapping to open the full-screen preview lands in a follow-up.
}
} else {
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 {
if usesListLayout {
GalleryDivider()
}
caption
}
}
@@ -60,12 +76,10 @@ struct GalleryRoomTimelineView_Previews: PreviewProvider, TestablePreview {
// 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
switch source.url {
case .mockMXCUnsafe: false
case .mockMXCScanning: nil
default: true
}
}))
@@ -77,6 +91,8 @@ struct GalleryRoomTimelineView_Previews: PreviewProvider, TestablePreview {
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")
makeView(makeFileListItem(caption: "Quarterly reports"), viewModel).previewDisplayName("File list")
makeView(makeMixedScanFileListItem(), mixedScanViewModel).previewDisplayName("Mixed content scan (files)")
}
static func makeView(_ item: GalleryRoomTimelineItem, _ viewModel: TimelineViewModel) -> some View {
@@ -123,4 +139,43 @@ struct GalleryRoomTimelineView_Previews: PreviewProvider, TestablePreview {
items: items),
properties: .init())
}
private static func makeFileListItem(caption: String?) -> GalleryRoomTimelineItem {
let items: [GalleryItem] = [
.mockImage(index: 0, filename: "photo.jpg"),
.mockFile(index: 1, filename: "report.pdf"),
.mockAudio(index: 2, filename: "meeting.m4a")
]
return GalleryRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(body: "Mixed gallery",
caption: caption,
items: items),
properties: .init())
}
private static func makeMixedScanFileListItem() -> GalleryRoomTimelineItem {
// Safe/unsafe/scanning rows via the mixed-scan sources.
let items: [GalleryItem] = [
.mockFile(index: 0, filename: "report.zip", source: safeSource, contentType: .zip),
.mockFile(index: 1, filename: "contract.pdf", source: unsafeSource),
.mockAudio(index: 2, filename: "meeting.m4a", source: scanningSource)
]
return GalleryRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(body: "Files",
caption: "Mixed scan states",
items: items),
properties: .init())
}
}