Allow previews in the timeline to include filtered galleries (#5943)

* remove the usage of the timeline kind override in favour of getting it properly from the timeline view model

* allow the previews to include gallery items filtered by the allowed types when started outside the gallery

sss

asa

* fix for actions not being forwarded to the timeline right

* pr suggestions
This commit is contained in:
Mauro
2026-07-30 13:49:22 +02:00
committed by GitHub
parent 33f8ce2759
commit ae795df530
17 changed files with 243 additions and 110 deletions
@@ -44,12 +44,16 @@ class TimelineController: TimelineControllerProtocol {
activeTimelineItemProvider.kind
}
let allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]?
init(roomProxy: JoinedRoomProxyProtocol,
timelineProxy: TimelineProxyProtocol,
initialFocussedEventID: String?,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol,
appSettings: AppSettings) {
appSettings: AppSettings,
allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]? = nil) {
self.allowedGalleryItemTypes = allowedGalleryItemTypes
self.roomProxy = roomProxy
liveTimelineItemProvider = timelineProxy.timelineItemProvider
self.timelineItemFactory = timelineItemFactory
@@ -71,7 +71,8 @@ struct TimelineControllerFactory: TimelineControllerFactoryProtocol {
initialFocussedEventID: nil,
timelineItemFactory: timelineItemFactory,
mediaProvider: mediaProvider,
appSettings: appSettings))
appSettings: appSettings,
allowedGalleryItemTypes: allowedMessageTypes.allowedGalleryItemTypes))
case .failure(let error):
return .failure(.roomProxyError(error))
}
@@ -26,7 +26,7 @@ enum TimelineControllerAction {
}
case displayMediaPreview(item: EventBasedMessageTimelineItemProtocol, timelineViewModel: TimelineViewModelKind)
case displayGalleryPreview(galleryItem: GalleryRoomTimelineItem, initialIndex: Int)
case displayGalleryPreview(galleryItem: GalleryRoomTimelineItem, timelineViewModel: TimelineViewModelKind)
case displayLocation(StaticLocationData)
case displayLiveLocation(sender: TimelineItemSender, initialLiveLocationShare: LiveLocationShare)
case none
@@ -47,6 +47,9 @@ enum TimelineControllerError: Error {
protocol TimelineControllerProtocol: Sendable {
var timelineKind: TimelineKind { get }
/// The gallery attachments this timeline includes, or `nil` when it isn't filtered.
var allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]? { get }
/// The currently known items, use only for setting up the intial state.
var timelineItems: [RoomTimelineItemProtocol] { get }
@@ -19,8 +19,9 @@ nonisolated struct GalleryRoomTimelineItemContent: Hashable {
/// 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 identifier of the parent gallery timeline item. The whole identifier is used so that an
/// attachment remains identifiable across timelines, where the unique ID alone wouldn't be.
let timelineItemID: TimelineItemIdentifier
/// The item's position within the gallery.
let mediaIndex: Int
}
@@ -152,13 +153,24 @@ nonisolated enum GalleryItem: Hashable, Identifiable {
var hasThumbnail: Bool {
isImage || thumbnailSource != nil
}
/// The type this attachment is allowed by, or `nil` when its type is unknown.
var allowedItemType: TimelineAllowedGalleryItemType? {
switch self {
case .image: .image
case .video: .video
case .audio: .audio
case .file: .file
case .other: nil
}
}
}
// MARK: - Mocks
nonisolated extension GalleryItemID {
static func mock(_ index: Int) -> GalleryItemID {
.init(timelineItemUniqueID: .init("gallery-mock"), mediaIndex: index)
.init(timelineItemID: .randomEvent, mediaIndex: index)
}
}
@@ -149,7 +149,7 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
case .location(let content):
.location(buildLocationTimelineItemContent(content))
case .gallery(let content):
.gallery(buildGalleryTimelineItemContent(content, uniqueID: .init(senderID)))
.gallery(buildGalleryTimelineItemContent(content, timelineItemID: .randomEvent))
case .other(_, let body):
.text(.init(body: body))
case .none:
@@ -417,7 +417,7 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
shouldBoost: eventItemProxy.shouldBoost,
sender: eventItemProxy.sender,
content: buildGalleryTimelineItemContent(galleryMessageContent, uniqueID: eventItemProxy.id.uniqueID),
content: buildGalleryTimelineItemContent(galleryMessageContent, timelineItemID: eventItemProxy.id),
properties: .init(replyDetails: buildTimelineItemReplyDetails(messageLikeContent.inReplyTo),
isThreaded: messageLikeContent.threadRoot != nil,
threadSummary: buildTimelineItemThreadSummary(messageLikeContent.threadSummary),
@@ -429,13 +429,13 @@ nonisolated struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
encryptionForwarder: eventItemProxy.forwarder))
}
private func buildGalleryTimelineItemContent(_ messageContent: GalleryMessageContent, uniqueID: TimelineItemIdentifier.UniqueID) -> GalleryRoomTimelineItemContent {
private func buildGalleryTimelineItemContent(_ messageContent: GalleryMessageContent, timelineItemID: TimelineItemIdentifier) -> 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))
buildGalleryItem(itemType, id: GalleryItemID(timelineItemID: timelineItemID, mediaIndex: index))
}
return GalleryRoomTimelineItemContent(body: messageContent.body,
@@ -94,7 +94,7 @@ final class TimelineProxy: TimelineProxyProtocol {
}
func messageEventContent(for timelineItemID: TimelineItemIdentifier) async -> RoomMessageEventContentWithoutRelation? {
guard let content = timelineItemProvider.itemProxies.firstEventTimelineItemUsingStableID(timelineItemID)?.content,
guard let content = timelineItemProvider.itemProxies.firstEventTimelineItem(matching: timelineItemID)?.content,
case let .msgLike(messageLikeContent) = content,
case let .message(messageContent) = messageLikeContent.kind else {
return nil
@@ -663,15 +663,19 @@ private extension MatrixRustSDK.PollKind {
}
extension Array where Element == TimelineItemProxy {
func firstEventTimelineItemUsingStableID(_ id: TimelineItemIdentifier) -> EventTimelineItem? {
for item in self {
if case let .event(eventTimelineItem) = item {
if eventTimelineItem.id.uniqueID == id.uniqueID {
return eventTimelineItem.item
}
}
/// The event matching the given identifier, found by the event itself as that is the only part of
/// the identifier that can be compared across timelines. Identifiers that don't represent an event
/// fall back to the unique ID.
func firstEventTimelineItem(matching id: TimelineItemIdentifier) -> EventTimelineItem? {
let eventTimelineItems = compactMap { item -> EventTimelineItemProxy? in
guard case let .event(eventTimelineItem) = item else { return nil }
return eventTimelineItem
}
return nil
guard let eventOrTransactionID = id.eventOrTransactionID else {
return eventTimelineItems.first { $0.id.uniqueID == id.uniqueID }?.item
}
return eventTimelineItems.first { $0.id.eventOrTransactionID == eventOrTransactionID }?.item
}
}
@@ -16,7 +16,7 @@ enum TimelineKind: Equatable {
case pinned
case thread(rootEventID: String)
enum MediaPresentation { case roomScreenLive, roomScreenDetached, pinnedEventsScreen, mediaFilesScreen }
enum MediaPresentation { case roomScreenLive, roomScreenDetached, roomScreenThread, pinnedEventsScreen, mediaFilesScreen }
case media(MediaPresentation)
var isThread: Bool {
@@ -42,6 +42,34 @@ enum TimelineFocus {
enum TimelineAllowedMessageType {
case audio, file, image, video, gallery
/// The gallery attachments this type allows, so that a gallery event's attachments can be
/// filtered client side to match. A gallery can't contain another gallery, hence the `nil`.
var allowedGalleryItemType: TimelineAllowedGalleryItemType? {
switch self {
case .audio: .audio
case .file: .file
case .image: .image
case .video: .video
case .gallery: nil
}
}
}
/// The gallery attachments a timeline allows, mirroring the message types it is filtered to. The SDK can
/// only filter whole events, so a gallery's attachments are filtered by these client side. Unlike a
/// message type there's no gallery case, as a gallery can't contain another one.
enum TimelineAllowedGalleryItemType {
case audio, file, image, video
}
extension [TimelineAllowedMessageType] {
/// The gallery attachments these types allow, or `nil` when they place no restriction on them,
/// which is the case for a timeline made up of galleries alone.
var allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]? {
let types = compactMap(\.allowedGalleryItemType)
return types.isEmpty ? nil : types
}
}
enum TimelineProxyError: Error {