mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Rewrote timeline back pagination on top of direct access to the underlying UITableView.
This commit is contained in:
@@ -29,4 +29,5 @@ enum RoomScreenViewAction {
|
|||||||
struct RoomScreenViewState: BindableState {
|
struct RoomScreenViewState: BindableState {
|
||||||
var roomTitle: String = ""
|
var roomTitle: String = ""
|
||||||
var timelineItems: [RoomTimelineViewProvider] = []
|
var timelineItems: [RoomTimelineViewProvider] = []
|
||||||
|
var isBackPaginating = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState,
|
|||||||
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
|
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
|
||||||
|
|
||||||
private struct Constants {
|
private struct Constants {
|
||||||
static let backPaginationPageSize: UInt = 20
|
static let backPaginationPageSize: UInt = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
private let roomProxy: RoomProxyProtocol
|
private let roomProxy: RoomProxyProtocol
|
||||||
@@ -58,7 +58,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
override func process(viewAction: RoomScreenViewAction) {
|
override func process(viewAction: RoomScreenViewAction) {
|
||||||
switch viewAction {
|
switch viewAction {
|
||||||
case .loadPreviousPage:
|
case .loadPreviousPage:
|
||||||
timelineController.paginateBackwards(Constants.backPaginationPageSize)
|
state.isBackPaginating = true
|
||||||
|
timelineController.paginateBackwards(Constants.backPaginationPageSize) { [weak self] _ in
|
||||||
|
self?.state.isBackPaginating = false
|
||||||
|
}
|
||||||
case .itemAppeared:
|
case .itemAppeared:
|
||||||
break
|
break
|
||||||
case .itemDisappeared:
|
case .itemDisappeared:
|
||||||
|
|||||||
@@ -20,47 +20,32 @@ import Combine
|
|||||||
|
|
||||||
struct RoomScreen: View {
|
struct RoomScreen: View {
|
||||||
|
|
||||||
@State private var scrollViewObserver: ScrollViewObserver = ScrollViewObserver()
|
@State private var tableViewObserver: TableViewObserver = TableViewObserver()
|
||||||
@State private var timelineItems: [RoomTimelineViewProvider] = []
|
@State private var timelineItems: [RoomTimelineViewProvider] = []
|
||||||
|
@State private var hasPendingChanges = false
|
||||||
@State private var didRequestBackPagination = false
|
@State private var text: String = ""
|
||||||
@State private var hasPendingMessages = false
|
|
||||||
@State private var wasBottomVisible = false
|
|
||||||
|
|
||||||
@State private var previousTopMostMessageIdentifier: String?
|
|
||||||
|
|
||||||
private let timelineBottomAnchor = "TimelineBottomAnchor"
|
|
||||||
|
|
||||||
@ObservedObject var context: RoomScreenViewModel.Context
|
@ObservedObject var context: RoomScreenViewModel.Context
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { scrollViewProxy in
|
// The observer behaves differently when not in an reader
|
||||||
|
ScrollViewReader { _ in
|
||||||
List {
|
List {
|
||||||
if didRequestBackPagination == false {
|
HStack {
|
||||||
HStack {
|
Spacer()
|
||||||
Spacer()
|
ProgressView()
|
||||||
ProgressView()
|
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
|
||||||
Spacer()
|
.animation(.default, value: context.viewState.isBackPaginating)
|
||||||
}
|
Spacer()
|
||||||
.onAppear {
|
|
||||||
guard didRequestBackPagination == false else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
didRequestBackPagination = true
|
|
||||||
context.send(viewAction: .loadPreviousPage)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(timelineItems) { timelineItem in
|
// No idea why previews don't work otherwise
|
||||||
|
ForEach(isPreview ? context.viewState.timelineItems : timelineItems) { timelineItem in
|
||||||
timelineItem
|
timelineItem
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.task {
|
||||||
|
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
context.send(viewAction: .itemAppeared(id: timelineItem.id))
|
context.send(viewAction: .itemAppeared(id: timelineItem.id))
|
||||||
}
|
}
|
||||||
@@ -68,121 +53,190 @@ struct RoomScreen: View {
|
|||||||
context.send(viewAction: .itemDisappeared(id: timelineItem.id))
|
context.send(viewAction: .itemDisappeared(id: timelineItem.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Color.clear
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.id(timelineBottomAnchor)
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.navigationTitle(context.viewState.roomTitle)
|
.navigationTitle(context.viewState.roomTitle)
|
||||||
.environment(\.defaultMinListRowHeight, 0.0)
|
.environment(\.defaultMinListRowHeight, 0.0)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
// Fetch the underlying UIScrollView and start observing it
|
.introspectTableView { tableView in
|
||||||
.introspectTableView { scrollView in
|
if tableView == tableViewObserver.tableView {
|
||||||
if scrollView == scrollViewObserver.scrollView {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollViewObserver = ScrollViewObserver(scrollView: scrollView)
|
tableViewObserver = TableViewObserver(tableView: tableView)
|
||||||
}
|
|
||||||
// Scroll to the bottom when the timeline first appears
|
|
||||||
.onAppear {
|
|
||||||
scrollViewProxy.scrollTo(timelineBottomAnchor, anchor: .bottom)
|
|
||||||
}
|
|
||||||
// When the view state changes check whether the user is interacting with the scroll view.
|
|
||||||
// Updating in that case causes undesired scrolling. Delay until the scroll view stops scrolling.
|
|
||||||
// Also store previous top most message identifier to have something to scroll to after the update.
|
|
||||||
.onChange(of: context.viewState.timelineItems) { newValue in
|
|
||||||
previousTopMostMessageIdentifier = timelineItems.first?.id
|
|
||||||
wasBottomVisible = scrollViewObserver.isBottomVisible
|
|
||||||
|
|
||||||
if scrollViewObserver.isTracking == true {
|
// Check if there are enough items. Otherwise ask for more
|
||||||
hasPendingMessages = true
|
attemptBackPagination()
|
||||||
|
}
|
||||||
|
.onReceive(tableViewObserver.scrollViewDidReachTop, perform: {
|
||||||
|
if context.viewState.isBackPaginating {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
timelineItems = newValue
|
attemptBackPagination()
|
||||||
}
|
|
||||||
// Check if we have pending messages to apply and apply them when the scroll finishes scrolling
|
|
||||||
.onReceive(scrollViewObserver.didEndScrolling, perform: {
|
|
||||||
if hasPendingMessages {
|
|
||||||
timelineItems = context.viewState.timelineItems
|
|
||||||
hasPendingMessages = false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
// Process timeline updates
|
.onChange(of: context.viewState.timelineItems) { _ in
|
||||||
.onChange(of: timelineItems, perform: { _ in
|
// Don't update the list while moving
|
||||||
if didRequestBackPagination && wasBottomVisible {
|
if tableViewObserver.isDecelerating || tableViewObserver.isTracking {
|
||||||
scrollViewProxy.scrollTo(timelineBottomAnchor, anchor: .bottom)
|
hasPendingChanges = true
|
||||||
} else if didRequestBackPagination == false {
|
return
|
||||||
if wasBottomVisible {
|
|
||||||
scrollViewProxy.scrollTo(timelineBottomAnchor, anchor: .bottom)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Manual scrolling breaks inertia. Don't do it if the scroll view is decelerating
|
|
||||||
if scrollViewObserver.isDecelerating == false {
|
|
||||||
scrollViewProxy.scrollTo(previousTopMostMessageIdentifier, anchor: .top)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
didRequestBackPagination = false
|
tableViewObserver.saveCurrentOffset()
|
||||||
|
timelineItems = context.viewState.timelineItems
|
||||||
|
}
|
||||||
|
.onReceive(tableViewObserver.scrollViewDidRest, perform: {
|
||||||
|
if hasPendingChanges == false {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tableViewObserver.saveCurrentOffset()
|
||||||
|
timelineItems = context.viewState.timelineItems
|
||||||
|
hasPendingChanges = false
|
||||||
|
})
|
||||||
|
.onChange(of: timelineItems, perform: { _ in
|
||||||
|
tableViewObserver.restoreSavedOffset()
|
||||||
|
|
||||||
|
// Check if there are enough items. Otherwise ask for more
|
||||||
|
attemptBackPagination()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func attemptBackPagination() {
|
||||||
|
if context.viewState.isBackPaginating {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tableViewObserver.isTopVisible == false {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
context.send(viewAction: .loadPreviousPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isPreview: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple wrapper around an UIScrollView that publishes when it finishes scrolling
|
private class TableViewObserver: NSObject, UITableViewDelegate {
|
||||||
class ScrollViewObserver: NSObject, UIScrollViewDelegate {
|
|
||||||
private(set) var scrollView: UIScrollView?
|
|
||||||
|
|
||||||
let didEndScrolling = PassthroughSubject<Void, Never>()
|
private enum ContentOffsetDetails {
|
||||||
|
case topOffset(previousVisibleIndexPath: IndexPath, previousItemCount: Int)
|
||||||
|
case bottomOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
private let topTriggerHeight = 50.0
|
||||||
|
private var isAtTop: Bool = false
|
||||||
|
private var offsetDetails: ContentOffsetDetails?
|
||||||
|
|
||||||
|
private(set) var tableView: UITableView?
|
||||||
|
|
||||||
|
let scrollViewDidRest = PassthroughSubject<Void, Never>()
|
||||||
|
let scrollViewDidReachTop = PassthroughSubject<Void, Never>()
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(scrollView: UIScrollView) {
|
init(tableView: UITableView) {
|
||||||
self.scrollView = scrollView
|
self.tableView = tableView
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
scrollView.delegate = self
|
tableView.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveCurrentOffset() {
|
||||||
|
guard let tableView = tableView,
|
||||||
|
tableView.numberOfSections > 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isBottomVisible {
|
||||||
|
offsetDetails = .bottomOffset
|
||||||
|
} else if isTopVisible {
|
||||||
|
if let topIndexPath = tableView.indexPathsForVisibleRows?.first {
|
||||||
|
offsetDetails = .topOffset(previousVisibleIndexPath: topIndexPath,
|
||||||
|
previousItemCount: tableView.numberOfRows(inSection: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreSavedOffset() {
|
||||||
|
defer {
|
||||||
|
offsetDetails = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let tableView = tableView,
|
||||||
|
tableView.numberOfSections > 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentItemCount = tableView.numberOfRows(inSection: 0)
|
||||||
|
|
||||||
|
switch offsetDetails {
|
||||||
|
case .bottomOffset:
|
||||||
|
tableView.scrollToRow(at: .init(row: max(0, currentItemCount - 1), section: 0), at: .bottom, animated: false)
|
||||||
|
case .topOffset(let indexPath, let previousItemCount):
|
||||||
|
let row = indexPath.row + max(0, (currentItemCount - previousItemCount))
|
||||||
|
if row < currentItemCount {
|
||||||
|
tableView.scrollToRow(at: .init(row: row, section: 0), at: .top, animated: false)
|
||||||
|
}
|
||||||
|
case .none:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isTracking: Bool {
|
var isTracking: Bool {
|
||||||
self.scrollView?.isTracking == true
|
self.tableView?.isTracking == true
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDecelerating: Bool {
|
var isDecelerating: Bool {
|
||||||
self.scrollView?.isDecelerating == true
|
self.tableView?.isDecelerating == true
|
||||||
}
|
}
|
||||||
|
|
||||||
var isTopVisible: Bool {
|
var isTopVisible: Bool {
|
||||||
guard let scrollView = scrollView else {
|
guard let scrollView = tableView else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) <= 0.0
|
return (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) <= topTriggerHeight
|
||||||
}
|
|
||||||
|
|
||||||
var isBottomVisible: Bool {
|
|
||||||
guard let scrollView = scrollView else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return (scrollView.contentOffset.y) >= (scrollView.contentSize.height - scrollView.frame.size.height)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UIScrollViewDelegate
|
// MARK: - UIScrollViewDelegate
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
let isTopVisible = isTopVisible
|
||||||
|
if isTopVisible && isAtTop != isTopVisible {
|
||||||
|
scrollViewDidReachTop.send(())
|
||||||
|
}
|
||||||
|
|
||||||
|
isAtTop = isTopVisible
|
||||||
|
}
|
||||||
|
|
||||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
didEndScrolling.send(())
|
scrollViewDidRest.send(())
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerating: Bool) {
|
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerating: Bool) {
|
||||||
if decelerating == false {
|
if decelerating == false {
|
||||||
didEndScrolling.send(())
|
scrollViewDidRest.send(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private var isBottomVisible: Bool {
|
||||||
|
guard let scrollView = tableView else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (scrollView.contentOffset.y) >= (scrollView.contentSize.height - scrollView.frame.size.height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
@@ -191,6 +245,7 @@ struct RoomScreen_Previews: PreviewProvider {
|
|||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let viewModel = RoomScreenViewModel(roomProxy: MockRoomProxy(displayName: "Test"),
|
let viewModel = RoomScreenViewModel(roomProxy: MockRoomProxy(displayName: "Test"),
|
||||||
timelineController: MockRoomTimelineController())
|
timelineController: MockRoomTimelineController())
|
||||||
|
|
||||||
RoomScreen(context: viewModel.context)
|
RoomScreen(context: viewModel.context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
|
|
||||||
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
|
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
|
||||||
|
|
||||||
let timelineItems: [RoomTimelineViewProvider] =
|
var timelineItems: [RoomTimelineViewProvider] = [RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Yesterday")),
|
||||||
[RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Yesterday")),
|
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You rock!", timestamp: "10:10 AM", shouldShowSenderDetails: true)),
|
||||||
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You rock!", timestamp: "10:10 AM", shouldShowSenderDetails: true)),
|
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You also rule!", timestamp: "10:11 AM", shouldShowSenderDetails: false)),
|
||||||
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You also rule!", timestamp: "10:11 AM", shouldShowSenderDetails: false)),
|
RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Today")),
|
||||||
RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Today")),
|
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Bob", text: "You too!", timestamp: "5 PM", shouldShowSenderDetails: true))]
|
||||||
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Bob", text: "You too!", timestamp: "5 PM", shouldShowSenderDetails: true))]
|
|
||||||
|
|
||||||
func paginateBackwards(_ count: UInt) {
|
|
||||||
|
|
||||||
|
func paginateBackwards(_ count: UInt, callback: ((Result<Void, RoomTimelineControllerError>) -> Void)) {
|
||||||
|
callbacks.send(.updatedTimelineItems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ import Foundation
|
|||||||
import Combine
|
import Combine
|
||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
|
|
||||||
enum RoomTimelineControllerCallback {
|
|
||||||
case updatedTimelineItems
|
|
||||||
}
|
|
||||||
|
|
||||||
class RoomTimelineController: RoomTimelineControllerProtocol {
|
class RoomTimelineController: RoomTimelineControllerProtocol {
|
||||||
private let timelineProvider: RoomTimelineProvider
|
private let timelineProvider: RoomTimelineProvider
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@@ -29,50 +25,62 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
switch callback {
|
switch callback {
|
||||||
case .updatedMessages:
|
case .addedMessage:
|
||||||
var newTimelineItems = [RoomTimelineViewProvider]()
|
self.rebuildTimeline()
|
||||||
|
|
||||||
var previousMessage: Message?
|
|
||||||
for message in self.timelineProvider.messages {
|
|
||||||
let timestamp = Date(timeIntervalSince1970: TimeInterval(message.originServerTs()))
|
|
||||||
|
|
||||||
let areMessagesFromTheSameDay = self.haveSameDay(lhs: previousMessage, rhs: message)
|
|
||||||
let shouldAddSectionHeader = !areMessagesFromTheSameDay
|
|
||||||
|
|
||||||
if shouldAddSectionHeader {
|
|
||||||
let item = SeparatorRoomTimelineItem(id: timestamp.ISO8601Format(),
|
|
||||||
text: timestamp.formatted(date: .long, time: .omitted))
|
|
||||||
|
|
||||||
newTimelineItems.append(RoomTimelineViewProvider.separator(item))
|
|
||||||
}
|
|
||||||
|
|
||||||
let areMessagesFromTheSameSender = (previousMessage?.sender() == message.sender())
|
|
||||||
let shouldShowSenderDetails = !areMessagesFromTheSameSender || !areMessagesFromTheSameDay
|
|
||||||
|
|
||||||
let item = TextRoomTimelineItem(id: message.id(),
|
|
||||||
senderDisplayName: message.sender(),
|
|
||||||
text: message.content(),
|
|
||||||
timestamp: timestamp.formatted(date: .omitted, time: .shortened),
|
|
||||||
shouldShowSenderDetails: shouldShowSenderDetails)
|
|
||||||
|
|
||||||
newTimelineItems.append(RoomTimelineViewProvider.text(item))
|
|
||||||
|
|
||||||
previousMessage = message
|
|
||||||
}
|
|
||||||
|
|
||||||
self.timelineItems = newTimelineItems
|
|
||||||
|
|
||||||
self.callbacks.send(.updatedTimelineItems)
|
|
||||||
}
|
}
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func paginateBackwards(_ count: UInt) {
|
func paginateBackwards(_ count: UInt, callback: @escaping ((Result<Void, RoomTimelineControllerError>) -> Void)) {
|
||||||
timelineProvider.paginateBackwards(count)
|
timelineProvider.paginateBackwards(count) { [weak self] result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
callback(.success(()))
|
||||||
|
self?.rebuildTimeline()
|
||||||
|
case .failure:
|
||||||
|
callback(.failure(.generic))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func rebuildTimeline() {
|
||||||
|
var newTimelineItems = [RoomTimelineViewProvider]()
|
||||||
|
|
||||||
|
var previousMessage: Message?
|
||||||
|
for message in self.timelineProvider.messages {
|
||||||
|
let timestamp = Date(timeIntervalSince1970: TimeInterval(message.originServerTs()))
|
||||||
|
|
||||||
|
let areMessagesFromTheSameDay = self.haveSameDay(lhs: previousMessage, rhs: message)
|
||||||
|
let shouldAddSectionHeader = !areMessagesFromTheSameDay
|
||||||
|
|
||||||
|
if shouldAddSectionHeader {
|
||||||
|
let item = SeparatorRoomTimelineItem(id: timestamp.ISO8601Format(),
|
||||||
|
text: timestamp.formatted(date: .long, time: .omitted))
|
||||||
|
|
||||||
|
newTimelineItems.append(RoomTimelineViewProvider.separator(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
let areMessagesFromTheSameSender = (previousMessage?.sender() == message.sender())
|
||||||
|
let shouldShowSenderDetails = !areMessagesFromTheSameSender || !areMessagesFromTheSameDay
|
||||||
|
|
||||||
|
let item = TextRoomTimelineItem(id: message.id(),
|
||||||
|
senderDisplayName: message.sender(),
|
||||||
|
text: message.content(),
|
||||||
|
timestamp: timestamp.formatted(date: .omitted, time: .shortened),
|
||||||
|
shouldShowSenderDetails: shouldShowSenderDetails)
|
||||||
|
|
||||||
|
newTimelineItems.append(RoomTimelineViewProvider.text(item))
|
||||||
|
|
||||||
|
previousMessage = message
|
||||||
|
}
|
||||||
|
|
||||||
|
self.timelineItems = newTimelineItems
|
||||||
|
|
||||||
|
self.callbacks.send(.updatedTimelineItems)
|
||||||
|
}
|
||||||
|
|
||||||
private func haveSameDay(lhs: Message?, rhs: Message?) -> Bool {
|
private func haveSameDay(lhs: Message?, rhs: Message?) -> Bool {
|
||||||
guard let lhs = lhs, let rhs = rhs else {
|
guard let lhs = lhs, let rhs = rhs else {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -9,9 +9,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
enum RoomTimelineControllerCallback {
|
||||||
|
case updatedTimelineItems
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RoomTimelineControllerError: Error {
|
||||||
|
case generic
|
||||||
|
}
|
||||||
|
|
||||||
protocol RoomTimelineControllerProtocol {
|
protocol RoomTimelineControllerProtocol {
|
||||||
var timelineItems: [RoomTimelineViewProvider] { get }
|
var timelineItems: [RoomTimelineViewProvider] { get }
|
||||||
var callbacks: PassthroughSubject<RoomTimelineControllerCallback, Never> { get }
|
var callbacks: PassthroughSubject<RoomTimelineControllerCallback, Never> { get }
|
||||||
|
|
||||||
func paginateBackwards(_ count: UInt)
|
func paginateBackwards(_ count: UInt, callback: @escaping ((Result<Void, RoomTimelineControllerError>) -> Void))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ import Combine
|
|||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
|
|
||||||
enum RoomTimelineCallback {
|
enum RoomTimelineCallback {
|
||||||
case updatedMessages
|
case addedMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RoomTimelineError: Error {
|
||||||
|
case generic
|
||||||
}
|
}
|
||||||
|
|
||||||
class RoomTimelineProvider {
|
class RoomTimelineProvider {
|
||||||
private let roomProxy: RoomProxyProtocol
|
private let roomProxy: RoomProxyProtocol
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
private var paginationCounter: UInt = 0
|
|
||||||
|
|
||||||
let callbacks = PassthroughSubject<RoomTimelineCallback, Never>()
|
let callbacks = PassthroughSubject<RoomTimelineCallback, Never>()
|
||||||
private(set) var messages = [Message]()
|
private(set) var messages = [Message]()
|
||||||
|
|
||||||
@@ -32,24 +34,21 @@ class RoomTimelineProvider {
|
|||||||
switch callback {
|
switch callback {
|
||||||
case .addedMessage(let message):
|
case .addedMessage(let message):
|
||||||
self.messages.append(message)
|
self.messages.append(message)
|
||||||
|
self.callbacks.send(.addedMessage)
|
||||||
case .updatedLastMessage:
|
case .updatedLastMessage:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
self.callbacks.send(.updatedMessages)
|
|
||||||
|
|
||||||
}.store(in: &cancellables)
|
}.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func paginateBackwards(_ count: UInt) {
|
func paginateBackwards(_ count: UInt, callback: ((Result<([Message]), RoomTimelineError>) -> Void)?) {
|
||||||
self.roomProxy.paginateBackwards(count: count) { result in
|
self.roomProxy.paginateBackwards(count: count) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let messages):
|
case .success(let messages):
|
||||||
self.messages.insert(contentsOf: messages.reversed(), at: 0)
|
self.messages.insert(contentsOf: messages.reversed(), at: 0)
|
||||||
self.callbacks.send(.updatedMessages)
|
callback?(.success((self.messages)))
|
||||||
case .failure(let error):
|
case .failure:
|
||||||
MXLog.debug("Failed paginating backwards with error: \(error)")
|
callback?(.failure(.generic))
|
||||||
self.callbacks.send(.updatedMessages)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user