Add the gallery timeline item rendered as a placeholder

Introduces the GalleryRoomTimelineItem and its typed GalleryItem model (reusing the existing per-message content types), builds it in the RoomTimelineItemFactory and routes it through RoomTimelineItemView. For now it renders as a placeholder; the grid/list media views and the full-screen preview land in follow-up PRs.

Co-authored-by: svetoslav.mitov <svetoslav.mitov@sportinno.com>
This commit is contained in:
Mauro Romito
2026-07-24 11:10:17 +02:00
committed by Mauro
parent 3796fdc966
commit 4a9cd02fbc
12 changed files with 289 additions and 22 deletions
+12
View File
@@ -197,6 +197,7 @@
1C8BC70A18060677E295A846 /* ShareToMapsAppActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */; }; 1C8BC70A18060677E295A846 /* ShareToMapsAppActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */; };
1C9BB74711E5F24C77B7FED0 /* RoomMembersListScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */; }; 1C9BB74711E5F24C77B7FED0 /* RoomMembersListScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */; };
1CA094038D4D036A6F0A1314 /* VoiceMessageTrashButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */; }; 1CA094038D4D036A6F0A1314 /* VoiceMessageTrashButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABF84AA68B2B7584D9275769 /* VoiceMessageTrashButton.swift */; };
1CA6E1843E1B3680544EB584 /* GalleryRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A5AB4E2E5CA98F99BFC552 /* GalleryRoomTimelineItemContent.swift */; };
1D2D713A5A269072D103AE37 /* CLLocationManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA46D6DD4B4AB17E1D45092E /* CLLocationManagerProtocol.swift */; }; 1D2D713A5A269072D103AE37 /* CLLocationManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA46D6DD4B4AB17E1D45092E /* CLLocationManagerProtocol.swift */; };
1D5DC685CED904386C89B7DA /* NSRegularExpresion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */; }; 1D5DC685CED904386C89B7DA /* NSRegularExpresion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */; };
1D623953F970D11F6F38499C /* AppLockService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851B95BB98649B8E773D6790 /* AppLockService.swift */; }; 1D623953F970D11F6F38499C /* AppLockService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851B95BB98649B8E773D6790 /* AppLockService.swift */; };
@@ -307,6 +308,7 @@
3042527CB344A9EF1157FC26 /* AudioRecorderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55CC239AE12339C565F6C9A /* AudioRecorderStateTests.swift */; }; 3042527CB344A9EF1157FC26 /* AudioRecorderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55CC239AE12339C565F6C9A /* AudioRecorderStateTests.swift */; };
308BD9343B95657FAA583FB7 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 2B788C81F6369D164ADEB917 /* GZIP */; }; 308BD9343B95657FAA583FB7 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 2B788C81F6369D164ADEB917 /* GZIP */; };
30CC1DB7CE357659C82AA115 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */; }; 30CC1DB7CE357659C82AA115 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EB16E7FE59A947CA441531 /* MediaProviderProtocol.swift */; };
30DA1A668482E5B95E8F1F2B /* GalleryRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04EFA0E3139C01E1CFDC93B /* GalleryRoomTimelineView.swift */; };
30E5628F74AD3C27A061BF25 /* QRCodeLoginScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC1B7CB061C9865B2B91B56 /* QRCodeLoginScreenViewModel.swift */; }; 30E5628F74AD3C27A061BF25 /* QRCodeLoginScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC1B7CB061C9865B2B91B56 /* QRCodeLoginScreenViewModel.swift */; };
311868F5E65BA61AFA6CCC2C /* CompoundHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B89D6C760E8CAE29CA28FB1 /* CompoundHook.swift */; }; 311868F5E65BA61AFA6CCC2C /* CompoundHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B89D6C760E8CAE29CA28FB1 /* CompoundHook.swift */; };
3118D9ABFD4BE5A3492FF88A /* ElementCallConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC437C491EA6996513B1CEAB /* ElementCallConfiguration.swift */; }; 3118D9ABFD4BE5A3492FF88A /* ElementCallConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC437C491EA6996513B1CEAB /* ElementCallConfiguration.swift */; };
@@ -1362,6 +1364,7 @@
DFF7D6A6C26DDD40D00AE579 /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = F012CB5EE3F2B67359F6CC52 /* target.yml */; }; DFF7D6A6C26DDD40D00AE579 /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = F012CB5EE3F2B67359F6CC52 /* target.yml */; };
E010DDE938032D3B8E84CC35 /* TracingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */; }; E010DDE938032D3B8E84CC35 /* TracingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */; };
E02DAD9FD8D62587049FFFEC /* LinkNewDeviceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F04B12FA231E797B7151A8 /* LinkNewDeviceTests.swift */; }; E02DAD9FD8D62587049FFFEC /* LinkNewDeviceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F04B12FA231E797B7151A8 /* LinkNewDeviceTests.swift */; };
E08DFC18E97623B5C91C5025 /* GalleryRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E994322F10D20ECE68683683 /* GalleryRoomTimelineItem.swift */; };
E0B6A569AC3E81D233B43D60 /* SettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E625B0EB2F86B37C14EF7E6 /* SettingsScreenViewModel.swift */; }; E0B6A569AC3E81D233B43D60 /* SettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E625B0EB2F86B37C14EF7E6 /* SettingsScreenViewModel.swift */; };
E0C167D41A48EDB30B447DE3 /* VoiceMessageRecordingComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A5C3F7C9C1DA10CAEC6A98 /* VoiceMessageRecordingComposer.swift */; }; E0C167D41A48EDB30B447DE3 /* VoiceMessageRecordingComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A5C3F7C9C1DA10CAEC6A98 /* VoiceMessageRecordingComposer.swift */; };
E1428612B08ED3030EC1FEC3 /* SpacesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104B7747499487538483FEAF /* SpacesScreen.swift */; }; E1428612B08ED3030EC1FEC3 /* SpacesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104B7747499487538483FEAF /* SpacesScreen.swift */; };
@@ -1980,6 +1983,7 @@
3558A15CFB934F9229301527 /* RestorationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorationToken.swift; sourceTree = "<group>"; }; 3558A15CFB934F9229301527 /* RestorationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorationToken.swift; sourceTree = "<group>"; };
358528B29FA72ACFD0D9644B /* SpacesScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacesScreenCoordinator.swift; sourceTree = "<group>"; }; 358528B29FA72ACFD0D9644B /* SpacesScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacesScreenCoordinator.swift; sourceTree = "<group>"; };
35A057BA9BE0F079784CD061 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 35A057BA9BE0F079784CD061 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
35A5AB4E2E5CA98F99BFC552 /* GalleryRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRoomTimelineItemContent.swift; sourceTree = "<group>"; };
35AFCF4C05DEED04E3DB1A16 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; }; 35AFCF4C05DEED04E3DB1A16 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
36DA824791172B9821EACBED /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 36DA824791172B9821EACBED /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxyMock.swift; sourceTree = "<group>"; }; 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxyMock.swift; sourceTree = "<group>"; };
@@ -2579,6 +2583,7 @@
A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenViewModel.swift; sourceTree = "<group>"; }; A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenViewModel.swift; sourceTree = "<group>"; };
A019A12C866D64CF072024B9 /* AppLockSetupPINScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModel.swift; sourceTree = "<group>"; }; A019A12C866D64CF072024B9 /* AppLockSetupPINScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModel.swift; sourceTree = "<group>"; };
A02D1A490944BF01A37586E1 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/SAS.strings; sourceTree = "<group>"; }; A02D1A490944BF01A37586E1 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/SAS.strings; sourceTree = "<group>"; };
A04EFA0E3139C01E1CFDC93B /* GalleryRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRoomTimelineView.swift; sourceTree = "<group>"; };
A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerProtocol.swift; sourceTree = "<group>"; }; A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerProtocol.swift; sourceTree = "<group>"; };
A07B011547B201A836C03052 /* EncryptionResetFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetFlowCoordinator.swift; sourceTree = "<group>"; }; A07B011547B201A836C03052 /* EncryptionResetFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetFlowCoordinator.swift; sourceTree = "<group>"; };
A0E7B059E84E7E374D3322A2 /* SpaceRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomCell.swift; sourceTree = "<group>"; }; A0E7B059E84E7E374D3322A2 /* SpaceRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomCell.swift; sourceTree = "<group>"; };
@@ -3000,6 +3005,7 @@
E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = "<group>"; }; E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = "<group>"; };
E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevelsProxyMock.swift; sourceTree = "<group>"; }; E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevelsProxyMock.swift; sourceTree = "<group>"; };
E992D7B8BE54B2AB454613AF /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = "<group>"; }; E992D7B8BE54B2AB454613AF /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = "<group>"; };
E994322F10D20ECE68683683 /* GalleryRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRoomTimelineItem.swift; sourceTree = "<group>"; };
E9A3D3CFA199FA7897364547 /* CallInviteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallInviteRoomTimelineItem.swift; sourceTree = "<group>"; }; E9A3D3CFA199FA7897364547 /* CallInviteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallInviteRoomTimelineItem.swift; sourceTree = "<group>"; };
E9B796D347E53631576F631C /* PreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewTests.swift; sourceTree = "<group>"; }; E9B796D347E53631576F631C /* PreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewTests.swift; sourceTree = "<group>"; };
E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
@@ -5500,6 +5506,8 @@
EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */, EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */,
5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */, 5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */,
216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */, 216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */,
E994322F10D20ECE68683683 /* GalleryRoomTimelineItem.swift */,
35A5AB4E2E5CA98F99BFC552 /* GalleryRoomTimelineItemContent.swift */,
3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */, 3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */,
B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */, B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */,
8A8DCBD0ABAADFDE5AF17E1F /* LiveLocationRoomTimelineItem.swift */, 8A8DCBD0ABAADFDE5AF17E1F /* LiveLocationRoomTimelineItem.swift */,
@@ -6572,6 +6580,7 @@
2F06F70B9C433BAD4BC6B9F5 /* EncryptedRoomTimelineView.swift */, 2F06F70B9C433BAD4BC6B9F5 /* EncryptedRoomTimelineView.swift */,
4F75EF13F49DD2204E760910 /* FileRoomTimelineView.swift */, 4F75EF13F49DD2204E760910 /* FileRoomTimelineView.swift */,
C258C9C815272911A5B132C3 /* FormattedBodyText.swift */, C258C9C815272911A5B132C3 /* FormattedBodyText.swift */,
A04EFA0E3139C01E1CFDC93B /* GalleryRoomTimelineView.swift */,
59B7CC77B82C6C67DE3AD869 /* HighlightedTimelineItemModifier.swift */, 59B7CC77B82C6C67DE3AD869 /* HighlightedTimelineItemModifier.swift */,
C5599255A6C98EBDA77B76E6 /* ImageRoomTimelineView.swift */, C5599255A6C98EBDA77B76E6 /* ImageRoomTimelineView.swift */,
52947F8F356F0BA5B1F50912 /* LiveLocationRoomTimelineView.swift */, 52947F8F356F0BA5B1F50912 /* LiveLocationRoomTimelineView.swift */,
@@ -8544,6 +8553,9 @@
7807B1DEE32617896886A8E5 /* FormattingToolbar.swift in Sources */, 7807B1DEE32617896886A8E5 /* FormattingToolbar.swift in Sources */,
5705511EBE083295EF98F998 /* FrequentlyUsedEmoji.swift in Sources */, 5705511EBE083295EF98F998 /* FrequentlyUsedEmoji.swift in Sources */,
46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */, 46BA7F4B4D3A7164DED44B88 /* FullscreenDialog.swift in Sources */,
E08DFC18E97623B5C91C5025 /* GalleryRoomTimelineItem.swift in Sources */,
1CA6E1843E1B3680544EB584 /* GalleryRoomTimelineItemContent.swift in Sources */,
30DA1A668482E5B95E8F1F2B /* GalleryRoomTimelineView.swift in Sources */,
F18CA61A58C77C84F551B8E7 /* GeneratedMocks.swift in Sources */, F18CA61A58C77C84F551B8E7 /* GeneratedMocks.swift in Sources */,
4295E5F850897710A51AE114 /* GeoURI.swift in Sources */, 4295E5F850897710A51AE114 /* GeoURI.swift in Sources */,
F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */, F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */,
@@ -95,7 +95,7 @@ struct SearchScreenMessage: Identifiable, Equatable {
content.formattedBody ?? AttributedString(content.body) content.formattedBody ?? AttributedString(content.body)
case .emote(let content): case .emote(let content):
content.formattedBody ?? AttributedString(content.body) content.formattedBody ?? AttributedString(content.body)
case .audio, .file, .image, .video: case .audio, .file, .image, .video, .gallery:
nil nil
case .voice: case .voice:
AttributedString(L10n.commonVoiceMessage) AttributedString(L10n.commonVoiceMessage)
@@ -130,7 +130,7 @@ struct SearchScreenMessage: Identifiable, Equatable {
return .init(title: content.caption ?? content.filename, return .init(title: content.caption ?? content.filename,
details: mediaDetails(filename: content.filename, fileSize: content.videoInfo.fileSize), details: mediaDetails(filename: content.filename, fileSize: content.videoInfo.fileSize),
kind: .video(thumbnail: content.thumbnailInfo, blurhash: content.blurhash)) kind: .video(thumbnail: content.thumbnailInfo, blurhash: content.blurhash))
case .text, .notice, .emote, .voice, .location: case .text, .notice, .emote, .voice, .location, .gallery:
return nil return nil
} }
} }
@@ -129,6 +129,13 @@ struct TimelineReplyView: View {
plainBody: L10n.commonSharedLocation, plainBody: L10n.commonSharedLocation,
formattedBody: nil, formattedBody: nil,
icon: .init(kind: .icon(\.locationPin))) icon: .init(kind: .icon(\.locationPin)))
case .gallery(let content):
ReplyView(sender: sender,
plainBody: content.caption ?? content.body,
formattedBody: content.formattedCaption,
icon: content.items.first?.thumbnailSource.map { .init(kind: .mediaSource($0)) }
?? content.items.first?.mediaSource.map { .init(kind: .mediaSource($0)) }
?? .init(kind: .icon(\.attachment)))
} }
case .poll(let question): case .poll(let question):
ReplyView(sender: sender, ReplyView(sender: sender,
@@ -82,6 +82,12 @@ struct TimelineThreadSummaryView: View {
plainBody: L10n.commonSharedLocation, plainBody: L10n.commonSharedLocation,
formattedBody: nil, formattedBody: nil,
numberOfReplies: numberOfReplies) numberOfReplies: numberOfReplies)
case .gallery(let content):
ThreadView(senderID: senderID,
sender: sender,
plainBody: content.caption ?? content.body,
formattedBody: content.formattedCaption,
numberOfReplies: numberOfReplies)
} }
case .poll(let question): case .poll(let question):
ThreadView(senderID: senderID, ThreadView(senderID: senderID,
@@ -0,0 +1,22 @@
//
// 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 GalleryRoomTimelineView: View {
let timelineItem: GalleryRoomTimelineItem
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,
trailingReservedSize: timelineItem.trailingReservedSize,
boostFontSize: timelineItem.shouldBoost)
}
}
}
@@ -18,6 +18,7 @@ nonisolated enum EventBasedMessageTimelineItemContentType: Hashable {
case video(VideoRoomTimelineItemContent) case video(VideoRoomTimelineItemContent)
case location(LocationRoomTimelineItemContent) case location(LocationRoomTimelineItemContent)
case voice(AudioRoomTimelineItemContent) case voice(AudioRoomTimelineItemContent)
case gallery(GalleryRoomTimelineItemContent)
} }
nonisolated protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol { nonisolated protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol {
@@ -27,7 +28,7 @@ nonisolated protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineIt
nonisolated extension EventBasedMessageTimelineItemProtocol { nonisolated extension EventBasedMessageTimelineItemProtocol {
var supportsMediaCaption: Bool { var supportsMediaCaption: Bool {
switch contentType { switch contentType {
case .audio, .file, .image, .video: case .audio, .file, .image, .video, .gallery:
true true
case .emote, .notice, .text, .location, .voice: case .emote, .notice, .text, .location, .voice:
false false
@@ -48,6 +49,8 @@ nonisolated extension EventBasedMessageTimelineItemProtocol {
content.caption content.caption
case .video(let content): case .video(let content):
content.caption content.caption
case .gallery(let content):
content.caption
case .emote, .notice, .text, .location, .voice: case .emote, .notice, .text, .location, .voice:
nil nil
} }
@@ -63,6 +66,8 @@ nonisolated extension EventBasedMessageTimelineItemProtocol {
content.formattedCaption content.formattedCaption
case .video(let content): case .video(let content):
content.formattedCaption content.formattedCaption
case .gallery(let content):
content.formattedCaption
case .emote, .notice, .text, .location, .voice: case .emote, .notice, .text, .location, .voice:
nil nil
} }
@@ -103,7 +103,7 @@ nonisolated extension EventBasedTimelineItemProtocol {
} }
switch messageBasedItem.contentType { switch messageBasedItem.contentType {
case .audio, .file, .image, .video, .location, .voice: case .audio, .file, .image, .video, .location, .voice, .gallery:
return false return false
case .text, .emote, .notice: case .text, .emote, .notice:
return true return true
@@ -0,0 +1,31 @@
//
// 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 UIKit
nonisolated struct GalleryRoomTimelineItem: EventBasedMessageTimelineItemProtocol, Equatable {
let id: TimelineItemIdentifier
let timestamp: Date
let isOutgoing: Bool
let isEditable: Bool
let canBeRepliedTo: Bool
var shouldBoost = false
let sender: TimelineItemSender
let content: GalleryRoomTimelineItemContent
var properties = RoomTimelineItemProperties()
var body: String {
content.caption ?? content.body
}
var contentType: EventBasedMessageTimelineItemContentType {
.gallery(content)
}
}
@@ -0,0 +1,145 @@
//
// 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 Foundation
import UniformTypeIdentifiers
nonisolated struct GalleryRoomTimelineItemContent: Hashable {
let body: String
var caption: String?
var formattedCaption: AttributedString?
/// The original textual representation of the formatted caption directly from the event (usually HTML code)
var formattedCaptionHTMLString: String?
let items: [GalleryItem]
}
/// Identifies a single attachment within a gallery message.
nonisolated struct GalleryItemID: Hashable {
/// The unique ID of the parent gallery timeline item.
let timelineItemUniqueID: TimelineItemIdentifier.UniqueID
/// The item's position within the gallery.
let mediaIndex: Int
}
/// A single attachment of a gallery message. The SDK hands back the same content types it uses for
/// individual messages, so we reuse them here rather than duplicating their fields.
nonisolated enum GalleryItem: Hashable, Identifiable {
case image(id: GalleryItemID, ImageRoomTimelineItemContent)
case video(id: GalleryItemID, VideoRoomTimelineItemContent)
case audio(id: GalleryItemID, AudioRoomTimelineItemContent)
case file(id: GalleryItemID, FileRoomTimelineItemContent)
/// An item of an unknown type. It has no media source, so there's nothing to preview.
case other(id: GalleryItemID, filename: String)
var id: GalleryItemID {
switch self {
case .image(let id, _), .video(let id, _), .audio(let id, _), .file(let id, _), .other(let id, _):
id
}
}
var isImage: Bool {
if case .image = self {
true
} else {
false
}
}
var isVideo: Bool {
if case .video = self {
true
} else {
false
}
}
var isAudio: Bool {
if case .audio = self {
true
} else {
false
}
}
var isFile: Bool {
if case .file = self {
true
} else {
false
}
}
var filename: String {
switch self {
case .image(_, let content): content.filename
case .video(_, let content): content.filename
case .audio(_, let content): content.filename
case .file(_, let content): content.filename
case .other(_, let filename): filename
}
}
var mediaSource: MediaSourceProxy? {
switch self {
case .image(_, let content): content.imageInfo.source
case .video(_, let content): content.videoInfo.source
case .audio(_, let content): content.source
case .file(_, let content): content.source
case .other: nil
}
}
var thumbnailSource: MediaSourceProxy? {
switch self {
case .image(_, let content): content.thumbnailInfo?.source
case .video(_, let content): content.thumbnailInfo?.source
case .file(_, let content): content.thumbnailSource
case .audio, .other: nil
}
}
var size: CGSize? {
switch self {
case .image(_, let content): content.imageInfo.size
case .video(_, let content): content.videoInfo.size
case .audio, .file, .other: nil
}
}
var blurhash: String? {
switch self {
case .image(_, let content): content.blurhash
case .video(_, let content): content.blurhash
case .audio, .file, .other: nil
}
}
var duration: TimeInterval? {
switch self {
case .video(_, let content): content.videoInfo.duration
case .audio(_, let content): content.duration
case .image, .file, .other: nil
}
}
var contentType: UTType? {
switch self {
case .image(_, let content): content.contentType
case .video(_, let content): content.contentType
case .audio(_, let content): content.contentType
case .file(_, let content): content.contentType
case .other: nil
}
}
/// Whether the item can render a visible thumbnail. Images always can (their media source
/// IS the image); for everything else we need an explicit thumbnail source.
var hasThumbnail: Bool {
isImage || thumbnailSource != nil
}
}
@@ -149,7 +149,7 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
case .location(let content): case .location(let content):
.location(buildLocationTimelineItemContent(content)) .location(buildLocationTimelineItemContent(content))
case .gallery(let content): case .gallery(let content):
.text(.init(body: content.body)) .gallery(buildGalleryTimelineItemContent(content, uniqueID: .init(senderID)))
case .other(_, let body): case .other(_, let body):
.text(.init(body: body)) .text(.init(body: body))
case .none: case .none:
@@ -410,23 +410,54 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
_ messageContent: MessageContent, _ messageContent: MessageContent,
_ galleryMessageContent: GalleryMessageContent, _ galleryMessageContent: GalleryMessageContent,
_ isOutgoing: Bool) -> RoomTimelineItemProtocol { _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
TextRoomTimelineItem(id: eventItemProxy.id, GalleryRoomTimelineItem(id: eventItemProxy.id,
timestamp: eventItemProxy.timestamp, timestamp: eventItemProxy.timestamp,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
canBeRepliedTo: eventItemProxy.canBeRepliedTo, canBeRepliedTo: eventItemProxy.canBeRepliedTo,
shouldBoost: eventItemProxy.shouldBoost, shouldBoost: eventItemProxy.shouldBoost,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
content: .init(body: galleryMessageContent.body), content: buildGalleryTimelineItemContent(galleryMessageContent, uniqueID: eventItemProxy.id.uniqueID),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil, isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
isEdited: messageContent.isEdited, isEdited: messageContent.isEdited,
reactions: buildAggregatedReactions(messageLikeContent.reactions), reactions: buildAggregatedReactions(messageLikeContent.reactions),
deliveryStatus: eventItemProxy.deliveryStatus, deliveryStatus: eventItemProxy.deliveryStatus,
orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts),
encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState),
encryptionForwarder: eventItemProxy.forwarder)) encryptionForwarder: eventItemProxy.forwarder))
}
private func buildGalleryTimelineItemContent(_ messageContent: GalleryMessageContent, uniqueID: TimelineItemIdentifier.UniqueID) -> GalleryRoomTimelineItemContent {
let htmlCaption = messageContent.formatted?.format == .html ? messageContent.formatted?.body : nil
let plainCaption = messageContent.formatted?.format != .html ? messageContent.formatted?.body : nil
let formattedCaption = htmlCaption != nil ? attributedStringBuilder.fromHTML(htmlCaption) : (plainCaption.flatMap(attributedStringBuilder.fromPlain))
let items = messageContent.itemtypes.enumerated().map { index, itemType in
buildGalleryItem(itemType, id: GalleryItemID(timelineItemUniqueID: uniqueID, mediaIndex: index))
}
return GalleryRoomTimelineItemContent(body: messageContent.body,
caption: plainCaption ?? messageContent.body,
formattedCaption: formattedCaption,
formattedCaptionHTMLString: htmlCaption,
items: items)
}
private func buildGalleryItem(_ itemType: GalleryItemType, id: GalleryItemID) -> GalleryItem {
switch itemType {
case .image(let content):
.image(id: id, buildImageTimelineItemContent(content))
case .video(let content):
.video(id: id, buildVideoTimelineItemContent(content))
case .audio(let content):
.audio(id: id, buildAudioTimelineItemContent(content))
case .file(let content):
.file(id: id, buildFileTimelineItemContent(content))
case .other(_, let body):
.other(id: id, filename: body)
}
} }
private func buildStickerTimelineItem(_ eventItemProxy: EventTimelineItemProxy, private func buildStickerTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
@@ -40,6 +40,8 @@ struct RoomTimelineItemView: View {
AudioRoomTimelineView(timelineItem: item) AudioRoomTimelineView(timelineItem: item)
case .file(let item): case .file(let item):
FileRoomTimelineView(timelineItem: item) FileRoomTimelineView(timelineItem: item)
case .gallery(let item):
GalleryRoomTimelineView(timelineItem: item)
case .emote(let item): case .emote(let item):
EmoteRoomTimelineView(timelineItem: item) EmoteRoomTimelineView(timelineItem: item)
case .notice(let item): case .notice(let item):
@@ -53,6 +53,7 @@ enum RoomTimelineItemType: Equatable {
case video(VideoRoomTimelineItem) case video(VideoRoomTimelineItem)
case audio(AudioRoomTimelineItem) case audio(AudioRoomTimelineItem)
case file(FileRoomTimelineItem) case file(FileRoomTimelineItem)
case gallery(GalleryRoomTimelineItem)
case emote(EmoteRoomTimelineItem) case emote(EmoteRoomTimelineItem)
case notice(NoticeRoomTimelineItem) case notice(NoticeRoomTimelineItem)
case redacted(RedactedRoomTimelineItem) case redacted(RedactedRoomTimelineItem)
@@ -83,6 +84,8 @@ enum RoomTimelineItemType: Equatable {
self = .audio(item) self = .audio(item)
case let item as FileRoomTimelineItem: case let item as FileRoomTimelineItem:
self = .file(item) self = .file(item)
case let item as GalleryRoomTimelineItem:
self = .gallery(item)
case let item as SeparatorRoomTimelineItem: case let item as SeparatorRoomTimelineItem:
self = .separator(item) self = .separator(item)
case let item as NoticeRoomTimelineItem: case let item as NoticeRoomTimelineItem:
@@ -132,6 +135,7 @@ enum RoomTimelineItemType: Equatable {
.video(let item as RoomTimelineItemProtocol), .video(let item as RoomTimelineItemProtocol),
.audio(let item as RoomTimelineItemProtocol), .audio(let item as RoomTimelineItemProtocol),
.file(let item as RoomTimelineItemProtocol), .file(let item as RoomTimelineItemProtocol),
.gallery(let item as RoomTimelineItemProtocol),
.emote(let item as RoomTimelineItemProtocol), .emote(let item as RoomTimelineItemProtocol),
.notice(let item as RoomTimelineItemProtocol), .notice(let item as RoomTimelineItemProtocol),
.redacted(let item as RoomTimelineItemProtocol), .redacted(let item as RoomTimelineItemProtocol),
@@ -171,6 +175,8 @@ enum RoomTimelineItemType: Equatable {
return item.timestamp return item.timestamp
case .file(let item): case .file(let item):
return item.timestamp return item.timestamp
case .gallery(let item):
return item.timestamp
case .emote(let item): case .emote(let item):
return item.timestamp return item.timestamp
case .notice(let item): case .notice(let item):