Render image and video in galleries as a grid (#5929)

This commit is contained in:
Mauro
2026-07-24 18:47:39 +02:00
committed by GitHub
parent 5d0343c480
commit 274da99787
39 changed files with 464 additions and 5 deletions
@@ -191,6 +191,10 @@ extension AccessibilityTests {
try await performAccessibilityAudit(named: "FullscreenDialog_Previews")
}
func testGalleryRoomTimelineView() async throws {
try await performAccessibilityAudit(named: "GalleryRoomTimelineView_Previews")
}
func testHighlightedTimelineItemModifier() async throws {
try await performAccessibilityAudit(named: "HighlightedTimelineItemModifier_Previews")
}
+8
View File
@@ -508,6 +508,7 @@
522269133E6F65F68482F4F4 /* RemotePreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 181CF280BC8E3F335AFCB4B8 /* RemotePreferenceTests.swift */; };
52473A4D7B1FBD4CD1E770C8 /* MatrixEntityRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */; };
52511848C094F3097F858F00 /* AvatarSettingsButtonLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3DA6F684A8FD5A70644CDA /* AvatarSettingsButtonLabel.swift */; };
52BE121FC21CE88803D2D8B5 /* GalleryItemTileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7927284C6F70D60BE5949F8C /* GalleryItemTileView.swift */; };
5341D48F833E3E30F16FA2A3 /* SeparatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2910422CB628D3B2BBE47449 /* SeparatorRoomTimelineView.swift */; };
5375902175B2FEA2949D7D74 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDDDDD9FE1A699D23A5E096 /* LoginScreen.swift */; };
53A55748D5F587C9061F98BF /* ServerConfigurationScreenViewStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */; };
@@ -806,6 +807,7 @@
82434593648CB74121F1A821 /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C513A6CD99E6C3C163DA1E /* RowDivider.swift */; };
8285FF4B2C2331758C437FF7 /* ReportContentScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713B48DBF65DE4B0DD445D66 /* ReportContentScreenViewModelProtocol.swift */; };
828EA5009557C2B9DCD4CA0F /* UserDiscoverySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */; };
82D1368B43020B55347E1743 /* GalleryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEE0273EC14A781BBF715E37 /* GalleryGridView.swift */; };
832A4EA1094B8FE423A08700 /* RoomChangeRolesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2A421198FD20AAAED20004 /* RoomChangeRolesScreen.swift */; };
8358D145F9BF94F412BEDCA8 /* RoomRolesAndPermissionsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE7969EBCAF078813E18EA1 /* RoomRolesAndPermissionsScreenModels.swift */; };
83A01FE4DACABDE97F04D338 /* PollTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBD507F529D440215892618A /* PollTests.swift */; };
@@ -2351,6 +2353,7 @@
78910787F967CBC6042A101E /* StartChatScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenViewModelProtocol.swift; sourceTree = "<group>"; };
78913D6E120D46138E97C107 /* NavigationSplitCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSplitCoordinatorTests.swift; sourceTree = "<group>"; };
78BBDF7A05CF53B5CDC13682 /* landscape_test_video.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = landscape_test_video.mov; sourceTree = "<group>"; };
7927284C6F70D60BE5949F8C /* GalleryItemTileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryItemTileView.swift; sourceTree = "<group>"; };
79351405CD99312A182B2488 /* NavigationCoordinators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinators.swift; sourceTree = "<group>"; };
796CBD0C56FA0D3AEDAB255B /* SessionVerificationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenCoordinator.swift; sourceTree = "<group>"; };
79A106D76741914F82664959 /* StateStoreViewModelV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateStoreViewModelV2.swift; sourceTree = "<group>"; };
@@ -3045,6 +3048,7 @@
EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
EEAB5662310AE73D93815134 /* JoinRoomScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinRoomScreenViewModelProtocol.swift; sourceTree = "<group>"; };
EED6D8956E554CEDFD4FE00D /* LocationSharingScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSharingScreenViewModelTests.swift; sourceTree = "<group>"; };
EEE0273EC14A781BBF715E37 /* GalleryGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryGridView.swift; sourceTree = "<group>"; };
EF13BFD415CA84B1272E94F8 /* PINTextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PINTextFieldTests.swift; sourceTree = "<group>"; };
EF1593DD87F974F8509BB619 /* ElementAnimations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementAnimations.swift; sourceTree = "<group>"; };
EF36FC3DE25B20B7FA91F1FD /* SpaceServiceProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceServiceProxyMock.swift; sourceTree = "<group>"; };
@@ -6580,6 +6584,8 @@
2F06F70B9C433BAD4BC6B9F5 /* EncryptedRoomTimelineView.swift */,
4F75EF13F49DD2204E760910 /* FileRoomTimelineView.swift */,
C258C9C815272911A5B132C3 /* FormattedBodyText.swift */,
EEE0273EC14A781BBF715E37 /* GalleryGridView.swift */,
7927284C6F70D60BE5949F8C /* GalleryItemTileView.swift */,
A04EFA0E3139C01E1CFDC93B /* GalleryRoomTimelineView.swift */,
59B7CC77B82C6C67DE3AD869 /* HighlightedTimelineItemModifier.swift */,
C5599255A6C98EBDA77B76E6 /* ImageRoomTimelineView.swift */,
@@ -8553,6 +8559,8 @@
7807B1DEE32617896886A8E5 /* FormattingToolbar.swift in Sources */,
5705511EBE083295EF98F998 /* FrequentlyUsedEmoji.swift in Sources */,
46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */,
82D1368B43020B55347E1743 /* GalleryGridView.swift in Sources */,
52BE121FC21CE88803D2D8B5 /* GalleryItemTileView.swift in Sources */,
E08DFC18E97623B5C91C5025 /* GalleryRoomTimelineItem.swift in Sources */,
1CA6E1843E1B3680544EB584 /* GalleryRoomTimelineItemContent.swift in Sources */,
30DA1A668482E5B95E8F1F2B /* GalleryRoomTimelineView.swift in Sources */,
@@ -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))
}
}
@@ -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 {
@@ -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))
}
}
@@ -333,6 +333,13 @@ extension PreviewTests {
}
}
@Test
func galleryRoomTimelineView() async throws {
for (index, preview) in GalleryRoomTimelineView_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
@Test
func highlightedTimelineItemModifier() async throws {
for (index, preview) in HighlightedTimelineItemModifier_Previews._allPreviews.enumerated() {
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e5f31021d11d8e53119bf7febad50475e41c1327ff05bc1e2e8f6d151a103ddc
size 371201
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:79e8a2f985ffc4690e63b8478c1e900df6877f8e2a35ca9796dea23439527cbe
size 372020
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:495adb9600e195aa7b9cf772af17b56b39cc5d32bf42b345c13fa3fdb9a84fd5
size 308458
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d9985b73dd6ca638ac0f6961e9044bb6e35daadfff5388506ebecde01dbe353a
size 309156
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:29f2e2d8c3fe8642686575086e4b95eba032823e37f5b755bd55a269dbffc35f
size 723805
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f9cea732e6f7704104bd7616c140ba25788fa7a9839da2ab23468b0c1da34a52
size 724685
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:82ed919fc7942db61311f0925f5754c5a8ae0cbb0bcb27b6a9297bb29dfd6818
size 637438
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d87f15ebadcf4c2ff2d3b42284977bdd717ab5092588dda0bac33278b657158
size 638144
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8e52d44da473e8921b9d1281f478ee1e540759788dba5f388a3844545a056bc
size 550029
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1c4bec28174ceccd1ea3035783337265b37eda4ae25ece2a8a6ca1a18b23fa1a
size 550916
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5032aa33467a3881365e45e86195109d7b8aaef241b86a8e559fb28bb6fdcfae
size 475154
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aa7f170f0505b05163bd6e24d85259b37fb176b56204e7ce84400bd71d454f64
size 475688
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7209f4b9ca0fb84c07103595b0c1fd631addd4fe9a87f8bd153d793841edb06b
size 721052
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:20e1c0d65f269f1794bd736e79e9bde9f4dd3889c08cb6dc5c0ed20ee55ba9d2
size 721789
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:19ad937d5564046f54b6606a27a48dfbf8ad678f9a6cf7bccd46df0f78a99f75
size 638676
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:73267f763aa665c66dc259c0efc283574ee0f259bf807ca6380039dc70f72e04
size 639372
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ac2ed67a5d4cacd01c62a3480ca45bdbd9743b83ede884526ba9aae5b9775ff0
size 542384
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb02ee47b306c1994cd64c206577edc2a059967167a1126a8032ea5d0c5faed6
size 543235
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dc0723da2bfb6a365572ce0484987e97e49e8284582cbd5ecfc632c44551f004
size 473528
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b300cee1280e07383de5765d64b1d166a24e34db054a99b12c4b13d4bcee554f
size 474205
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9c7ba5f7bdf6769eb1f99f3639da2bda85aace208e34b8e3905dc68d069b66a8
size 601521
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f079d9a752aba682062d1ffca1b1beb70e03d33618d1e9bf5a0f81ac502121a2
size 601945
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:08f3281105fea0fea17e890eae894b66373655622453b700831c34b54118c5d2
size 527254
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:596177f4370c0fc343675985bb225657dc1dff34a8f6d37c37d5ed6793a1d391
size 527364
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f1f2ea4e07e2fa5f4a899c6019e11c4b2ed6ad5fcde8b07f10901ae16ced804
size 342544
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2c81ebbe64a9965aa7b3a1770675b912278f6968b8384bcf0266693fe269bd66
size 344571
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a056ea3bdf2c82c715384414a4ee14b0ac164ba761861b513cf8945cefca3e7e
size 285296
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6cfe00c8cb7b2efbd836e10ef3a1bbbd7d08fe8cfc36d5ad276f90d0fe41d70f
size 286967