mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
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:
+6
-1
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+31
@@ -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)
|
||||
}
|
||||
}
|
||||
+145
@@ -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):
|
||||
.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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user