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
45 changed files with 490 additions and 202 deletions
@@ -64,7 +64,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
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,
roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
@@ -73,7 +74,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
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,
roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
@@ -6158,15 +6158,15 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
}
//MARK: - messageFilteredTimeline
var messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount = 0
var messageFilteredTimelineAllowedMessageTypesPresentationCallsCount: Int {
var messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount = 0
var messageFilteredTimelineFocusAllowedMessageTypesPresentationCallsCount: Int {
get {
if Thread.isMainThread {
return messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount
return messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount
returnValue = messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount
}
return returnValue!
@@ -6174,29 +6174,29 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
}
set {
if Thread.isMainThread {
messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount = newValue
messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingCallsCount = newValue
messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingCallsCount = newValue
}
}
}
}
var messageFilteredTimelineAllowedMessageTypesPresentationCalled: Bool {
return messageFilteredTimelineAllowedMessageTypesPresentationCallsCount > 0
var messageFilteredTimelineFocusAllowedMessageTypesPresentationCalled: Bool {
return messageFilteredTimelineFocusAllowedMessageTypesPresentationCallsCount > 0
}
var messageFilteredTimelineAllowedMessageTypesPresentationReceivedArguments: (allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation)?
var messageFilteredTimelineAllowedMessageTypesPresentationReceivedInvocations: [(allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation)] = []
var messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedArguments: (focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation)?
var messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedInvocations: [(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation)] = []
var messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue: Result<TimelineProxyProtocol, RoomProxyError>!
var messageFilteredTimelineAllowedMessageTypesPresentationReturnValue: Result<TimelineProxyProtocol, RoomProxyError>! {
var messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue: Result<TimelineProxyProtocol, RoomProxyError>!
var messageFilteredTimelineFocusAllowedMessageTypesPresentationReturnValue: Result<TimelineProxyProtocol, RoomProxyError>! {
get {
if Thread.isMainThread {
return messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue
return messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue
} else {
var returnValue: Result<TimelineProxyProtocol, RoomProxyError>? = nil
DispatchQueue.main.sync {
returnValue = messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue
returnValue = messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue
}
return returnValue!
@@ -6204,26 +6204,26 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
}
set {
if Thread.isMainThread {
messageFilteredTimelineAllowedMessageTypesPresentationUnderlyingReturnValue = newValue
messageFilteredTimelineFocusAllowedMessageTypesPresentationUnderlyingReturnValue = newValue
} else {
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> {
messageFilteredTimelineAllowedMessageTypesPresentationCallsCount += 1
messageFilteredTimelineAllowedMessageTypesPresentationReceivedArguments = (allowedMessageTypes: allowedMessageTypes, presentation: presentation)
func messageFilteredTimeline(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation) async -> Result<TimelineProxyProtocol, RoomProxyError> {
messageFilteredTimelineFocusAllowedMessageTypesPresentationCallsCount += 1
messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedArguments = (focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation)
DispatchQueue.main.async {
self.messageFilteredTimelineAllowedMessageTypesPresentationReceivedInvocations.append((allowedMessageTypes: allowedMessageTypes, presentation: presentation))
self.messageFilteredTimelineFocusAllowedMessageTypesPresentationReceivedInvocations.append((focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation))
}
if let messageFilteredTimelineAllowedMessageTypesPresentationClosure = messageFilteredTimelineAllowedMessageTypesPresentationClosure {
return await messageFilteredTimelineAllowedMessageTypesPresentationClosure(allowedMessageTypes, presentation)
if let messageFilteredTimelineFocusAllowedMessageTypesPresentationClosure = messageFilteredTimelineFocusAllowedMessageTypesPresentationClosure {
return await messageFilteredTimelineFocusAllowedMessageTypesPresentationClosure(focus, allowedMessageTypes, presentation)
} else {
return messageFilteredTimelineAllowedMessageTypesPresentationReturnValue
return messageFilteredTimelineFocusAllowedMessageTypesPresentationReturnValue
}
}
//MARK: - enableEncryption
@@ -14488,15 +14488,15 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck
}
//MARK: - buildMessageFilteredTimelineController
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int {
var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0
var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int {
get {
if Thread.isMainThread {
return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
returnValue = buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
}
return returnValue!
@@ -14504,29 +14504,29 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck
}
set {
if Thread.isMainThread {
buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
}
}
}
}
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCalled: Bool {
return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0
var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCalled: Bool {
return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0
}
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (allowedMessageTypes: [RoomMessageEventMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)?
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(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 buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = []
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>!
var buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>! {
var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>!
var buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>! {
get {
if Thread.isMainThread {
return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
} else {
var returnValue: Result<TimelineControllerProtocol, TimelineFactoryControllerError>? = nil
DispatchQueue.main.sync {
returnValue = buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
returnValue = buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
}
return returnValue!
@@ -14534,26 +14534,26 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck
}
set {
if Thread.isMainThread {
buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
} else {
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> {
buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1
buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (allowedMessageTypes: allowedMessageTypes, presentation: presentation, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)
func buildMessageFilteredTimelineController(focus: TimelineFocus, allowedMessageTypes: [TimelineAllowedMessageType], presentation: TimelineKind.MediaPresentation, roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> Result<TimelineControllerProtocol, TimelineFactoryControllerError> {
buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1
buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (focus: focus, allowedMessageTypes: allowedMessageTypes, presentation: presentation, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)
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 {
return await buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure(allowedMessageTypes, presentation, roomProxy, timelineItemFactory, mediaProvider)
if let buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure = buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure {
return await buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderClosure(focus, allowedMessageTypes, presentation, roomProxy, timelineItemFactory, mediaProvider)
} else {
return buildMessageFilteredTimelineControllerAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue
return buildMessageFilteredTimelineControllerFocusAllowedMessageTypesPresentationRoomProxyTimelineItemFactoryMediaProviderReturnValue
}
}
}
@@ -41,9 +41,16 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init)
self.initialItem = initialItem
let initialItemArrayIndex = previewItems.firstIndex { $0.id == initialItem.id } ?? 0
initialItemIndex = initialItemArrayIndex + initialPadding
currentItem = .media(previewItems[initialItemArrayIndex])
if let initialItemArrayIndex = previewItems.firstIndex(where: { $0.id == initialItem.id.eventOrTransactionID }) {
initialItemIndex = initialItemArrayIndex + initialPadding
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
forwardPadding = initialPadding
@@ -83,10 +90,18 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
hasPaginated = true
}
} else {
// Do nothing! Not ideal but if we reload the data source the current item will
// also be, reloaded resetting any interaction the user has made with it. If we
// ignore the pagination, then the next time they swipe they'll land on a different
// When the timeline is loading items from the store and the initial item is the only
// preview in the array, we don't want to wipe it out, so if the existing items aren't
// 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!
MXLog.info("Ignoring update: unable to find existing preview items range.")
return
}
previewItems = newItems
@@ -109,9 +124,9 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
let arrayIndex = index - backwardPadding
if index < firstPreviewItemIndex {
return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginating
return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginatingBackwards
} else if index > lastPreviewItemIndex {
return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginating
return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginatingForwards
} else {
return previewItems[arrayIndex]
}
@@ -151,7 +166,15 @@ enum TimelineMediaPreviewItem: Equatable {
// 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
@@ -274,11 +297,12 @@ enum TimelineMediaPreviewItem: Equatable {
}
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 timelineEnd = Loading(state: .timelineEnd)
enum State { case paginating, timelineStart, timelineEnd }
enum State { case paginating(PaginationDirection), timelineStart, timelineEnd }
let state: State
let previewItemURL: URL? = nil
@@ -14,7 +14,7 @@ enum TimelineMediaPreviewViewModelAction: Equatable {
}
enum TimelineMediaPreviewDriverAction {
case itemLoaded(TimelineItemIdentifier)
case itemLoaded(TimelineItemIdentifier.EventOrTransactionID)
case showItemDetails(TimelineMediaPreviewItem.Media)
case exportFile(TimelineMediaPreviewFileExportPicker.File)
case authorizationRequired(appMediator: AppMediatorProtocol)
@@ -63,7 +63,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
timelineViewModel.context.$viewState.map(\.timelineState.paginationState)
.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)
}
@@ -77,7 +81,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
switch action {
case .viewInRoomTimeline:
state.previewControllerDriver.send(.dismissDetailsSheet)
actionsSubject.send(.viewInRoomTimeline(item.id))
actionsSubject.send(.viewInRoomTimeline(item.timelineItem.id))
case .save:
Task { await saveCurrentItem() }
case .redact:
@@ -111,6 +115,23 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
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) {
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.previewControllerDriver.send(.dismissDetailsSheet)
actionsSubject.send(.dismiss)
@@ -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 }
refreshCurrentPreviewItem()
}
@@ -301,7 +301,8 @@ private struct DownloadIndicatorView: View {
private var shouldShowDownloadIndicator: Bool {
switch currentItem {
case .media(let mediaItem): mediaItem.fileHandle == nil
case .loading(let loadingItem): loadingItem.state == .paginating
case .loading(.paginatingBackwards), .loading(.paginatingForwards): true
case .loading: false
}
}
@@ -222,7 +222,7 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
thumbnailInfo: .mockThumbnail,
contentType: contentType))
let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen)
let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreenLive : .mediaFilesScreen)
let timelineController = MockTimelineController(timelineKind: timelineKind)
timelineController.timelineItems = [item]
@@ -63,7 +63,7 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
.scaledFrame(size: 40)
.background {
LoadableImage(mediaSource: mediaSource,
mediaType: .timelineItem(uniqueID: item.id.uniqueID),
mediaType: .generic,
blurhash: item.blurhash,
mediaProvider: context.mediaProvider) {
Color.compound.bgSubtleSecondary
@@ -79,6 +79,10 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
.store(in: &cancellables)
}
func stop() {
viewModel.stop()
}
func toPresentable() -> AnyView {
AnyView(MediaEventsTimelineScreen(context: viewModel.context))
}
@@ -64,6 +64,19 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
}
.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
guard let self, state.bindings.screenMode == .files else {
return
@@ -73,6 +86,19 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
}
.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)
}
@@ -90,10 +116,15 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
case .oldestItemDidDisappear:
isOldestItemVisible = false
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
private func updateWithTimelineViewState(_ timelineViewState: TimelineViewState) {
@@ -146,26 +177,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
}
}
private func handleItemTapped(_ item: RoomTimelineItemViewState) {
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)
private func displayMediaPreview(_ viewModel: TimelineMediaPreviewViewModel) {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
@@ -11,4 +11,6 @@ import Combine
protocol MediaEventsTimelineScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<MediaEventsTimelineScreenViewModelAction, Never> { get }
var context: MediaEventsTimelineScreenViewModelType.Context { get }
func stop()
}
@@ -62,6 +62,9 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
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:
self.actionsSubject.send(.dismiss)
}
@@ -77,6 +80,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.displayUser(userID: userID))
case .displayMessageForwarding(let forwardingItem):
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
case .displayMediaPreview(let mediaPreviewViewModel):
viewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocation(_, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(geoURI: geoURI, description: description))
case .viewInRoomTimeline(let eventID):
@@ -91,6 +96,10 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
}
.store(in: &cancellables)
}
func stop() {
viewModel.stop()
}
func toPresentable() -> AnyView {
AnyView(PinnedEventsTimelineScreen(context: viewModel.context, timelineContext: timelineViewModel.context))
@@ -8,10 +8,18 @@
import Foundation
enum PinnedEventsTimelineScreenViewModelAction {
case viewInRoomTimeline(itemID: TimelineItemIdentifier)
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 {
case close
@@ -34,4 +34,23 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy
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
}
}
@@ -11,4 +11,8 @@ import Combine
protocol PinnedEventsTimelineScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<PinnedEventsTimelineScreenViewModelAction, Never> { get }
var context: PinnedEventsTimelineScreenViewModelType.Context { get }
func stop()
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel)
}
@@ -27,7 +27,7 @@ struct PinnedEventsTimelineScreen: View {
.toolbar { toolbar }
.background(.compound.bgCanvasDefault)
.interactiveDismissDisabled()
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem)
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
.sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) }
.sheet(item: $timelineContext.actionMenuInfo) { info in
let actions = TimelineItemMenuActionProvider(timelineItem: info.item,
@@ -126,6 +126,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentMediaUploadPicker(.photoLibrary))
case .displayDocumentPicker:
actionsSubject.send(.presentMediaUploadPicker(.documents))
case .displayMediaPreview(let mediaPreviewViewModel):
roomViewModel.displayMediaPreview(mediaPreviewViewModel)
case .displayLocationPicker:
actionsSubject.send(.presentLocationPicker)
case .displayPollForm(let mode):
@@ -199,7 +201,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
func stop() {
composerViewModel.saveDraft()
timelineViewModel.stop()
roomViewModel.stop()
}
func toPresentable() -> AnyView {
@@ -67,7 +67,10 @@ struct RoomScreenViewState: BindableState {
var bindings: RoomScreenViewStateBindings
}
struct RoomScreenViewStateBindings { }
struct RoomScreenViewStateBindings {
/// The view model used to present a QuickLook media preview.
var mediaPreviewViewModel: TimelineMediaPreviewViewModel?
}
enum RoomScreenFooterViewAction {
case resolvePinViolation(userID: String)
@@ -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) {
state.lastScrollDirection = direction
}
@@ -123,6 +128,20 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
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
private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) {
@@ -12,6 +12,9 @@ protocol RoomScreenViewModelProtocol {
var actions: AnyPublisher<RoomScreenViewModelAction, Never> { get }
var context: RoomScreenViewModel.Context { get }
func stop()
func timelineHasScrolled(direction: ScrollDirection)
func setSelectedPinnedEventID(_ eventID: String)
func displayMediaPreview(_ mediaPreviewViewModel: TimelineMediaPreviewViewModel)
}
@@ -94,7 +94,7 @@ struct RoomScreen: View {
ReadReceiptsSummaryView(orderedReadReceipts: $0.orderedReceipts)
.environmentObject(timelineContext)
}
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem)
.timelineMediaPreview(viewModel: $roomContext.mediaPreviewViewModel)
.track(screen: .Room)
.onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in
guard let provider = providers.first,
@@ -502,7 +502,7 @@ class TimelineInteractionHandler {
}
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
}
@@ -510,8 +510,14 @@ class TimelineInteractionHandler {
case let item as LocationRoomTimelineItem:
guard let geoURI = item.content.geoURI else { return .none }
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:
return await displayMediaActionIfPossible(timelineItem: timelineItem)
return .none
}
}
@@ -528,39 +534,57 @@ class TimelineInteractionHandler {
}
}
private func displayMediaActionIfPossible(timelineItem: RoomTimelineItemProtocol) async -> TimelineControllerAction {
var source: MediaSourceProxy?
var filename: String
var caption: String?
switch timelineItem {
case let item as ImageRoomTimelineItem:
source = item.content.imageInfo.source
filename = item.content.filename
caption = item.content.caption
case let item as VideoRoomTimelineItem:
source = item.content.videoInfo.source
filename = item.content.filename
caption = item.content.caption
case let item as FileRoomTimelineItem:
source = item.content.source
filename = item.content.filename
caption = item.content.caption
case let item as AudioRoomTimelineItem:
// 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
private func mediaPreviewAction(for item: EventBasedMessageTimelineItemProtocol, messageTypes: [TimelineAllowedMessageType]) async -> TimelineControllerAction {
var newTimelineFocus: TimelineFocus?
var newTimelinePresentation: TimelineKind.MediaPresentation?
switch timelineController.timelineKind {
case .live:
newTimelineFocus = .live
newTimelinePresentation = .roomScreenLive
case .detached:
guard case let .event(_, eventOrTransactionID: .eventID(eventID)) = item.id else {
MXLog.error("Unexpected event type on a detached timeline.")
return .none
}
newTimelineFocus = .eventID(eventID)
newTimelinePresentation = .roomScreenDetached
case .pinned:
newTimelineFocus = .pinned
newTimelinePresentation = .pinnedEventsScreen
case .media:
break // We don't need to create a new timeline as it is already filtered.
}
guard let source else { return .none }
switch await mediaProvider.loadFileFromSource(source, filename: filename) {
case .success(let file):
return .displayMediaFile(file: file, title: caption ?? filename)
case .failure:
return .none
if let newTimelineFocus, let newTimelinePresentation {
let timelineItemFactory = RoomTimelineItemFactory(userID: roomProxy.ownUserID,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: roomProxy.ownUserID))
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)
}
}
}
@@ -21,6 +21,7 @@ enum TimelineViewModelAction {
case displayMediaUploadPreviewScreen(url: URL)
case tappedOnSenderDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayMediaPreview(TimelineMediaPreviewViewModel)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
case displayResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case composer(action: TimelineComposerAction)
@@ -123,9 +124,6 @@ struct TimelineViewStateBindings {
/// Key is itemID, value is the collapsed state.
var reactionsCollapsed: [TimelineItemIdentifier: Bool]
/// A media item that will be previewed with QuickLook.
var mediaPreviewItem: MediaPreviewItem?
var alertInfo: AlertInfo<RoomScreenAlertInfoType>?
var debugInfo: TimelineItemDebugInfo?
@@ -23,6 +23,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let timelineController: TimelineControllerProtocol
private let mediaProvider: MediaProviderProtocol
private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appMediator: AppMediatorProtocol
@@ -56,6 +57,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
emojiProvider: EmojiProviderProtocol,
timelineControllerFactory: TimelineControllerFactoryProtocol) {
self.timelineController = timelineController
self.mediaProvider = mediaProvider
self.mediaPlayerProvider = mediaPlayerProvider
self.roomProxy = roomProxy
self.appSettings = appSettings
@@ -123,11 +125,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
// MARK: - Public
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewItem = nil
}
override func process(viewAction: TimelineViewAction) {
switch viewAction {
case .itemAppeared(let id):
@@ -542,11 +539,13 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private func handleMediaTapped(with itemID: TimelineItemIdentifier) async {
state.showLoading = true
let action = await timelineInteractionHandler.processItemTap(itemID)
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.
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):
actionsSubject.send(.displayLocation(body: body, geoURI: geoURI, description: description))
case .none:
@@ -655,6 +654,21 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
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
private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {
@@ -13,8 +13,8 @@ import SwiftUI
protocol TimelineViewModelProtocol {
var actions: AnyPublisher<TimelineViewModelAction, Never> { get }
var context: TimelineViewModel.Context { get }
func process(composerAction: ComposerToolbarViewModelAction)
/// Updates the timeline to show and highlight the item with the corresponding event ID.
func focusOnEvent(eventID: String) async
func stop()
}
@@ -38,7 +38,7 @@ struct TimelineItemMenuActionProvider {
var actions: [TimelineItemMenuAction] = []
var secondaryActions: [TimelineItemMenuAction] = []
if timelineKind == .pinned || timelineKind == .media(.mediaFilesScreen) {
if timelineKind == .pinned || timelineKind == .media(.mediaFilesScreen) || timelineKind == .media(.pinnedEventsScreen) {
actions.append(.viewInRoomTimeline)
}
@@ -375,7 +375,6 @@ class ClientProxy: ClientProxyProtocol {
}
}
// swiftlint:disable:next function_parameter_count
func createRoom(name: String,
topic: String?,
isRoomPrivate: Bool,
@@ -117,7 +117,6 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError>
// swiftlint:disable:next function_parameter_count
func createRoom(name: String,
topic: String?,
isRoomPrivate: Bool,
@@ -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> {
do {
let sdkTimeline = try await room.timelineWithConfiguration(configuration: .init(focus: .live,
allowedMessageTypes: .only(types: allowedMessageTypes),
let rustFocus: MatrixRustSDK.TimelineFocus = switch focus {
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,
dateDividerMode: .monthly))
@@ -78,7 +78,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
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>
func enableEncryption() async -> Result<Void, RoomProxyError>
@@ -74,8 +74,11 @@ class MockTimelineController: TimelineControllerProtocol {
focusLiveCallCount += 1
callbacks.send(.isLive(true))
}
private(set) var paginateBackwardsCallCount = 0
func paginateBackwards(requestSize: UInt16) async -> Result<Void, TimelineControllerError> {
paginateBackwardsCallCount += 1
paginationState = PaginationState(backward: .paginating, forward: .timelineEndReached)
if client == nil {
@@ -85,9 +88,10 @@ class MockTimelineController: TimelineControllerProtocol {
return .success(())
}
private(set) var paginateForwardsCallCount = 0
func paginateForwards(requestSize: UInt16) async -> Result<Void, TimelineControllerError> {
// try? await simulateForwardPagination()
.success(())
paginateForwardsCallCount += 1
return .success(())
}
func sendReadReceipt(for itemID: TimelineItemIdentifier) async {
@@ -42,7 +42,7 @@ class TimelineController: TimelineControllerProtocol {
}
var timelineKind: TimelineKind {
liveTimelineProvider.kind
activeTimelineProvider.kind
}
init(roomProxy: JoinedRoomProxyProtocol,
@@ -36,12 +36,13 @@ struct TimelineControllerFactory: TimelineControllerFactoryProtocol {
appSettings: ServiceLocator.shared.settings)
}
func buildMessageFilteredTimelineController(allowedMessageTypes: [RoomMessageEventMessageType],
func buildMessageFilteredTimelineController(focus: TimelineFocus,
allowedMessageTypes: [TimelineAllowedMessageType],
presentation: TimelineKind.MediaPresentation,
roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
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):
return .success(TimelineController(roomProxy: roomProxy,
timelineProxy: timelineProxy,
@@ -23,7 +23,8 @@ protocol TimelineControllerFactoryProtocol {
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> TimelineControllerProtocol?
func buildMessageFilteredTimelineController(allowedMessageTypes: [RoomMessageEventMessageType],
func buildMessageFilteredTimelineController(focus: TimelineFocus,
allowedMessageTypes: [TimelineAllowedMessageType],
presentation: TimelineKind.MediaPresentation,
roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
@@ -16,7 +16,14 @@ enum TimelineControllerCallback {
}
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 none
}
@@ -76,7 +76,6 @@ struct RoomStateEventStringBuilder {
}
}
// swiftlint:disable:next function_parameter_count
func buildProfileChangeString(displayName: String?, previousDisplayName: String?,
avatarURLString: String?, previousAvatarURLString: String?,
member: String, memberIsYou: Bool) -> String? {
@@ -379,8 +379,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts),
encryptionAuthenticity: authenticity(eventItemProxy.shieldState)))
}
// swiftlint:disable:next function_parameter_count
private func buildPollTimelineItem(_ question: String,
_ pollKind: PollKind,
_ maxSelections: UInt64,
@@ -645,7 +644,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
return buildStateTimelineItem(for: eventItemProxy, text: text, isOutgoing: isOutgoing)
}
// swiftlint:disable:next function_parameter_count
private func buildStateProfileChangeTimelineItem(for eventItemProxy: EventTimelineItemProxy,
displayName: String?,
previousDisplayName: String?,
@@ -591,8 +591,8 @@ final class TimelineProxy: TimelineProxyProtocol {
backPaginationStatusSubject.send(.idle)
forwardPaginationStatusSubject.send(.idle)
case .media(let presentation):
backPaginationStatusSubject.send(.idle)
forwardPaginationStatusSubject.send(presentation == .mediaFilesScreen ? .timelineEndReached : .idle)
backPaginationStatusSubject.send(presentation == .pinnedEventsScreen ? .timelineEndReached : .idle)
forwardPaginationStatusSubject.send(presentation == .roomScreenDetached ? .idle : .timelineEndReached)
case .pinned:
backPaginationStatusSubject.send(.timelineEndReached)
forwardPaginationStatusSubject.send(.timelineEndReached)
@@ -14,10 +14,20 @@ enum TimelineKind: Equatable {
case detached
case pinned
enum MediaPresentation { case roomScreen, mediaFilesScreen }
enum MediaPresentation { case roomScreenLive, roomScreenDetached, pinnedEventsScreen, mediaFilesScreen }
case media(MediaPresentation)
}
enum TimelineFocus {
case live
case eventID(String)
case pinned
}
enum TimelineAllowedMessageType {
case audio, file, image, video
}
enum TimelineProxyError: Error {
case sdkError(Error)