mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
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:
@@ -11491,6 +11491,7 @@ nonisolated class TimelineControllerMock: TimelineControllerProtocol, @unchecked
|
||||
set(value) { underlyingTimelineKind = value }
|
||||
}
|
||||
nonisolated(unsafe) var underlyingTimelineKind: TimelineKind!
|
||||
nonisolated(unsafe) var allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]?
|
||||
nonisolated(unsafe) var timelineItems: [RoomTimelineItemProtocol] = []
|
||||
var paginationState: TimelinePaginationState {
|
||||
get { return underlyingPaginationState }
|
||||
|
||||
@@ -18,6 +18,7 @@ struct TimelineControllerMockConfiguration {
|
||||
var timelineProxy: TimelineProxyProtocol?
|
||||
var timelineItemsTimestamps: [TimelineItemIdentifier: Date] = [:]
|
||||
var paginationState: TimelinePaginationState = .initial
|
||||
var allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]?
|
||||
}
|
||||
|
||||
@MainActor extension TimelineControllerMock {
|
||||
@@ -44,6 +45,7 @@ struct TimelineControllerMockConfiguration {
|
||||
|
||||
callbacks = PassthroughSubject()
|
||||
timelineKind = configuration.timelineKind
|
||||
allowedGalleryItemTypes = configuration.allowedGalleryItemTypes
|
||||
paginationState = configuration.paginationState
|
||||
timelineItems = configuration.timelineItems
|
||||
|
||||
|
||||
@@ -35,13 +35,19 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
|
||||
|
||||
var paginationState: TimelinePaginationState
|
||||
|
||||
/// The gallery attachments to include, so that a gallery only contributes the media being browsed.
|
||||
private let allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]?
|
||||
|
||||
/// Builds a data source spanning every previewable attachment in the timeline, paginating
|
||||
/// as the user swipes past the loaded range. Used when tapping a standalone media message.
|
||||
init(itemViewStates: [RoomTimelineItemViewState],
|
||||
initialItem: EventBasedMessageTimelineItemProtocol,
|
||||
initialPadding: Int = 100,
|
||||
paginationState: TimelinePaginationState) {
|
||||
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init)
|
||||
paginationState: TimelinePaginationState,
|
||||
allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]? = nil) {
|
||||
self.allowedGalleryItemTypes = allowedGalleryItemTypes
|
||||
|
||||
previewItems = itemViewStates.flatMap { $0.previewableMedia(allowedGalleryItemTypes: allowedGalleryItemTypes) }
|
||||
|
||||
let initialPreviewID = TimelineMediaPreviewItem.Media(timelineItem: initialItem).id
|
||||
if let initialItemArrayIndex = previewItems.firstIndex(where: { $0.id == initialPreviewID }) {
|
||||
@@ -66,6 +72,8 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
|
||||
/// gallery and there's no timeline pagination to drive.
|
||||
init(galleryItem: GalleryRoomTimelineItem,
|
||||
initialIndex: Int) {
|
||||
allowedGalleryItemTypes = nil // All of the gallery's attachments are shown.
|
||||
|
||||
let media = galleryItem.content.items.map { item in
|
||||
TimelineMediaPreviewItem.Media(galleryParent: galleryItem, item: item)
|
||||
}
|
||||
@@ -93,17 +101,17 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
|
||||
}
|
||||
|
||||
func updatePreviewItems(itemViewStates: [RoomTimelineItemViewState]) {
|
||||
let newItems: [TimelineMediaPreviewItem.Media] = itemViewStates.compactMap { itemViewState in
|
||||
guard let newItem = TimelineMediaPreviewItem.Media(roomTimelineItemViewState: itemViewState) else { return nil }
|
||||
|
||||
// If an item already exists use that instead to preserve the file handle, download error etc.
|
||||
if let oldItem = previewItems.first(where: { $0.id == newItem.id }) {
|
||||
oldItem.content = newItem.content
|
||||
return oldItem
|
||||
let newItems: [TimelineMediaPreviewItem.Media] = itemViewStates
|
||||
.flatMap { $0.previewableMedia(allowedGalleryItemTypes: allowedGalleryItemTypes) }
|
||||
.map { newItem in
|
||||
// If an item already exists use that instead to preserve the file handle, download error etc.
|
||||
if let oldItem = previewItems.first(where: { $0.id == newItem.id }) {
|
||||
oldItem.content = newItem.content
|
||||
return oldItem
|
||||
}
|
||||
|
||||
return newItem
|
||||
}
|
||||
|
||||
return newItem
|
||||
}
|
||||
|
||||
var hasPaginated = false
|
||||
if let range = newItems.map(\.id).firstRange(of: previewItems.map(\.id)) {
|
||||
@@ -430,3 +438,23 @@ enum TimelineMediaPreviewItem: Equatable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension RoomTimelineItemViewState {
|
||||
/// The media of this item that can be previewed, flattening a gallery message into its individual
|
||||
/// attachments so that they can be browsed alongside the timeline's other media.
|
||||
/// - Parameter allowedGalleryItemTypes: Restricts a gallery's attachments to the media being
|
||||
/// browsed, as the SDK can only filter the gallery event as a whole.
|
||||
func previewableMedia(allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]?) -> [TimelineMediaPreviewItem.Media] {
|
||||
guard case .gallery(let galleryItem) = type else {
|
||||
return [TimelineMediaPreviewItem.Media(roomTimelineItemViewState: self)].compactMap { $0 }
|
||||
}
|
||||
|
||||
return galleryItem.content.items.filter { item in
|
||||
guard let allowedItemType = item.allowedItemType else {
|
||||
return false // Nothing to preview, and unlike a tile there's no index to line up with.
|
||||
}
|
||||
|
||||
return allowedGalleryItemTypes?.contains(allowedItemType) ?? true
|
||||
}.map { TimelineMediaPreviewItem.Media(galleryParent: galleryItem, item: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,6 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let appMediator: AppMediatorProtocol
|
||||
|
||||
/// Overrides the timeline kind used to build the context menu. Gallery previews use `.media` so the
|
||||
/// standard download/forward/delete actions appear, since their underlying room timeline is `.live`.
|
||||
private let menuTimelineKind: TimelineKind?
|
||||
|
||||
private var contentScannerService: ContentScannerServiceProtocol? {
|
||||
timelineViewModel.context.contentScannerService
|
||||
}
|
||||
@@ -47,13 +43,13 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
self.photoLibraryManager = photoLibraryManager
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appMediator = appMediator
|
||||
menuTimelineKind = nil
|
||||
|
||||
let timelineState = timelineViewModel.context.viewState.timelineState
|
||||
|
||||
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineState.itemViewStates,
|
||||
initialItem: initialItem,
|
||||
paginationState: timelineState.paginationState)),
|
||||
paginationState: timelineState.paginationState,
|
||||
allowedGalleryItemTypes: timelineViewModel.context.viewState.allowedGalleryItemTypes)),
|
||||
mediaProvider: mediaProvider)
|
||||
|
||||
rebuildCurrentItemActions()
|
||||
@@ -99,7 +95,6 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
self.photoLibraryManager = photoLibraryManager
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appMediator = appMediator
|
||||
menuTimelineKind = .media(.roomScreenLive)
|
||||
|
||||
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(galleryItem: galleryItem,
|
||||
initialIndex: initialIndex)),
|
||||
@@ -236,7 +231,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
|
||||
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
|
||||
areThreadsEnabled: timelineContext.viewState.areThreadsEnabled,
|
||||
timelineKind: menuTimelineKind ?? timelineContext.viewState.timelineKind,
|
||||
timelineKind: timelineContext.viewState.timelineKind,
|
||||
emojiProvider: timelineContext.viewState.emojiProvider)
|
||||
.makeActions()
|
||||
}
|
||||
|
||||
@@ -107,6 +107,14 @@ class TimelineInteractionHandler {
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func handleTimelineItemMenuAction(_ action: TimelineItemMenuAction, itemID: TimelineItemIdentifier) {
|
||||
// Redacting needs the event alone, so it works even when the item isn't part of this timeline,
|
||||
// such as one held by a media preview that was built from a different one.
|
||||
if case .redact = action {
|
||||
guard case let .event(_, eventOrTransactionID) = itemID else { fatalError() }
|
||||
Task { await timelineController.redact(eventOrTransactionID) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
|
||||
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
|
||||
return
|
||||
@@ -156,8 +164,7 @@ class TimelineInteractionHandler {
|
||||
UIPasteboard.general.url = permalinkURL
|
||||
}
|
||||
case .redact:
|
||||
guard case let .event(_, eventOrTransactionID) = itemID else { fatalError() }
|
||||
Task { await timelineController.redact(eventOrTransactionID) }
|
||||
break // Handled above, before the timeline item is looked up.
|
||||
case .reply:
|
||||
guard let eventID = eventTimelineItem.id.eventID else { return }
|
||||
|
||||
@@ -560,29 +567,23 @@ class TimelineInteractionHandler {
|
||||
timestamp: item.timestamp,
|
||||
timeoutDate: item.content.timeoutDate)
|
||||
return .displayLiveLocation(sender: item.sender, initialLiveLocationShare: initialLiveLocationShare)
|
||||
// Galleries are included so that their attachments can be browsed as individual media.
|
||||
case let item as ImageRoomTimelineItem:
|
||||
return await mediaPreviewAction(for: item, messageTypes: [.image, .video])
|
||||
return await mediaPreviewAction(for: item, messageTypes: [.image, .video, .gallery])
|
||||
case let item as VideoRoomTimelineItem:
|
||||
return await mediaPreviewAction(for: item, messageTypes: [.image, .video])
|
||||
return await mediaPreviewAction(for: item, messageTypes: [.image, .video, .gallery])
|
||||
case let item as AudioRoomTimelineItem:
|
||||
return await mediaPreviewAction(for: item, messageTypes: [.audio, .file])
|
||||
return await mediaPreviewAction(for: item, messageTypes: [.audio, .file, .gallery])
|
||||
case let item as FileRoomTimelineItem:
|
||||
return await mediaPreviewAction(for: item, messageTypes: [.audio, .file])
|
||||
return await mediaPreviewAction(for: item, messageTypes: [.audio, .file, .gallery])
|
||||
case let item as GalleryRoomTimelineItem:
|
||||
// Only galleries are needed as the preview is scoped to the attachments of the tapped one.
|
||||
return await mediaPreviewAction(for: item, messageTypes: [.gallery])
|
||||
default:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a media preview scoped to a single gallery's attachments. The preview pages
|
||||
/// only between the items of that one gallery — siblings in the wider timeline aren't
|
||||
/// reachable from this entry point (use the regular media tap for that).
|
||||
func processGalleryItemTap(itemID: TimelineItemIdentifier, index: Int) -> TimelineControllerAction {
|
||||
guard let galleryItem = timelineController.timelineItems.firstUsingStableID(itemID) as? GalleryRoomTimelineItem else {
|
||||
return .none
|
||||
}
|
||||
return .displayGalleryPreview(galleryItem: galleryItem, initialIndex: index)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func buildReplyInfo(for item: EventBasedTimelineItemProtocol) -> ReplyInfo {
|
||||
@@ -613,7 +614,10 @@ class TimelineInteractionHandler {
|
||||
case .pinned:
|
||||
newTimelineFocus = .pinned
|
||||
newTimelinePresentation = .pinnedEventsScreen
|
||||
case .media, .thread:
|
||||
case .thread(let rootEventID):
|
||||
newTimelineFocus = .thread(eventID: rootEventID)
|
||||
newTimelinePresentation = .roomScreenThread
|
||||
case .media:
|
||||
break // We don't need to create a new timeline as it is already filtered.
|
||||
}
|
||||
|
||||
@@ -644,11 +648,21 @@ class TimelineInteractionHandler {
|
||||
linkMetadataProvider: linkMetadataProvider,
|
||||
timelineControllerFactory: timelineControllerFactory)
|
||||
|
||||
return .displayMediaPreview(item: item, timelineViewModel: .new(timelineViewModel))
|
||||
return previewAction(for: item, timelineViewModel: .new(timelineViewModel))
|
||||
} else {
|
||||
return .displayMediaPreview(item: item, timelineViewModel: .active)
|
||||
return previewAction(for: item, timelineViewModel: .active)
|
||||
}
|
||||
}
|
||||
|
||||
/// A gallery is previewed scoped to its own attachments rather than the wider timeline's media.
|
||||
private func previewAction(for item: EventBasedMessageTimelineItemProtocol,
|
||||
timelineViewModel: TimelineControllerAction.TimelineViewModelKind) -> TimelineControllerAction {
|
||||
guard let galleryItem = item as? GalleryRoomTimelineItem else {
|
||||
return .displayMediaPreview(item: item, timelineViewModel: timelineViewModel)
|
||||
}
|
||||
|
||||
return .displayGalleryPreview(galleryItem: galleryItem, timelineViewModel: timelineViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReplyInfo {
|
||||
|
||||
@@ -104,6 +104,8 @@ enum TimelineComposerAction {
|
||||
|
||||
struct TimelineViewState: BindableState {
|
||||
let timelineKind: TimelineKind
|
||||
/// The gallery attachments the timeline includes, or `nil` when it isn't filtered.
|
||||
let allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]?
|
||||
var roomID: String
|
||||
var members: [String: RoomMemberState] = [:]
|
||||
var typingMembers: [String] = []
|
||||
|
||||
@@ -92,6 +92,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
true
|
||||
}
|
||||
super.init(initialViewState: TimelineViewState(timelineKind: timelineController.timelineKind,
|
||||
allowedGalleryItemTypes: timelineController.allowedGalleryItemTypes,
|
||||
roomID: roomProxy.id,
|
||||
isDM: roomProxy.infoPublisher.value.isDM,
|
||||
timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
|
||||
@@ -160,7 +161,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
case .mediaTapped(let id):
|
||||
Task { await handleMediaTapped(with: id) }
|
||||
case .galleryItemTapped(let id, let index):
|
||||
handleGalleryItemTapped(itemID: id, index: index)
|
||||
Task { await handleMediaTapped(with: id, galleryIndex: index) }
|
||||
case .itemSendInfoTapped(let itemID):
|
||||
handleItemSendInfoTapped(itemID: itemID)
|
||||
case .toggleReaction(let emoji, let itemID):
|
||||
@@ -700,7 +701,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
await timelineController.sendReadReceipt(for: lastVisibleItemID)
|
||||
}
|
||||
|
||||
private func handleMediaTapped(with itemID: TimelineItemIdentifier) async {
|
||||
private func handleMediaTapped(with itemID: TimelineItemIdentifier, galleryIndex: Int? = nil) async {
|
||||
state.showLoading = true
|
||||
let action = await timelineInteractionHandler.processItemTap(itemID)
|
||||
|
||||
@@ -710,8 +711,13 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
|
||||
let mediaPreviewViewModel = makeMediaPreviewViewModel(item: item, timelineViewModelKind: timelineViewModelKind)
|
||||
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
|
||||
case .displayGalleryPreview:
|
||||
fatalError("Galleries open through the dedicated galleryItemTapped path, which carries the tapped index.")
|
||||
case .displayGalleryPreview(let galleryItem, let timelineViewModelKind):
|
||||
actionsSubject.send(.composer(action: .removeFocus))
|
||||
|
||||
let mediaPreviewViewModel = makeGalleryPreviewViewModel(galleryItem: galleryItem,
|
||||
timelineViewModelKind: timelineViewModelKind,
|
||||
initialIndex: galleryIndex ?? 0)
|
||||
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
|
||||
case .displayLocation(let location):
|
||||
actionsSubject.send(.displayLocation(location))
|
||||
case .displayLiveLocation(let sender, let initialLiveLocationShare):
|
||||
@@ -722,18 +728,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
state.showLoading = false
|
||||
}
|
||||
|
||||
private func handleGalleryItemTapped(itemID: TimelineItemIdentifier, index: Int) {
|
||||
if case let .displayGalleryPreview(galleryItem, initialIndex) = timelineInteractionHandler.processGalleryItemTap(itemID: itemID, index: index) {
|
||||
displayGalleryPreview(galleryItem: galleryItem, initialIndex: initialIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private func displayGalleryPreview(galleryItem: GalleryRoomTimelineItem, initialIndex: Int) {
|
||||
actionsSubject.send(.composer(action: .removeFocus))
|
||||
let mediaPreviewViewModel = makeGalleryPreviewViewModel(galleryItem: galleryItem, initialIndex: initialIndex)
|
||||
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
|
||||
}
|
||||
|
||||
private func handleItemSendInfoTapped(itemID: TimelineItemIdentifier) {
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
|
||||
MXLog.warning("Couldn't find timeline item.")
|
||||
@@ -836,30 +830,33 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
|
||||
private func makeMediaPreviewViewModel(item: EventBasedMessageTimelineItemProtocol,
|
||||
timelineViewModelKind: TimelineControllerAction.TimelineViewModelKind) -> TimelineMediaPreviewViewModel {
|
||||
let timelineViewModel = switch timelineViewModelKind {
|
||||
case .active: self
|
||||
case .new(let newViewModel): newViewModel
|
||||
}
|
||||
|
||||
return TimelineMediaPreviewViewModel(initialItem: item,
|
||||
timelineViewModel: timelineViewModel,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
photoLibraryManager: PhotoLibraryManager(),
|
||||
userIndicatorController: userIndicatorController,
|
||||
appMediator: appMediator)
|
||||
}
|
||||
|
||||
private func makeGalleryPreviewViewModel(galleryItem: GalleryRoomTimelineItem,
|
||||
initialIndex: Int) -> TimelineMediaPreviewViewModel {
|
||||
TimelineMediaPreviewViewModel(galleryItem: galleryItem,
|
||||
initialIndex: initialIndex,
|
||||
timelineViewModel: self,
|
||||
TimelineMediaPreviewViewModel(initialItem: item,
|
||||
timelineViewModel: timelineViewModel(for: timelineViewModelKind),
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
photoLibraryManager: PhotoLibraryManager(),
|
||||
userIndicatorController: userIndicatorController,
|
||||
appMediator: appMediator)
|
||||
}
|
||||
|
||||
private func makeGalleryPreviewViewModel(galleryItem: GalleryRoomTimelineItem,
|
||||
timelineViewModelKind: TimelineControllerAction.TimelineViewModelKind,
|
||||
initialIndex: Int) -> TimelineMediaPreviewViewModel {
|
||||
TimelineMediaPreviewViewModel(galleryItem: galleryItem,
|
||||
initialIndex: initialIndex,
|
||||
timelineViewModel: timelineViewModel(for: timelineViewModelKind),
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
photoLibraryManager: PhotoLibraryManager(),
|
||||
userIndicatorController: userIndicatorController,
|
||||
appMediator: appMediator)
|
||||
}
|
||||
|
||||
private func timelineViewModel(for kind: TimelineControllerAction.TimelineViewModelKind) -> TimelineViewModel {
|
||||
switch kind {
|
||||
case .active: self
|
||||
case .new(let newViewModel): newViewModel
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Item Building
|
||||
|
||||
private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {
|
||||
|
||||
@@ -52,7 +52,7 @@ struct GalleryItemTileView: View {
|
||||
private var content: some View {
|
||||
if let source = item.thumbnailSource ?? item.mediaSource {
|
||||
LoadableImage(mediaSource: source,
|
||||
mediaType: .timelineItem(uniqueID: item.id.timelineItemUniqueID),
|
||||
mediaType: .timelineItem(uniqueID: item.id.timelineItemID.uniqueID),
|
||||
blurhash: item.blurhash,
|
||||
size: item.size,
|
||||
mediaProvider: mediaProvider) { imageView in
|
||||
|
||||
@@ -110,7 +110,7 @@ private struct GalleryListRow: View {
|
||||
private var icon: some View {
|
||||
if item.hasThumbnail, let source = item.thumbnailSource ?? item.mediaSource {
|
||||
LoadableImage(mediaSource: source,
|
||||
mediaType: .timelineItem(uniqueID: item.id.timelineItemUniqueID),
|
||||
mediaType: .timelineItem(uniqueID: item.id.timelineItemID.uniqueID),
|
||||
blurhash: item.blurhash,
|
||||
size: item.size,
|
||||
mediaProvider: mediaProvider) { imageView in
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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))
|
||||
}
|
||||
|
||||
+4
-1
@@ -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 }
|
||||
|
||||
|
||||
+15
-3
@@ -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 {
|
||||
|
||||
@@ -29,27 +29,58 @@ struct TimelineMediaPreviewDataSourceTests {
|
||||
try assertInitialDataSource()
|
||||
}
|
||||
|
||||
@Test
|
||||
func timelinePreviewFlattensGalleries() throws {
|
||||
// Given a timeline containing an image, a gallery of 2 attachments and a gallery of 3, one of
|
||||
// which is a file that belongs in the other timeline.
|
||||
let image = ImageRoomTimelineItem(id: .randomEvent,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(filename: "image.jpg", imageInfo: .mockImage, thumbnailInfo: nil, blurhash: nil))
|
||||
let firstGalleryItems: [GalleryItem] = [.mockImage(index: 0), .mockVideo(index: 1)]
|
||||
let secondGalleryItems: [GalleryItem] = [.mockImage(index: 0), .mockFile(index: 1), .mockImage(index: 2)]
|
||||
let timelineItems: [RoomTimelineItemProtocol] = [image,
|
||||
makeGallery(items: firstGalleryItems),
|
||||
makeGallery(items: secondGalleryItems)]
|
||||
let itemViewStates = timelineItems.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
|
||||
|
||||
// When opening the preview from the image, which isn't part of a gallery.
|
||||
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: itemViewStates,
|
||||
initialItem: image,
|
||||
paginationState: .initial,
|
||||
allowedGalleryItemTypes: [.image, .video])
|
||||
|
||||
// Then the galleries' visual attachments are browsable as though they were individual messages,
|
||||
// whilst the file is left to the timeline that shows those.
|
||||
let imageID = try #require(image.id.eventOrTransactionID)
|
||||
let expectedIDs: [MediaPreviewItemID] = [.timelineItem(imageID),
|
||||
.galleryItem(firstGalleryItems[0].id),
|
||||
.galleryItem(firstGalleryItems[1].id),
|
||||
.galleryItem(secondGalleryItems[0].id),
|
||||
.galleryItem(secondGalleryItems[2].id)]
|
||||
#expect(dataSource.previewItems.map(\.id) == expectedIDs)
|
||||
|
||||
// …starting from the tapped image.
|
||||
#expect(dataSource.currentMediaItemID == .timelineItem(imageID))
|
||||
}
|
||||
|
||||
@Test
|
||||
func galleryPreview() throws {
|
||||
// Given a gallery message with three previewable attachments.
|
||||
let items = (0..<3).map { index in
|
||||
GalleryItem.mockImage(index: index, filename: "image-\(index).jpg")
|
||||
}
|
||||
let gallery = GalleryRoomTimelineItem(id: .randomEvent,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "Gallery", caption: nil, items: items),
|
||||
properties: .init())
|
||||
// Given a gallery message whose attachments are a mix of media types.
|
||||
let items: [GalleryItem] = [.mockImage(index: 0), .mockFile(index: 1), .mockVideo(index: 2), .mockAudio(index: 3)]
|
||||
let gallery = makeGallery(items: items)
|
||||
|
||||
// When opening the preview scoped to that gallery on the second attachment.
|
||||
let dataSource = TimelineMediaPreviewDataSource(galleryItem: gallery, initialIndex: 1)
|
||||
|
||||
// Then it contains exactly the gallery's attachments with no surrounding pagination…
|
||||
#expect(dataSource.previewItems.count == 3)
|
||||
#expect(dataSource.numberOfPreviewItems(in: previewController) == 3)
|
||||
// Then it is self contained, holding every one of the attachments without filtering any of
|
||||
// them out and with no surrounding pagination…
|
||||
let expectedIDs: [MediaPreviewItemID] = items.map { .galleryItem($0.id) }
|
||||
#expect(dataSource.previewItems.map(\.id) == expectedIDs)
|
||||
#expect(dataSource.numberOfPreviewItems(in: previewController) == items.count)
|
||||
#expect(dataSource.initialItemIndex == 1)
|
||||
|
||||
// …and the initial item is the tapped attachment.
|
||||
@@ -294,6 +325,17 @@ struct TimelineMediaPreviewDataSourceTests {
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private func makeGallery(items: [GalleryItem]) -> GalleryRoomTimelineItem {
|
||||
.init(id: .randomEvent,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "Gallery", caption: nil, items: items),
|
||||
properties: .init())
|
||||
}
|
||||
|
||||
func newChunk() -> [EventBasedMessageTimelineItemProtocol] {
|
||||
TimelineFixtures.mediaChunk
|
||||
.compactMap { $0 as? EventBasedMessageTimelineItemProtocol }
|
||||
|
||||
Reference in New Issue
Block a user