Files
element-x-ios/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryItemTileView.swift
2026-08-03 14:33:12 +02:00

142 lines
5.2 KiB
Swift

//
// 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
/// The item's position within the gallery, announced instead of its filename.
let position: Int
let mediaProvider: MediaProviderProtocol?
let contentScannerService: ContentScannerServiceProtocol?
let overflowCount: Int
let onTap: () -> Void
private var accessibilityLabel: String {
if overflowCount > 0 {
L10n.a11yGalleryMoreMedia(overflowCount)
} else if item.isVideo {
L10n.a11yGalleryVideo(position)
} else if item.isImage {
L10n.a11yGalleryImage(position)
} else {
L10n.a11yGalleryAttachment(position)
}
}
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
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
} scanningContent: {
ScanningMediaEventsTimelineView()
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.a11yGalleryScanning(position))
} unsafeContent: { failure in
UnsafeMediaEventsTimelineView(failure: failure)
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.a11yGalleryFailedScan(position))
}
if overflowCount > 0 {
overflowOverlay
}
}
// Tappable in every scan state, so that an unsafe item can't hide the rest of the gallery.
.contentShape(Rectangle())
.onTapGesture { onTap() }
// One element per tile, labelled by whichever state is on show.
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
}
@ViewBuilder
private var content: some View {
if let source = item.thumbnailSource ?? item.mediaSource {
LoadableImage(mediaSource: source,
mediaType: .timelineItem(uniqueID: item.id.timelineItemID.uniqueID),
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)
}
.allowsHitTesting(false) // Let the tap fall through to the tile below.
.accessibilityHidden(true) // The count is already announced by the tile's label.
}
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))
}
}