Use the new preview screen when tapping media on the room and pinned events screens. (#3736)

* Use the new TimelineMediaPreview modifier on the room and pinned timeline screens.

* Use the same presentation logic for all timeline media previews.

* Fix a bug with the detection of the timeline end.

* Send pagination requests from the media preview screen.

* Add SwiftLint to the Danger workflow (it is no longer installed on the runner).

* Put SwiftLint back on all of the GitHub runners too.

* Set the function_parameter_count lint rule to 10.

* Make sure to clean-up any previews when the coordinator is done.

* Handle the viewInRoomTimeline action more appropriately.
This commit is contained in:
Doug 2025-02-05 13:27:23 +00:00 committed by GitHub
parent 921d1c627d
commit cfaa1b455a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 490 additions and 202 deletions

View File

@ -15,7 +15,7 @@ jobs:
- name: Setup environment - name: Setup environment
run: run:
brew install danger/tap/danger-swift brew install danger/tap/danger-swift swiftlint
- name: Danger - name: Danger
run: run:

View File

@ -41,6 +41,10 @@ function_body_length:
warning: 100 warning: 100
error: 100 error: 100
function_parameter_count:
warning: 10
error: 10
cyclomatic_complexity: cyclomatic_complexity:
ignores_case_statements: true ignores_case_statements: true

View File

@ -64,7 +64,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()), attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userSession.clientProxy.userID)) stateEventStringBuilder: RoomStateEventStringBuilder(userID: userSession.clientProxy.userID))
guard case let .success(mediaTimelineController) = await timelineControllerFactory.buildMessageFilteredTimelineController(allowedMessageTypes: [.image, .video], guard case let .success(mediaTimelineController) = await timelineControllerFactory.buildMessageFilteredTimelineController(focus: .live,
allowedMessageTypes: [.image, .video],
presentation: .mediaFilesScreen, presentation: .mediaFilesScreen,
roomProxy: roomProxy, roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory, timelineItemFactory: timelineItemFactory,
@ -73,7 +74,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
return return
} }
guard case let .success(filesTimelineController) = await timelineControllerFactory.buildMessageFilteredTimelineController(allowedMessageTypes: [.file, .audio], guard case let .success(filesTimelineController) = await timelineControllerFactory.buildMessageFilteredTimelineController(focus: .live,
allowedMessageTypes: [.file, .audio],
presentation: .mediaFilesScreen, presentation: .mediaFilesScreen,
roomProxy: roomProxy, roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory, timelineItemFactory: timelineItemFactory,

View File

@ -6158,15 +6158,15 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
} }
//MARK: - messageFilteredTimeline //MARK: - messageFilteredTimeline
var messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount = 0 var messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount = 0
var messageFilteredTimelineAllowedMessageTypesPresentationCallsCount: Int { var messageFilteredTimelineFocusAllowedMessageTypesPresentationCallsCount: Int {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount return messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount
} else { } else {
var returnValue: Int? = nil var returnValue: Int? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount returnValue = messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount
} }
return returnValue! return returnValue!
@ -6174,29 +6174,29 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount = newValue messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount = newValue messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount = newValue
} }
} }
} }
} }
var messageFilteredTimelineAllowedMessageTypesPresentationCalled: Bool { var messageFilteredTimelineFocusAllowedMessageTypesPresentationCalled: Bool {
return messageFilteredTimelineAllowedMessageTypesPresentationCallsCount > 0 return messageFilteredTimelineFocusAllowedMessageTypesPresentationCallsCount > 0
} }
var messageFilteredTimelineAllowedMessageTypesPresentationReceivedArguments: (allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation)? var messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedArguments: (focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation)?
var messageFilteredTimelineAllowedMessageTypesPresentationReceivedInvocations: [(allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation)] = [] var messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedInvocations: [(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation)] = []
var messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue: Result<TimelineProxyProtocol, RoomProxyError>! var messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue: Result<TimelineProxyProtocol, RoomProxyError>!
var messageFilteredTimelineAllowedMessageTypesPresentationReturnValue: Result<TimelineProxyProtocol, RoomProxyError>! { var messageFilteredTimelineFocusAllowedMessageTypesPresentationReturnValue: Result<TimelineProxyProtocol, RoomProxyError>! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue return messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue
} else { } else {
var returnValue: Result<TimelineProxyProtocol, RoomProxyError>? = nil var returnValue: Result<TimelineProxyProtocol, RoomProxyError>? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue returnValue = messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue
} }
return returnValue! return returnValue!
@ -6204,26 +6204,26 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue = newValue messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue = newValue messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue = newValue
} }
} }
} }
} }
var messageFilteredTimelineAllowedMessageTypesPresentationClosure: (([RoomMessageEventMessageType], TimelineKind.MediaPresentation) async -> Result<TimelineProxyProtocol, RoomProxyError>)? var messageFilteredTimelineFocusAllowedMessageTypesPresentationClosure: ((TimelineFocus, [TimelineAllowedMessageType], TimelineKind.MediaPresentation) async -> Result<TimelineProxyProtocol, RoomProxyError>)?
func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation) async -> Result<TimelineProxyProtocol, RoomProxyError> { func messageFilteredTimeline(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation) async -> Result<TimelineProxyProtocol, RoomProxyError> {
messageFilteredTimelineAllowedMessageTypesPresentationCallsCount += 1 messageFilteredTimelineFocusAllowedMessageTypesPresentationCallsCount += 1
messageFilteredTimelineAllowedMessageTypesPresentationReceivedArguments = (allowedMessageTypes: allowedMessageTypes, presentation: presentation) messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedArguments = (focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation)
DispatchQueue.main.async { DispatchQueue.main.async {
self.messageFilteredTimelineAllowedMessageTypesPresentationReceivedInvocations.append((allowedMessageTypes: allowedMessageTypes, presentation: presentation)) self.messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedInvocations.append((focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation))
} }
if let messageFilteredTimelineAllowedMessageTypesPresentationClosure = messageFilteredTimelineAllowedMessageTypesPresentationClosure { if let messageFilteredTimelineFocusAllowedMessageTypesPresentationClosure = messageFilteredTimelineFocusAllowedMessageTypesPresentationClosure {
return await messageFilteredTimelineAllowedMessageTypesPresentationClosure(allowedMessageTypes, presentation) return await messageFilteredTimelineFocusAllowedMessageTypesPresentationClosure(focus, allowedMessageTypes, presentation)
} else { } else {
return messageFilteredTimelineAllowedMessageTypesPresentationReturnValue return messageFilteredTimelineFocusAllowedMessageTypesPresentationReturnValue
} }
} }
//MARK: - enableEncryption //MARK: - enableEncryption
@ -14488,15 +14488,15 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck
} }
//MARK: - buildMessageFilteredTimelineController //MARK: - buildMessageFilteredTimelineController
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0 var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int { var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
} else { } else {
var returnValue: Int? = nil var returnValue: Int? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount returnValue = buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
} }
return returnValue! return returnValue!
@ -14504,29 +14504,29 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
} }
} }
} }
} }
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCalled: Bool { var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCalled: Bool {
return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0 return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0
} }
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)? var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)?
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = [] var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = []
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>! var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>!
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>! { var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
} else { } else {
var returnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>? = nil var returnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue returnValue = buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
} }
return returnValue! return returnValue!
@ -14534,26 +14534,26 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
} }
} }
} }
} }
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure: (([RoomMessageEventMessageType], TimelineKind.MediaPresentation, JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> Result<TimelineControllerProtocol, TimelineFactoryControllerError>)? var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure: ((TimelineFocus, [TimelineAllowedMessageType], TimelineKind.MediaPresentation, JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> Result<TimelineControllerProtocol, TimelineFactoryControllerError>)?
func buildMessageFilteredTimelineController(allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> Result<TimelineControllerProtocol, TimelineFactoryControllerError> { func buildMessageFilteredTimelineController(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> Result<TimelineControllerProtocol, TimelineFactoryControllerError> {
buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1 buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1
buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (allowedMessageTypes: allowedMessageTypes, presentation: presentation, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider) buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)
DispatchQueue.main.async { DispatchQueue.main.async {
self.buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((allowedMessageTypes: allowedMessageTypes, presentation: presentation, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)) self.buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider))
} }
if let buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure = buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure { if let buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure = buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure {
return await buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure(allowedMessageTypes, presentation, roomProxy, timelineItemFactory, mediaProvider) return await buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure(focus, allowedMessageTypes, presentation, roomProxy, timelineItemFactory, mediaProvider)
} else { } else {
return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue
} }
} }
} }

View File

@ -41,9 +41,16 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init) previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init)
self.initialItem = initialItem self.initialItem = initialItem
let initialItemArrayIndex = previewItems.firstIndex { $0.id == initialItem.id } ?? 0 if let initialItemArrayIndex = previewItems.firstIndex(where: { $0.id == initialItem.id.eventOrTransactionID }) {
initialItemIndex = initialItemArrayIndex + initialPadding initialItemIndex = initialItemArrayIndex + initialPadding
currentItem = .media(previewItems[initialItemArrayIndex]) currentItem = .media(previewItems[initialItemArrayIndex])
} else {
// The timeline hasn't loaded the initial item yet, so replace the whatever was loaded with
// the item the user wants to preview.
initialItemIndex = initialPadding
previewItems = [.init(timelineItem: initialItem)]
currentItem = .media(previewItems[0])
}
backwardPadding = initialPadding backwardPadding = initialPadding
forwardPadding = initialPadding forwardPadding = initialPadding
@ -83,10 +90,18 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
hasPaginated = true hasPaginated = true
} }
} else { } else {
// Do nothing! Not ideal but if we reload the data source the current item will // When the timeline is loading items from the store and the initial item is the only
// also be, reloaded resetting any interaction the user has made with it. If we // preview in the array, we don't want to wipe it out, so if the existing items aren't
// ignore the pagination, then the next time they swipe they'll land on a different // found within the new items then let's ignore the update for now. This comes with a
// tradeoff that when a media gets redacted, no more previews will be added to the viewer.
//
// Note for the future if anyone wants to fix the redaction issue: Reloading the data source,
// will also reload the current item resetting any interaction the user has made with it.
// If you ignore the pagination, then the next time they swipe they'll land on a different
// media but this is probably less jarring overall. I hate QLPreviewController! // media but this is probably less jarring overall. I hate QLPreviewController!
MXLog.info("Ignoring update: unable to find existing preview items range.")
return
} }
previewItems = newItems previewItems = newItems
@ -109,9 +124,9 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
let arrayIndex = index - backwardPadding let arrayIndex = index - backwardPadding
if index < firstPreviewItemIndex { if index < firstPreviewItemIndex {
return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginating return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginatingBackwards
} else if index > lastPreviewItemIndex { } else if index > lastPreviewItemIndex {
return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginating return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginatingForwards
} else { } else {
return previewItems[arrayIndex] return previewItems[arrayIndex]
} }
@ -151,7 +166,15 @@ enum TimelineMediaPreviewItem: Equatable {
// MARK: Identifiable // MARK: Identifiable
var id: TimelineItemIdentifier { timelineItem.id } /// The timeline item's event or transaction ID.
///
/// We're identifying items by this to ensure that all matching is made using only this part of the identifier. This is
/// because the unique ID will be different across timelines so when the initial item comes from a regular timeline and
/// we build a filtered timeline to fetch the other media items, it is impossible to match by the `TimelineItemIdentifier`.
var id: TimelineItemIdentifier.EventOrTransactionID {
guard let id = timelineItem.id.eventOrTransactionID else { fatalError("Virtual items cannot be previewed.") }
return id
}
// MARK: QLPreviewItem // MARK: QLPreviewItem
@ -274,11 +297,12 @@ enum TimelineMediaPreviewItem: Equatable {
} }
class Loading: NSObject, QLPreviewItem { class Loading: NSObject, QLPreviewItem {
static let paginating = Loading(state: .paginating) static let paginatingBackwards = Loading(state: .paginating(.backwards))
static let paginatingForwards = Loading(state: .paginating(.forwards))
static let timelineStart = Loading(state: .timelineStart) static let timelineStart = Loading(state: .timelineStart)
static let timelineEnd = Loading(state: .timelineEnd) static let timelineEnd = Loading(state: .timelineEnd)
enum State { case paginating, timelineStart, timelineEnd } enum State { case paginating(PaginationDirection), timelineStart, timelineEnd }
let state: State let state: State
let previewItemURL: URL? = nil let previewItemURL: URL? = nil

View File

@ -14,7 +14,7 @@ enum TimelineMediaPreviewViewModelAction: Equatable {
} }
enum TimelineMediaPreviewDriverAction { enum TimelineMediaPreviewDriverAction {
case itemLoaded(TimelineItemIdentifier) case itemLoaded(TimelineItemIdentifier.EventOrTransactionID)
case showItemDetails(TimelineMediaPreviewItem.Media) case showItemDetails(TimelineMediaPreviewItem.Media)
case exportFile(TimelineMediaPreviewFileExportPicker.File) case exportFile(TimelineMediaPreviewFileExportPicker.File)
case authorizationRequired(appMediator: AppMediatorProtocol) case authorizationRequired(appMediator: AppMediatorProtocol)

View File

@ -63,7 +63,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
timelineViewModel.context.$viewState.map(\.timelineState.paginationState) timelineViewModel.context.$viewState.map(\.timelineState.paginationState)
.removeDuplicates() .removeDuplicates()
.weakAssign(to: \.state.dataSource.paginationState, on: self) .sink { [weak self] paginationState in
guard let self else { return }
state.dataSource.paginationState = paginationState
paginateIfNeeded()
}
.store(in: &cancellables) .store(in: &cancellables)
} }
@ -77,7 +81,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
switch action { switch action {
case .viewInRoomTimeline: case .viewInRoomTimeline:
state.previewControllerDriver.send(.dismissDetailsSheet) state.previewControllerDriver.send(.dismissDetailsSheet)
actionsSubject.send(.viewInRoomTimeline(item.id)) actionsSubject.send(.viewInRoomTimeline(item.timelineItem.id))
case .save: case .save:
Task { await saveCurrentItem() } Task { await saveCurrentItem() }
case .redact: case .redact:
@ -111,6 +115,23 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
mediaItem.downloadError = error mediaItem.downloadError = error
} }
} }
} else {
paginateIfNeeded()
}
}
private func paginateIfNeeded() {
switch state.currentItem {
case .loading(.paginatingBackwards):
if state.dataSource.paginationState.backward == .idle {
timelineViewModel.context.send(viewAction: .paginateBackwards)
}
case .loading(.paginatingForwards):
if state.dataSource.paginationState.forward == .idle {
timelineViewModel.context.send(viewAction: .paginateForwards)
}
default:
break
} }
} }
@ -166,7 +187,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
} }
private func redactItem(_ item: TimelineMediaPreviewItem.Media) { private func redactItem(_ item: TimelineMediaPreviewItem.Media) {
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact)) timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.timelineItem.id, action: .redact))
state.bindings.redactConfirmationItem = nil state.bindings.redactConfirmationItem = nil
state.previewControllerDriver.send(.dismissDetailsSheet) state.previewControllerDriver.send(.dismissDetailsSheet)
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)

View File

@ -182,7 +182,7 @@ class TimelineMediaPreviewController: QLPreviewController {
} }
} }
private func handleFileLoaded(itemID: TimelineItemIdentifier) { private func handleFileLoaded(itemID: TimelineItemIdentifier.EventOrTransactionID) {
guard (currentPreviewItem as? TimelineMediaPreviewItem.Media)?.id == itemID else { return } guard (currentPreviewItem as? TimelineMediaPreviewItem.Media)?.id == itemID else { return }
refreshCurrentPreviewItem() refreshCurrentPreviewItem()
} }
@ -301,7 +301,8 @@ private struct DownloadIndicatorView: View {
private var shouldShowDownloadIndicator: Bool { private var shouldShowDownloadIndicator: Bool {
switch currentItem { switch currentItem {
case .media(let mediaItem): mediaItem.fileHandle == nil case .media(let mediaItem): mediaItem.fileHandle == nil
case .loading(let loadingItem): loadingItem.state == .paginating case .loading(.paginatingBackwards), .loading(.paginatingForwards): true
case .loading: false
} }
} }

View File

@ -222,7 +222,7 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
thumbnailInfo: .mockThumbnail, thumbnailInfo: .mockThumbnail,
contentType: contentType)) contentType: contentType))
let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen) let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreenLive : .mediaFilesScreen)
let timelineController = MockTimelineController(timelineKind: timelineKind) let timelineController = MockTimelineController(timelineKind: timelineKind)
timelineController.timelineItems = [item] timelineController.timelineItems = [item]

View File

@ -63,7 +63,7 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
.scaledFrame(size: 40) .scaledFrame(size: 40)
.background { .background {
LoadableImage(mediaSource: mediaSource, LoadableImage(mediaSource: mediaSource,
mediaType: .timelineItem(uniqueID: item.id.uniqueID), mediaType: .generic,
blurhash: item.blurhash, blurhash: item.blurhash,
mediaProvider: context.mediaProvider) { mediaProvider: context.mediaProvider) {
Color.compound.bgSubtleSecondary Color.compound.bgSubtleSecondary

View File

@ -79,6 +79,10 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
.store(in: &cancellables) .store(in: &cancellables)
} }
func stop() {
viewModel.stop()
}
func toPresentable() -> AnyView { func toPresentable() -> AnyView {
AnyView(MediaEventsTimelineScreen(context: viewModel.context)) AnyView(MediaEventsTimelineScreen(context: viewModel.context))
} }

View File

@ -64,6 +64,19 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
} }
.store(in: &cancellables) .store(in: &cancellables)
mediaTimelineViewModel.actions.sink { [weak self] action in
switch action {
case .displayMediaPreview(let mediaPreviewViewModel):
self?.displayMediaPreview(mediaPreviewViewModel)
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
.tappedOnSenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.composer, .hasScrolled, .viewInRoomTimeline:
break
}
}
.store(in: &cancellables)
filesTimelineViewModel.context.$viewState.sink { [weak self] timelineViewState in filesTimelineViewModel.context.$viewState.sink { [weak self] timelineViewState in
guard let self, state.bindings.screenMode == .files else { guard let self, state.bindings.screenMode == .files else {
return return
@ -73,6 +86,19 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
} }
.store(in: &cancellables) .store(in: &cancellables)
filesTimelineViewModel.actions.sink { [weak self] action in
switch action {
case .displayMediaPreview(let mediaPreviewViewModel):
self?.displayMediaPreview(mediaPreviewViewModel)
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
.tappedOnSenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
.composer, .hasScrolled, .viewInRoomTimeline:
break
}
}
.store(in: &cancellables)
updateWithTimelineViewState(activeTimelineViewModel.context.viewState) updateWithTimelineViewState(activeTimelineViewModel.context.viewState)
} }
@ -90,10 +116,15 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
case .oldestItemDidDisappear: case .oldestItemDidDisappear:
isOldestItemVisible = false isOldestItemVisible = false
case .tappedItem(let item): case .tappedItem(let item):
handleItemTapped(item) activeTimelineViewModel.context.send(viewAction: .mediaTapped(itemID: item.identifier))
} }
} }
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewViewModel = nil
}
// MARK: - Private // MARK: - Private
private func updateWithTimelineViewState(_ timelineViewState: TimelineViewState) { private func updateWithTimelineViewState(_ timelineViewState: TimelineViewState) {
@ -146,26 +177,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
} }
} }
private func handleItemTapped(_ item: RoomTimelineItemViewState) { private func displayMediaPreview(_ viewModel: TimelineMediaPreviewViewModel) {
let item: EventBasedMessageTimelineItemProtocol? = switch item.type {
case .audio(let audioItem): audioItem
case .file(let fileItem): fileItem
case .image(let imageItem): imageItem
case .video(let videoItem): videoItem
default: nil
}
guard let item else {
MXLog.error("Unexpected item type tapped.")
return
}
let viewModel = TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: activeTimelineViewModel,
mediaProvider: mediaProvider,
photoLibraryManager: PhotoLibraryManager(),
userIndicatorController: userIndicatorController,
appMediator: appMediator)
viewModel.actions.sink { [weak self] action in viewModel.actions.sink { [weak self] action in
guard let self else { return } guard let self else { return }
switch action { switch action {

View File

@ -11,4 +11,6 @@ import Combine
protocol MediaEventsTimelineScreenViewModelProtocol { protocol MediaEventsTimelineScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<MediaEventsTimelineScreenViewModelAction, Never> { get } var actionsPublisher: AnyPublisher<MediaEventsTimelineScreenViewModelAction, Never> { get }
var context: MediaEventsTimelineScreenViewModelType.Context { get } var context: MediaEventsTimelineScreenViewModelType.Context { get }
func stop()
} }

View File

@ -62,6 +62,9 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
guard let self else { return } guard let self else { return }
switch action { switch action {
case .viewInRoomTimeline(let itemID):
guard let eventID = itemID.eventID else { fatalError("A pinned event must have an event ID.") }
actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID))
case .dismiss: case .dismiss:
self.actionsSubject.send(.dismiss) self.actionsSubject.send(.dismiss)
} }
@ -77,6 +80,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.displayUser(userID: userID)) actionsSubject.send(.displayUser(userID: userID))
case .displayMessageForwarding(let forwardingItem): case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem)) actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
case .displayMediaPreview(let mediaPreviewViewModel):
viewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocation(_, let geoURI, let description): case .displayLocation(_, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description)) actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description))
case .viewInRoomTimeline(let eventID): case .viewInRoomTimeline(let eventID):
@ -92,6 +97,10 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
.store(in: &cancellables) .store(in: &cancellables)
} }
func stop() {
viewModel.stop()
}
func toPresentable() -> AnyView { func toPresentable() -> AnyView {
AnyView(PinnedEventsTimelineScreen(context: viewModel.context, timelineContext: timelineViewModel.context)) AnyView(PinnedEventsTimelineScreen(context: viewModel.context, timelineContext: timelineViewModel.context))
} }

View File

@ -8,10 +8,18 @@
import Foundation import Foundation
enum PinnedEventsTimelineScreenViewModelAction { enum PinnedEventsTimelineScreenViewModelAction {
case viewInRoomTimeline(itemID: TimelineItemIdentifier)
case dismiss case dismiss
} }
struct PinnedEventsTimelineScreenViewState: BindableState { } struct PinnedEventsTimelineScreenViewState: BindableState {
var bindings = PinnedEventsTimelineScreenViewStateBindings()
}
struct PinnedEventsTimelineScreenViewStateBindings {
/// The view model used to present a QuickLook media preview.
var mediaPreviewViewModel: TimelineMediaPreviewViewModel?
}
enum PinnedEventsTimelineScreenViewAction { enum PinnedEventsTimelineScreenViewAction {
case close case close

View File

@ -34,4 +34,23 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
} }
} }
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewViewModel = nil
}
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) {
mediaPreviewViewModel.actions.sink { [weak self] action in
switch action {
case .viewInRoomTimeline(let itemID):
self?.actionsSubject.send(.viewInRoomTimeline(itemID: itemID))
case .dismiss:
self?.state.bindings.mediaPreviewViewModel = nil
}
}
.store(in: &cancellables)
state.bindings.mediaPreviewViewModel = mediaPreviewViewModel
}
} }

View File

@ -11,4 +11,8 @@ import Combine
protocol PinnedEventsTimelineScreenViewModelProtocol { protocol PinnedEventsTimelineScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<PinnedEventsTimelineScreenViewModelAction, Never> { get } var actionsPublisher: AnyPublisher<PinnedEventsTimelineScreenViewModelAction, Never> { get }
var context: PinnedEventsTimelineScreenViewModelType.Context { get } var context: PinnedEventsTimelineScreenViewModelType.Context { get }
func stop()
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel)
} }

View File

@ -27,7 +27,7 @@ struct PinnedEventsTimelineScreen: View {
.toolbar { toolbar } .toolbar { toolbar }
.background(.compound.bgCanvasDefault) .background(.compound.bgCanvasDefault)
.interactiveDismissDisabled() .interactiveDismissDisabled()
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem) .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
.sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) } .sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) }
.sheet(item: $timelineContext.actionMenuInfo) { info in .sheet(item: $timelineContext.actionMenuInfo) { info in
let actions = TimelineItemMenuActionProvider(timelineItem: info.item, let actions = TimelineItemMenuActionProvider(timelineItem: info.item,

View File

@ -126,6 +126,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentMediaUploadPicker(.photoLibrary)) actionsSubject.send(.presentMediaUploadPicker(.photoLibrary))
case .displayDocumentPicker: case .displayDocumentPicker:
actionsSubject.send(.presentMediaUploadPicker(.documents)) actionsSubject.send(.presentMediaUploadPicker(.documents))
case .displayMediaPreview(let mediaPreviewViewModel):
roomViewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocationPicker: case .displayLocationPicker:
actionsSubject.send(.presentLocationPicker) actionsSubject.send(.presentLocationPicker)
case .displayPollForm(let mode): case .displayPollForm(let mode):
@ -199,7 +201,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
func stop() { func stop() {
composerViewModel.saveDraft() composerViewModel.saveDraft()
timelineViewModel.stop() roomViewModel.stop()
} }
func toPresentable() -> AnyView { func toPresentable() -> AnyView {

View File

@ -67,7 +67,10 @@ struct RoomScreenViewState: BindableState {
var bindings: RoomScreenViewStateBindings var bindings: RoomScreenViewStateBindings
} }
struct RoomScreenViewStateBindings { } struct RoomScreenViewStateBindings {
/// The view model used to present a QuickLook media preview.
var mediaPreviewViewModel: TimelineMediaPreviewViewModel?
}
enum RoomScreenFooterViewAction { enum RoomScreenFooterViewAction {
case resolvePinViolation(userID: String) case resolvePinViolation(userID: String)

View File

@ -115,6 +115,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
} }
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewViewModel = nil
}
func timelineHasScrolled(direction: ScrollDirection) { func timelineHasScrolled(direction: ScrollDirection) {
state.lastScrollDirection = direction state.lastScrollDirection = direction
} }
@ -123,6 +128,20 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.pinnedEventsBannerState.setSelectedPinnedEventID(eventID) state.pinnedEventsBannerState.setSelectedPinnedEventID(eventID)
} }
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel) {
mediaPreviewViewModel.actions.sink { [weak self] action in
switch action {
case .viewInRoomTimeline:
fatalError("viewInRoomTimeline should not be visible on a room preview.")
case .dismiss:
self?.state.bindings.mediaPreviewViewModel = nil
}
}
.store(in: &cancellables)
state.bindings.mediaPreviewViewModel = mediaPreviewViewModel
}
// MARK: - Private // MARK: - Private
private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) { private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) {

View File

@ -12,6 +12,9 @@ protocol RoomScreenViewModelProtocol {
var actions: AnyPublisher<RoomScreenViewModelAction, Never> { get } var actions: AnyPublisher<RoomScreenViewModelAction, Never> { get }
var context: RoomScreenViewModel.Context { get } var context: RoomScreenViewModel.Context { get }
func stop()
func timelineHasScrolled(direction: ScrollDirection) func timelineHasScrolled(direction: ScrollDirection)
func setSelectedPinnedEventID(_ eventID: String) func setSelectedPinnedEventID(_ eventID: String)
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel)
} }

View File

@ -94,7 +94,7 @@ struct RoomScreen: View {
ReadReceiptsSummaryView(orderedReadReceipts: $0.orderedReceipts) ReadReceiptsSummaryView(orderedReadReceipts: $0.orderedReceipts)
.environmentObject(timelineContext) .environmentObject(timelineContext)
} }
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem) .timelineMediaPreview(viewModel: $roomContext.mediaPreviewViewModel)
.track(screen: .Room) .track(screen: .Room)
.onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in .onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in
guard let provider = providers.first, guard let provider = providers.first,

View File

@ -502,7 +502,7 @@ class TimelineInteractionHandler {
} }
func processItemTap(_ itemID: TimelineItemIdentifier) async -> TimelineControllerAction { func processItemTap(_ itemID: TimelineItemIdentifier) async -> TimelineControllerAction {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else { guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) as? EventBasedMessageTimelineItemProtocol else {
return .none return .none
} }
@ -510,8 +510,14 @@ class TimelineInteractionHandler {
case let item as LocationRoomTimelineItem: case let item as LocationRoomTimelineItem:
guard let geoURI = item.content.geoURI else { return .none } guard let geoURI = item.content.geoURI else { return .none }
return .displayLocation(body: item.content.body, geoURI: geoURI, description: item.content.description) return .displayLocation(body: item.content.body, geoURI: geoURI, description: item.content.description)
case is ImageRoomTimelineItem,
is VideoRoomTimelineItem:
return await mediaPreviewAction(for: timelineItem, messageTypes: [.image, .video])
case is AudioRoomTimelineItem,
is FileRoomTimelineItem:
return await mediaPreviewAction(for: timelineItem, messageTypes: [.audio, .file])
default: default:
return await displayMediaActionIfPossible(timelineItem: timelineItem) return .none
} }
} }
@ -528,39 +534,57 @@ class TimelineInteractionHandler {
} }
} }
private func displayMediaActionIfPossible(timelineItem: RoomTimelineItemProtocol) async -> TimelineControllerAction { private func mediaPreviewAction(for item: EventBasedMessageTimelineItemProtocol, messageTypes: [TimelineAllowedMessageType]) async -> TimelineControllerAction {
var source: MediaSourceProxy? var newTimelineFocus: TimelineFocus?
var filename: String var newTimelinePresentation: TimelineKind.MediaPresentation?
var caption: String? switch timelineController.timelineKind {
case .live:
switch timelineItem { newTimelineFocus = .live
case let item as ImageRoomTimelineItem: newTimelinePresentation = .roomScreenLive
source = item.content.imageInfo.source case .detached:
filename = item.content.filename guard case let .event(_, eventOrTransactionID: .eventID(eventID)) = item.id else {
caption = item.content.caption MXLog.error("Unexpected event type on a detached timeline.")
case let item as VideoRoomTimelineItem: return .none
source = item.content.videoInfo.source }
filename = item.content.filename newTimelineFocus = .eventID(eventID)
caption = item.content.caption newTimelinePresentation = .roomScreenDetached
case let item as FileRoomTimelineItem: case .pinned:
source = item.content.source newTimelineFocus = .pinned
filename = item.content.filename newTimelinePresentation = .pinnedEventsScreen
caption = item.content.caption case .media:
case let item as AudioRoomTimelineItem: break // We don't need to create a new timeline as it is already filtered.
// For now we are just displaying audio messages with the File preview until we create a timeline player for them.
source = item.content.source
filename = item.content.filename
caption = item.content.caption
default:
return .none
} }
guard let source else { return .none } if let newTimelineFocus, let newTimelinePresentation {
switch await mediaProvider.loadFileFromSource(source, filename: filename) { let timelineItemFactory = RoomTimelineItemFactory(userID: roomProxy.ownUserID,
case .success(let file): attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
return .displayMediaFile(file: file, title: caption ?? filename) stateEventStringBuilder: RoomStateEventStringBuilder(userID: roomProxy.ownUserID))
case .failure:
return .none guard case let .success(timelineController) = await timelineControllerFactory.buildMessageFilteredTimelineController(focus: newTimelineFocus,
allowedMessageTypes: messageTypes,
presentation: newTimelinePresentation,
roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: mediaProvider) else {
MXLog.error("Failed presenting media timeline")
return .none
}
let timelineViewModel = TimelineViewModel(roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: mediaProvider,
mediaPlayerProvider: mediaPlayerProvider,
voiceMessageMediaManager: voiceMessageMediaManager,
userIndicatorController: userIndicatorController,
appMediator: appMediator,
appSettings: appSettings,
analyticsService: analyticsService,
emojiProvider: emojiProvider,
timelineControllerFactory: timelineControllerFactory)
return .displayMediaPreview(item: item, timelineViewModel: .new(timelineViewModel))
} else {
return .displayMediaPreview(item: item, timelineViewModel: .active)
} }
} }
} }

View File

@ -21,6 +21,7 @@ enum TimelineViewModelAction {
case displayMediaUploadPreviewScreen(url: URL) case displayMediaUploadPreviewScreen(url: URL)
case tappedOnSenderDetails(userID: String) case tappedOnSenderDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem) case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayMediaPreview(TimelineMediaPreviewViewModel)
case displayLocation(body: String, geoURI: GeoURI, description: String?) case displayLocation(body: String, geoURI: GeoURI, description: String?)
case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case composer(action: TimelineComposerAction) case composer(action: TimelineComposerAction)
@ -123,9 +124,6 @@ struct TimelineViewStateBindings {
/// Key is itemID, value is the collapsed state. /// Key is itemID, value is the collapsed state.
var reactionsCollapsed: [TimelineItemIdentifier: Bool] var reactionsCollapsed: [TimelineItemIdentifier: Bool]
/// A media item that will be previewed with QuickLook.
var mediaPreviewItem: MediaPreviewItem?
var alertInfo: AlertInfo<RoomScreenAlertInfoType>? var alertInfo: AlertInfo<RoomScreenAlertInfoType>?
var debugInfo: TimelineItemDebugInfo? var debugInfo: TimelineItemDebugInfo?

View File

@ -23,6 +23,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol private let roomProxy: JoinedRoomProxyProtocol
private let timelineController: TimelineControllerProtocol private let timelineController: TimelineControllerProtocol
private let mediaProvider: MediaProviderProtocol
private let mediaPlayerProvider: MediaPlayerProviderProtocol private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
private let appMediator: AppMediatorProtocol private let appMediator: AppMediatorProtocol
@ -56,6 +57,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
emojiProvider: EmojiProviderProtocol, emojiProvider: EmojiProviderProtocol,
timelineControllerFactory: TimelineControllerFactoryProtocol) { timelineControllerFactory: TimelineControllerFactoryProtocol) {
self.timelineController = timelineController self.timelineController = timelineController
self.mediaProvider = mediaProvider
self.mediaPlayerProvider = mediaPlayerProvider self.mediaPlayerProvider = mediaPlayerProvider
self.roomProxy = roomProxy self.roomProxy = roomProxy
self.appSettings = appSettings self.appSettings = appSettings
@ -123,11 +125,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
// MARK: - Public // MARK: - Public
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewItem = nil
}
override func process(viewAction: TimelineViewAction) { override func process(viewAction: TimelineViewAction) {
switch viewAction { switch viewAction {
case .itemAppeared(let id): case .itemAppeared(let id):
@ -544,9 +541,11 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
let action = await timelineInteractionHandler.processItemTap(itemID) let action = await timelineInteractionHandler.processItemTap(itemID)
switch action { switch action {
case .displayMediaFile(let file, let title): case .displayMediaPreview(let item, let timelineViewModelKind):
actionsSubject.send(.composer(action: .removeFocus)) // Hide the keyboard otherwise a big white space is sometimes shown when dismissing the preview. actionsSubject.send(.composer(action: .removeFocus)) // Hide the keyboard otherwise a big white space is sometimes shown when dismissing the preview.
state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: title)
let mediaPreviewViewModel = makeMediaPreviewViewModel(item: item, timelineViewModelKind: timelineViewModelKind)
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
case .displayLocation(let body, let geoURI, let description): case .displayLocation(let body, let geoURI, let description):
actionsSubject.send(.displayLocation(body: body, geoURI: geoURI, description: description)) actionsSubject.send(.displayLocation(body: body, geoURI: geoURI, description: description))
case .none: case .none:
@ -655,6 +654,21 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
analyticsService.trackComposer(inThread: false, isEditing: isEdit, isReply: isReply, startsThread: nil) analyticsService.trackComposer(inThread: false, isEditing: isEdit, isReply: isReply, startsThread: nil)
} }
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: mediaProvider,
photoLibraryManager: PhotoLibraryManager(),
userIndicatorController: userIndicatorController,
appMediator: appMediator)
}
// MARK: - Timeline Item Building // MARK: - Timeline Item Building
private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) { private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {

View File

@ -13,8 +13,8 @@ import SwiftUI
protocol TimelineViewModelProtocol { protocol TimelineViewModelProtocol {
var actions: AnyPublisher<TimelineViewModelAction, Never> { get } var actions: AnyPublisher<TimelineViewModelAction, Never> { get }
var context: TimelineViewModel.Context { get } var context: TimelineViewModel.Context { get }
func process(composerAction: ComposerToolbarViewModelAction) func process(composerAction: ComposerToolbarViewModelAction)
/// Updates the timeline to show and highlight the item with the corresponding event ID. /// Updates the timeline to show and highlight the item with the corresponding event ID.
func focusOnEvent(eventID: String) async func focusOnEvent(eventID: String) async
func stop()
} }

View File

@ -38,7 +38,7 @@ struct TimelineItemMenuActionProvider {
var actions: [TimelineItemMenuAction] = [] var actions: [TimelineItemMenuAction] = []
var secondaryActions: [TimelineItemMenuAction] = [] var secondaryActions: [TimelineItemMenuAction] = []
if timelineKind == .pinned || timelineKind == .media(.mediaFilesScreen) { if timelineKind == .pinned || timelineKind == .media(.mediaFilesScreen) || timelineKind == .media(.pinnedEventsScreen) {
actions.append(.viewInRoomTimeline) actions.append(.viewInRoomTimeline)
} }

View File

@ -375,7 +375,6 @@ class ClientProxy: ClientProxyProtocol {
} }
} }
// swiftlint:disable:next function_parameter_count
func createRoom(name: String, func createRoom(name: String,
topic: String?, topic: String?,
isRoomPrivate: Bool, isRoomPrivate: Bool,

View File

@ -117,7 +117,6 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError> func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError>
// swiftlint:disable:next function_parameter_count
func createRoom(name: String, func createRoom(name: String,
topic: String?, topic: String?,
isRoomPrivate: Bool, isRoomPrivate: Bool,

View File

@ -182,11 +182,27 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
} }
} }
func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType], func messageFilteredTimeline(focus: TimelineFocus,
allowedMessageTypes: [TimelineAllowedMessageType],
presentation: TimelineKind.MediaPresentation) async -> Result<any TimelineProxyProtocol, RoomProxyError> { presentation: TimelineKind.MediaPresentation) async -> Result<any TimelineProxyProtocol, RoomProxyError> {
do { do {
let sdkTimeline = try await room.timelineWithConfiguration(configuration: .init(focus: .live, let rustFocus: MatrixRustSDK.TimelineFocus = switch focus {
allowedMessageTypes: .only(types: allowedMessageTypes), case .live: .live
case .eventID(let eventID): .event(eventId: eventID, numContextEvents: 100)
case .pinned: .pinnedEvents(maxEventsToLoad: 100, maxConcurrentRequests: 10)
}
let rustMessageTypes: [MatrixRustSDK.RoomMessageEventMessageType] = allowedMessageTypes.map {
switch $0 {
case .audio: .audio
case .file: .file
case .image: .image
case .video: .video
}
}
let sdkTimeline = try await room.timelineWithConfiguration(configuration: .init(focus: rustFocus,
allowedMessageTypes: .only(types: rustMessageTypes),
internalIdPrefix: nil, internalIdPrefix: nil,
dateDividerMode: .monthly)) dateDividerMode: .monthly))

View File

@ -78,7 +78,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
func timelineFocusedOnEvent(eventID: String, numberOfEvents: UInt16) async -> Result<TimelineProxyProtocol, RoomProxyError> func timelineFocusedOnEvent(eventID: String, numberOfEvents: UInt16) async -> Result<TimelineProxyProtocol, RoomProxyError>
func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType], func messageFilteredTimeline(focus: TimelineFocus,
allowedMessageTypes: [TimelineAllowedMessageType],
presentation: TimelineKind.MediaPresentation) async -> Result<TimelineProxyProtocol, RoomProxyError> presentation: TimelineKind.MediaPresentation) async -> Result<TimelineProxyProtocol, RoomProxyError>
func enableEncryption() async -> Result<Void, RoomProxyError> func enableEncryption() async -> Result<Void, RoomProxyError>

View File

@ -75,7 +75,10 @@ class MockTimelineController: TimelineControllerProtocol {
callbacks.send(.isLive(true)) callbacks.send(.isLive(true))
} }
private(set) var paginateBackwardsCallCount = 0
func paginateBackwards(requestSize: UInt16) async -> Result<Void, TimelineControllerError> { func paginateBackwards(requestSize: UInt16) async -> Result<Void, TimelineControllerError> {
paginateBackwardsCallCount += 1
paginationState = PaginationState(backward: .paginating, forward: .timelineEndReached) paginationState = PaginationState(backward: .paginating, forward: .timelineEndReached)
if client == nil { if client == nil {
@ -85,9 +88,10 @@ class MockTimelineController: TimelineControllerProtocol {
return .success(()) return .success(())
} }
private(set) var paginateForwardsCallCount = 0
func paginateForwards(requestSize: UInt16) async -> Result<Void, TimelineControllerError> { func paginateForwards(requestSize: UInt16) async -> Result<Void, TimelineControllerError> {
// try? await simulateForwardPagination() paginateForwardsCallCount += 1
.success(()) return .success(())
} }
func sendReadReceipt(for itemID: TimelineItemIdentifier) async { func sendReadReceipt(for itemID: TimelineItemIdentifier) async {

View File

@ -42,7 +42,7 @@ class TimelineController: TimelineControllerProtocol {
} }
var timelineKind: TimelineKind { var timelineKind: TimelineKind {
liveTimelineProvider.kind activeTimelineProvider.kind
} }
init(roomProxy: JoinedRoomProxyProtocol, init(roomProxy: JoinedRoomProxyProtocol,

View File

@ -36,12 +36,13 @@ struct TimelineControllerFactory: TimelineControllerFactoryProtocol {
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings)
} }
func buildMessageFilteredTimelineController(allowedMessageTypes: [RoomMessageEventMessageType], func buildMessageFilteredTimelineController(focus: TimelineFocus,
allowedMessageTypes: [TimelineAllowedMessageType],
presentation: TimelineKind.MediaPresentation, presentation: TimelineKind.MediaPresentation,
roomProxy: JoinedRoomProxyProtocol, roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> Result<TimelineControllerProtocol, TimelineFactoryControllerError> { mediaProvider: MediaProviderProtocol) async -> Result<TimelineControllerProtocol, TimelineFactoryControllerError> {
switch await roomProxy.messageFilteredTimeline(allowedMessageTypes: allowedMessageTypes, presentation: presentation) { switch await roomProxy.messageFilteredTimeline(focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation) {
case .success(let timelineProxy): case .success(let timelineProxy):
return .success(TimelineController(roomProxy: roomProxy, return .success(TimelineController(roomProxy: roomProxy,
timelineProxy: timelineProxy, timelineProxy: timelineProxy,

View File

@ -23,7 +23,8 @@ protocol TimelineControllerFactoryProtocol {
timelineItemFactory: RoomTimelineItemFactoryProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> TimelineControllerProtocol? mediaProvider: MediaProviderProtocol) async -> TimelineControllerProtocol?
func buildMessageFilteredTimelineController(allowedMessageTypes: [RoomMessageEventMessageType], func buildMessageFilteredTimelineController(focus: TimelineFocus,
allowedMessageTypes: [TimelineAllowedMessageType],
presentation: TimelineKind.MediaPresentation, presentation: TimelineKind.MediaPresentation,
roomProxy: JoinedRoomProxyProtocol, roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol,

View File

@ -16,7 +16,14 @@ enum TimelineControllerCallback {
} }
enum TimelineControllerAction { enum TimelineControllerAction {
case displayMediaFile(file: MediaFileHandleProxy, title: String?) enum TimelineViewModelKind {
/// Use the active timeline view model.
case active
/// Use the newly generated view model provided.
case new(TimelineViewModel)
}
case displayMediaPreview(item: EventBasedMessageTimelineItemProtocol, timelineViewModel: TimelineViewModelKind)
case displayLocation(body: String, geoURI: GeoURI, description: String?) case displayLocation(body: String, geoURI: GeoURI, description: String?)
case none case none
} }

View File

@ -76,7 +76,6 @@ struct RoomStateEventStringBuilder {
} }
} }
// swiftlint:disable:next function_parameter_count
func buildProfileChangeString(displayName: String?, previousDisplayName: String?, func buildProfileChangeString(displayName: String?, previousDisplayName: String?,
avatarURLString: String?, previousAvatarURLString: String?, avatarURLString: String?, previousAvatarURLString: String?,
member: String, memberIsYou: Bool) -> String? { member: String, memberIsYou: Bool) -> String? {

View File

@ -380,7 +380,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
encryptionAuthenticity: authenticity(eventItemProxy.shieldState))) encryptionAuthenticity: authenticity(eventItemProxy.shieldState)))
} }
// swiftlint:disable:next function_parameter_count
private func buildPollTimelineItem(_ question: String, private func buildPollTimelineItem(_ question: String,
_ pollKind: PollKind, _ pollKind: PollKind,
_ maxSelections: UInt64, _ maxSelections: UInt64,
@ -645,7 +644,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
return buildStateTimelineItem(for: eventItemProxy, text: text, isOutgoing: isOutgoing) return buildStateTimelineItem(for: eventItemProxy, text: text, isOutgoing: isOutgoing)
} }
// swiftlint:disable:next function_parameter_count
private func buildStateProfileChangeTimelineItem(for eventItemProxy: EventTimelineItemProxy, private func buildStateProfileChangeTimelineItem(for eventItemProxy: EventTimelineItemProxy,
displayName: String?, displayName: String?,
previousDisplayName: String?, previousDisplayName: String?,

View File

@ -591,8 +591,8 @@ final class TimelineProxy: TimelineProxyProtocol {
backPaginationStatusSubject.send(.idle) backPaginationStatusSubject.send(.idle)
forwardPaginationStatusSubject.send(.idle) forwardPaginationStatusSubject.send(.idle)
case .media(let presentation): case .media(let presentation):
backPaginationStatusSubject.send(.idle) backPaginationStatusSubject.send(presentation == .pinnedEventsScreen ? .timelineEndReached : .idle)
forwardPaginationStatusSubject.send(presentation == .mediaFilesScreen ? .timelineEndReached : .idle) forwardPaginationStatusSubject.send(presentation == .roomScreenDetached ? .idle : .timelineEndReached)
case .pinned: case .pinned:
backPaginationStatusSubject.send(.timelineEndReached) backPaginationStatusSubject.send(.timelineEndReached)
forwardPaginationStatusSubject.send(.timelineEndReached) forwardPaginationStatusSubject.send(.timelineEndReached)

View File

@ -14,10 +14,20 @@ enum TimelineKind: Equatable {
case detached case detached
case pinned case pinned
enum MediaPresentation { case roomScreen, mediaFilesScreen } enum MediaPresentation { case roomScreenLive, roomScreenDetached, pinnedEventsScreen, mediaFilesScreen }
case media(MediaPresentation) case media(MediaPresentation)
} }
enum TimelineFocus {
case live
case eventID(String)
case pinned
}
enum TimelineAllowedMessageType {
case audio, file, image, video
}
enum TimelineProxyError: Error { enum TimelineProxyError: Error {
case sdkError(Error) case sdkError(Error)

View File

@ -14,7 +14,6 @@ import XCTest
class HomeScreenRoomTests: XCTestCase { class HomeScreenRoomTests: XCTestCase {
var roomSummary: RoomSummary! var roomSummary: RoomSummary!
// swiftlint:disable:next function_parameter_count
func setupRoomSummary(isMarkedUnread: Bool, func setupRoomSummary(isMarkedUnread: Bool,
unreadMessagesCount: UInt, unreadMessagesCount: UInt,
unreadMentionsCount: UInt, unreadMentionsCount: UInt,

View File

@ -23,7 +23,7 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
initialMediaViewStates = initialMediaItems.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) } initialMediaViewStates = initialMediaItems.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
} }
func testInitialItems() -> TimelineMediaPreviewDataSource { func testInitialItems() throws -> TimelineMediaPreviewDataSource {
// Given a data source built with the initial items. // Given a data source built with the initial items.
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates,
initialItem: initialMediaItems[initialItemIndex], initialItem: initialMediaItems[initialItemIndex],
@ -32,12 +32,13 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
// When the preview controller displays the data. // When the preview controller displays the data.
let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) let previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media let displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
// Then the preview controller should be showing the initial item and the data source should reflect this. // Then the preview controller should be showing the initial item and the data source should reflect this.
XCTAssertEqual(dataSource.initialItemIndex, initialItemIndex + initialPadding, "The initial item index should be padded for the preview controller.") XCTAssertEqual(dataSource.initialItemIndex, initialItemIndex + initialPadding, "The initial item index should be padded for the preview controller.")
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should be the initial item.") XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should be the initial item.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should also be the initial item.") XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should also be the initial item.")
XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The initial count of preview items should be correct.") XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The initial count of preview items should be correct.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The initial item count should be padded for the preview controller.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
@ -45,17 +46,15 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
return dataSource return dataSource
} }
func testCurrentUpdateItem() { func testCurrentUpdateItem() throws {
// Given a data source built with the initial items. // Given a data source built with the initial items.
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates, let dataSource = TimelineMediaPreviewDataSource(itemViewStates: initialMediaViewStates,
initialItem: initialMediaItems[initialItemIndex], initialItem: initialMediaItems[initialItemIndex],
paginationState: .initial) paginationState: .initial)
// When a different item is displayed. // When a different item is displayed.
guard let previewItem = dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem.Media else { let previewItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: 1 + initialPadding) as? TimelineMediaPreviewItem.Media,
XCTFail("A preview item should be found.") "A preview item should be found.")
return
}
dataSource.updateCurrentItem(.media(previewItem)) dataSource.updateCurrentItem(.media(previewItem))
// Then the data source should reflect the change of item. // Then the data source should reflect the change of item.
@ -74,7 +73,7 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
func testUpdatedItems() async throws { func testUpdatedItems() async throws {
// Given a data source built with the initial items. // Given a data source built with the initial items.
let dataSource = testInitialItems() let dataSource = try testInitialItems()
// When one of the items changes but no pagination has occurred. // When one of the items changes but no pagination has occurred.
let deferred = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: 1) { _ in true } let deferred = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: 1) { _ in true }
@ -84,9 +83,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
try await deferred.fulfill() try await deferred.fulfill()
let previewItemCount = dataSource.numberOfPreviewItems(in: previewController) let previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
let displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media let displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The number of items should not change.") XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The number of items should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The padded number of items should not change.") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The padded number of items should not change.")
@ -94,7 +93,7 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
func testPagination() async throws { func testPagination() async throws {
// Given a data source built with the initial items. // Given a data source built with the initial items.
let dataSource = testInitialItems() let dataSource = try testInitialItems()
// When more items are loaded in a back pagination. // When more items are loaded in a back pagination.
var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
@ -107,9 +106,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
// When more items are loaded in a forward pagination or sync. // When more items are loaded in a forward pagination or sync.
@ -123,16 +122,16 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
previewItemCount = dataSource.numberOfPreviewItems(in: previewController) previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
} }
func testPaginationLimits() async throws { func testPaginationLimits() async throws {
// Given a data source with a small amount of padding remaining. // Given a data source with a small amount of padding remaining.
initialPadding = 2 initialPadding = 2
let dataSource = testInitialItems() let dataSource = try testInitialItems()
// When paginating backwards by more than the available padding. // When paginating backwards by more than the available padding.
var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true } var deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
@ -146,9 +145,9 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController) var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
// When paginating forwards by more than the available padding. // When paginating forwards by more than the available padding.
@ -162,12 +161,92 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.") XCTAssertEqual(dataSource.previewItems.count, newViewStates.count, "The new items should be added.")
previewItemCount = dataSource.numberOfPreviewItems(in: previewController) previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media)
XCTAssertEqual(displayedItem?.id, initialMediaItems[initialItemIndex].id, "The displayed item should not change.") XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id, "The current item should not change.") XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change") XCTAssertEqual(previewItemCount, initialMediaViewStates.count + (2 * initialPadding), "The number of items should not change")
} }
func testEmptyTimeline() async throws {
// Given a data source built with no timeline items loaded.
let initialItem = initialMediaItems[initialItemIndex]
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: [],
initialItem: initialItem,
initialPadding: initialPadding,
paginationState: .initial)
// When the preview controller displays the data.
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
// Then the preview controller should always show the initial item.
XCTAssertEqual(dataSource.previewItems.count, 1, "The initial item should be in the preview items array.")
XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should be padded for the preview controller.")
XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.")
XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item should also be the initial item.")
// When the timeline loads the initial items.
let deferred = deferFulfillment(dataSource.previewItemsPaginationPublisher) { _ in true }
let loadedItems = initialMediaItems.map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
dataSource.updatePreviewItems(itemViewStates: loadedItems)
try await deferred.fulfill()
// Then the preview controller should still show the initial item with the other items loaded around it.
previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
XCTAssertEqual(dataSource.previewItems.count, initialMediaViewStates.count, "The preview items should now be loaded.")
XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The item count should not change as the padding will be reduced.")
XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The item index should not change.")
XCTAssertEqual(displayedItem.id, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialMediaItems[initialItemIndex].id.eventOrTransactionID, "The current item should not change.")
}
func testTimelineUpdateWithoutInitialItem() async throws {
// Given a data source built with no timeline items loaded.
let initialItem = initialMediaItems[initialItemIndex]
let dataSource = TimelineMediaPreviewDataSource(itemViewStates: [],
initialItem: initialItem,
initialPadding: initialPadding,
paginationState: .initial)
// When the preview controller displays the data.
var previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
var displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
// Then the preview controller should always show the initial item.
XCTAssertEqual(dataSource.previewItems.count, 1, "The initial item should be in the preview items array.")
XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should be padded for the preview controller.")
XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should be padded for the preview controller.")
XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should be the initial item.")
XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item should also be the initial item.")
// When the timeline loads more items but still doesn't include the initial item.
let failure = deferFailure(dataSource.previewItemsPaginationPublisher, timeout: 1) { _ in true }
let loadedItems = newChunk().map { RoomTimelineItemViewState(item: $0, groupStyle: .single) }
dataSource.updatePreviewItems(itemViewStates: loadedItems)
try await failure.fulfill()
// Then the preview controller shouldn't update the available preview items.
previewItemCount = dataSource.numberOfPreviewItems(in: previewController)
displayedItem = try XCTUnwrap(dataSource.previewController(previewController, previewItemAt: dataSource.initialItemIndex) as? TimelineMediaPreviewItem.Media,
"A preview item should be found.")
XCTAssertEqual(dataSource.previewItems.count, 1, "No new items should have been added to the array.")
XCTAssertEqual(previewItemCount, 1 + (2 * initialPadding), "The initial item count should not change.")
XCTAssertEqual(dataSource.initialItemIndex, initialPadding, "The initial item index should not change.")
XCTAssertEqual(displayedItem.id, initialItem.id.eventOrTransactionID, "The displayed item should not change.")
XCTAssertEqual(dataSource.currentMediaItemID, initialItem.id.eventOrTransactionID, "The current item not change.")
}
// MARK: Helpers // MARK: Helpers
func newChunk() -> [EventBasedMessageTimelineItemProtocol] { func newChunk() -> [EventBasedMessageTimelineItemProtocol] {
@ -178,7 +257,7 @@ class TimelineMediaPreviewDataSourceTests: XCTestCase {
} }
private extension TimelineMediaPreviewDataSource { private extension TimelineMediaPreviewDataSource {
var currentMediaItemID: TimelineItemIdentifier? { var currentMediaItemID: TimelineItemIdentifier.EventOrTransactionID? {
switch currentItem { switch currentItem {
case .media(let mediaItem): mediaItem.id case .media(let mediaItem): mediaItem.id
case .loading: nil case .loading: nil

View File

@ -84,18 +84,22 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0])) XCTAssertEqual(context.viewState.currentItem, .media(context.viewState.dataSource.previewItems[0]))
} }
func testLoadingMoreItem() async throws { func testLoadingMoreItems() async throws {
// Given a view model with a loaded item. // Given a view model with a loaded item.
try await testLoadingItem() try await testLoadingItem()
XCTAssertEqual(timelineController.paginateBackwardsCallCount, 0)
// When swiping to a "loading more" item. // When swiping to a "loading more" item and there are more media items to load.
timelineController.paginationState = .init(backward: .idle, forward: .timelineEndReached)
timelineController.backPaginationResponses.append(RoomTimelineItemFixtures.mediaChunk)
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded } let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded }
context.send(viewAction: .updateCurrentItem(.loading(.paginating))) context.send(viewAction: .updateCurrentItem(.loading(.paginatingBackwards)))
try await failure.fulfill() try await failure.fulfill()
// Then there should no longer be a media preview and no attempt should be made to load one. // Then there should no longer be a media preview and instead of loading any media, a pagination request should be made.
XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1) XCTAssertEqual(mediaProvider.loadFileFromSourceFilenameCallsCount, 1)
XCTAssertEqual(context.viewState.currentItem, .loading(.paginating)) XCTAssertEqual(context.viewState.currentItem, .loading(.paginatingBackwards)) // Note: This item only changes when the preview controller handles the new items.
XCTAssertEqual(timelineController.paginateBackwardsCallCount, 1)
} }
func testPagination() async throws { func testPagination() async throws {
@ -130,7 +134,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
return return
} }
let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(mediaItem.id) } let deferred = deferFulfillment(viewModel.actions) { $0 == .viewInRoomTimeline(mediaItem.timelineItem.id) }
context.send(viewAction: .menuAction(.viewInRoomTimeline, item: mediaItem)) context.send(viewAction: .menuAction(.viewInRoomTimeline, item: mediaItem))
// Then the action should be sent upwards to make this happen. // Then the action should be sent upwards to make this happen.

View File

@ -37,9 +37,7 @@ setup_github_actions_environment() {
unset HOMEBREW_NO_INSTALL_FROM_API unset HOMEBREW_NO_INSTALL_FROM_API
export HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 export HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1
brew update && brew install xcodegen swiftformat git-lfs a7ex/homebrew-formulae/xcresultparser brew update && brew install xcodegen swiftlint swiftformat git-lfs a7ex/homebrew-formulae/xcresultparser
# brew "swiftlint" # Fails on the CI: `Target /usr/local/bin/swiftlint Target /usr/local/bin/swiftlint already exists`. Installed through https://github.com/actions/virtual-environments/blob/main/images/macos/macos-12-Readme.md#linters
bundle config path vendor/bundle bundle config path vendor/bundle
bundle install --jobs 4 --retry 3 bundle install --jobs 4 --retry 3