diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 81955e969..cce3cde63 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -197,6 +197,7 @@ 1C8BC70A18060677E295A846 /* ShareToMapsAppActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */; }; 1C9BB74711E5F24C77B7FED0 /* RoomMembersListScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.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 */; }; 1D5DC685CED904386C89B7DA /* NSRegularExpresion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.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 */; }; 308BD9343B95657FAA583FB7 /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 2B788C81F6369D164ADEB917 /* GZIP */; }; 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 */; }; 311868F5E65BA61AFA6CCC2C /* CompoundHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B89D6C760E8CAE29CA28FB1 /* CompoundHook.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 */; }; E010DDE938032D3B8E84CC35 /* TracingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A95C9B8299A36A6495DECA6 /* TracingHook.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 */; }; E0C167D41A48EDB30B447DE3 /* VoiceMessageRecordingComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A5C3F7C9C1DA10CAEC6A98 /* VoiceMessageRecordingComposer.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 = ""; }; 358528B29FA72ACFD0D9644B /* SpacesScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpacesScreenCoordinator.swift; sourceTree = ""; }; 35A057BA9BE0F079784CD061 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 35A5AB4E2E5CA98F99BFC552 /* GalleryRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRoomTimelineItemContent.swift; sourceTree = ""; }; 35AFCF4C05DEED04E3DB1A16 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; 36DA824791172B9821EACBED /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxyMock.swift; sourceTree = ""; }; @@ -2579,6 +2583,7 @@ A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenViewModel.swift; sourceTree = ""; }; A019A12C866D64CF072024B9 /* AppLockSetupPINScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModel.swift; sourceTree = ""; }; A02D1A490944BF01A37586E1 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/SAS.strings; sourceTree = ""; }; + A04EFA0E3139C01E1CFDC93B /* GalleryRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRoomTimelineView.swift; sourceTree = ""; }; A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerProtocol.swift; sourceTree = ""; }; A07B011547B201A836C03052 /* EncryptionResetFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetFlowCoordinator.swift; sourceTree = ""; }; A0E7B059E84E7E374D3322A2 /* SpaceRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomCell.swift; sourceTree = ""; }; @@ -3000,6 +3005,7 @@ E8AE4B3273BA189FDCD4055C /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = ""; }; E944F717FC10A428D027074D /* RoomPowerLevelsProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevelsProxyMock.swift; sourceTree = ""; }; E992D7B8BE54B2AB454613AF /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = ""; }; + E994322F10D20ECE68683683 /* GalleryRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryRoomTimelineItem.swift; sourceTree = ""; }; E9A3D3CFA199FA7897364547 /* CallInviteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallInviteRoomTimelineItem.swift; sourceTree = ""; }; E9B796D347E53631576F631C /* PreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewTests.swift; sourceTree = ""; }; E9D059BFE329BE09B6D96A9F /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ro; path = ro.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -5500,6 +5506,8 @@ EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */, 5098DA7799946A61E34A2373 /* FileRoomTimelineItem.swift */, 216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */, + E994322F10D20ECE68683683 /* GalleryRoomTimelineItem.swift */, + 35A5AB4E2E5CA98F99BFC552 /* GalleryRoomTimelineItemContent.swift */, 3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */, B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */, 8A8DCBD0ABAADFDE5AF17E1F /* LiveLocationRoomTimelineItem.swift */, @@ -6572,6 +6580,7 @@ 2F06F70B9C433BAD4BC6B9F5 /* EncryptedRoomTimelineView.swift */, 4F75EF13F49DD2204E760910 /* FileRoomTimelineView.swift */, C258C9C815272911A5B132C3 /* FormattedBodyText.swift */, + A04EFA0E3139C01E1CFDC93B /* GalleryRoomTimelineView.swift */, 59B7CC77B82C6C67DE3AD869 /* HighlightedTimelineItemModifier.swift */, C5599255A6C98EBDA77B76E6 /* ImageRoomTimelineView.swift */, 52947F8F356F0BA5B1F50912 /* LiveLocationRoomTimelineView.swift */, @@ -8544,6 +8553,9 @@ 7807B1DEE32617896886A8E5 /* FormattingToolbar.swift in Sources */, 5705511EBE083295EF98F998 /* FrequentlyUsedEmoji.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 */, 4295E5F850897710A51AE114 /* GeoURI.swift in Sources */, F3C9CAD26FD4D7D6EBACF501 /* HTMLFixtures.swift in Sources */, diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift index d50aa03f2..7e54cdfe9 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift @@ -95,7 +95,7 @@ struct SearchScreenMessage: Identifiable, Equatable { content.formattedBody ?? AttributedString(content.body) case .emote(let content): content.formattedBody ?? AttributedString(content.body) - case .audio, .file, .image, .video: + case .audio, .file, .image, .video, .gallery: nil case .voice: AttributedString(L10n.commonVoiceMessage) @@ -130,7 +130,7 @@ struct SearchScreenMessage: Identifiable, Equatable { return .init(title: content.caption ?? content.filename, details: mediaDetails(filename: content.filename, fileSize: content.videoInfo.fileSize), kind: .video(thumbnail: content.thumbnailInfo, blurhash: content.blurhash)) - case .text, .notice, .emote, .voice, .location: + case .text, .notice, .emote, .voice, .location, .gallery: return nil } } diff --git a/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift b/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift index 740574fef..63717b2f0 100644 --- a/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift @@ -129,6 +129,13 @@ struct TimelineReplyView: View { plainBody: L10n.commonSharedLocation, formattedBody: nil, 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): ReplyView(sender: sender, diff --git a/ElementX/Sources/Screens/Timeline/View/Threads/TimelineThreadSummaryView.swift b/ElementX/Sources/Screens/Timeline/View/Threads/TimelineThreadSummaryView.swift index 73c6222e2..ff0469ff5 100644 --- a/ElementX/Sources/Screens/Timeline/View/Threads/TimelineThreadSummaryView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Threads/TimelineThreadSummaryView.swift @@ -82,6 +82,12 @@ struct TimelineThreadSummaryView: View { plainBody: L10n.commonSharedLocation, formattedBody: nil, 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): ThreadView(senderID: senderID, diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift new file mode 100644 index 000000000..191aab880 --- /dev/null +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/GalleryRoomTimelineView.swift @@ -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) + } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift index eb446aa15..a33aca26b 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedMessageTimelineItemProtocol.swift @@ -18,6 +18,7 @@ nonisolated enum EventBasedMessageTimelineItemContentType: Hashable { case video(VideoRoomTimelineItemContent) case location(LocationRoomTimelineItemContent) case voice(AudioRoomTimelineItemContent) + case gallery(GalleryRoomTimelineItemContent) } nonisolated protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol { @@ -27,7 +28,7 @@ nonisolated protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineIt nonisolated extension EventBasedMessageTimelineItemProtocol { var supportsMediaCaption: Bool { switch contentType { - case .audio, .file, .image, .video: + case .audio, .file, .image, .video, .gallery: true case .emote, .notice, .text, .location, .voice: false @@ -48,6 +49,8 @@ nonisolated extension EventBasedMessageTimelineItemProtocol { content.caption case .video(let content): content.caption + case .gallery(let content): + content.caption case .emote, .notice, .text, .location, .voice: nil } @@ -63,6 +66,8 @@ nonisolated extension EventBasedMessageTimelineItemProtocol { content.formattedCaption case .video(let content): content.formattedCaption + case .gallery(let content): + content.formattedCaption case .emote, .notice, .text, .location, .voice: nil } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index 93c6a11d4..a1a702d3c 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -103,7 +103,7 @@ nonisolated extension EventBasedTimelineItemProtocol { } switch messageBasedItem.contentType { - case .audio, .file, .image, .video, .location, .voice: + case .audio, .file, .image, .video, .location, .voice, .gallery: return false case .text, .emote, .notice: return true diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/GalleryRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/GalleryRoomTimelineItem.swift new file mode 100644 index 000000000..0570b97a4 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/GalleryRoomTimelineItem.swift @@ -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) + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/GalleryRoomTimelineItemContent.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/GalleryRoomTimelineItemContent.swift new file mode 100644 index 000000000..2da8bf4b4 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/GalleryRoomTimelineItemContent.swift @@ -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 + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index a8d72b38a..0376a1176 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -149,7 +149,7 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { case .location(let content): .location(buildLocationTimelineItemContent(content)) case .gallery(let content): - .text(.init(body: content.body)) + .gallery(buildGalleryTimelineItemContent(content, uniqueID: .init(senderID))) case .other(_, let body): .text(.init(body: body)) case .none: @@ -410,23 +410,54 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { _ messageContent: MessageContent, _ galleryMessageContent: GalleryMessageContent, _ isOutgoing: Bool) -> RoomTimelineItemProtocol { - TextRoomTimelineItem(id: eventItemProxy.id, - timestamp: eventItemProxy.timestamp, - isOutgoing: isOutgoing, - isEditable: eventItemProxy.isEditable, - canBeRepliedTo: eventItemProxy.canBeRepliedTo, - shouldBoost: eventItemProxy.shouldBoost, - sender: eventItemProxy.sender, - content: .init(body: galleryMessageContent.body), - properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), - isThreaded: messageLikeContent.threadRoot != nil, - threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), - isEdited: messageContent.isEdited, - reactions: buildAggregatedReactions(messageLikeContent.reactions), - deliveryStatus: eventItemProxy.deliveryStatus, - orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), - encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), - encryptionForwarder: eventItemProxy.forwarder)) + GalleryRoomTimelineItem(id: eventItemProxy.id, + timestamp: eventItemProxy.timestamp, + isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, + canBeRepliedTo: eventItemProxy.canBeRepliedTo, + shouldBoost: eventItemProxy.shouldBoost, + sender: eventItemProxy.sender, + content: buildGalleryTimelineItemContent(galleryMessageContent, uniqueID: eventItemProxy.id.uniqueID), + properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo), + isThreaded: messageLikeContent.threadRoot != nil, + threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary), + isEdited: messageContent.isEdited, + reactions: buildAggregatedReactions(messageLikeContent.reactions), + deliveryStatus: eventItemProxy.deliveryStatus, + orderedReadReceipts: buildOrderedReadReceipts(eventItemProxy.readReceipts), + encryptionAuthenticity: buildEncryptionAuthenticity(eventItemProxy.shieldState), + 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, diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift index aea7aa589..8bd257dbe 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift @@ -40,6 +40,8 @@ struct RoomTimelineItemView: View { AudioRoomTimelineView(timelineItem: item) case .file(let item): FileRoomTimelineView(timelineItem: item) + case .gallery(let item): + GalleryRoomTimelineView(timelineItem: item) case .emote(let item): EmoteRoomTimelineView(timelineItem: item) case .notice(let item): diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift index ebb358a2e..ba3cbcc8b 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift @@ -53,6 +53,7 @@ enum RoomTimelineItemType: Equatable { case video(VideoRoomTimelineItem) case audio(AudioRoomTimelineItem) case file(FileRoomTimelineItem) + case gallery(GalleryRoomTimelineItem) case emote(EmoteRoomTimelineItem) case notice(NoticeRoomTimelineItem) case redacted(RedactedRoomTimelineItem) @@ -83,6 +84,8 @@ enum RoomTimelineItemType: Equatable { self = .audio(item) case let item as FileRoomTimelineItem: self = .file(item) + case let item as GalleryRoomTimelineItem: + self = .gallery(item) case let item as SeparatorRoomTimelineItem: self = .separator(item) case let item as NoticeRoomTimelineItem: @@ -132,6 +135,7 @@ enum RoomTimelineItemType: Equatable { .video(let item as RoomTimelineItemProtocol), .audio(let item as RoomTimelineItemProtocol), .file(let item as RoomTimelineItemProtocol), + .gallery(let item as RoomTimelineItemProtocol), .emote(let item as RoomTimelineItemProtocol), .notice(let item as RoomTimelineItemProtocol), .redacted(let item as RoomTimelineItemProtocol), @@ -171,6 +175,8 @@ enum RoomTimelineItemType: Equatable { return item.timestamp case .file(let item): return item.timestamp + case .gallery(let item): + return item.timestamp case .emote(let item): return item.timestamp case .notice(let item):