New timeline (#276) (#280)

* Fixes #276 - Rebuilt room timeline:
    - Removed the need for the ListCollectionViewAdapter
    - Rewrote the TimelineItemList without using introspection
    - Added ReversedScrollView for laying out items at the bottom/trailing
    - Rewrote TimelineProvider diffing through CollectionDifference (similar to the RoomSummaryProvider)
    - Added back `scrollDismissesKeyboard`  behavior
    - Various other tweaks and fixes
- Fixed various warnings:
    - removed async AttributedStringBuilder as AttributedString is non-sendable, made the RoomTimelineItemFactory synchronous
    - removed unused virtual timeline items
    - removed unused isOutgoing property from the FormattedBodyText
* Make TimelineItemContextMenuActions indentifiable and specify contextMenu identifiers
* Bump the matrix-rust-components-swift to v1.0.16-alpha
* Add changes file and changelog contribution guide
* Fix attributed string builder unit tests
This commit is contained in:
Stefan Ceriu
2022-11-02 13:03:34 +02:00
committed by GitHub
parent b270b8a30e
commit fabb0bc95f
31 changed files with 516 additions and 584 deletions
@@ -19,8 +19,6 @@ import Foundation
import MatrixRustSDK
private class WeakRoomSummaryProviderWrapper: SlidingSyncViewRoomListObserver, SlidingSyncViewStateObserver, SlidingSyncViewRoomsCountObserver {
private weak var roomSummaryProvider: RoomSummaryProvider?
/// Publishes room list diffs as they come in through sliding sync
let roomListDiffPublisher = PassthroughSubject<SlidingSyncViewRoomsListDiff, Never>()
@@ -29,11 +27,7 @@ private class WeakRoomSummaryProviderWrapper: SlidingSyncViewRoomListObserver, S
/// Publishes the number of available rooms
let countUpdatePublisher = CurrentValueSubject<UInt, Never>(0)
init(roomSummaryProvider: RoomSummaryProvider) {
self.roomSummaryProvider = roomSummaryProvider
}
// MARK: - SlidingSyncViewRoomListObserver
func didReceiveUpdate(diff: SlidingSyncViewRoomsListDiff) {
@@ -85,7 +79,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
self.slidingSyncController = slidingSyncController
self.roomMessageFactory = roomMessageFactory
let weakProvider = WeakRoomSummaryProviderWrapper(roomSummaryProvider: self)
let weakProvider = WeakRoomSummaryProviderWrapper()
weakProvider.stateUpdatePublisher
.map(RoomSummaryProviderState.init)
@@ -17,8 +17,11 @@
import Combine
struct MockRoomTimelineProvider: RoomTimelineProviderProtocol {
var callbacks = PassthroughSubject<RoomTimelineProviderCallback, Never>()
var itemProxies = [TimelineItemProxy]()
var itemsPublisher = CurrentValueSubject<[TimelineItemProxy], Never>([])
var backPaginationPublisher = CurrentValueSubject<Bool, Never>(false)
private var itemProxies = [TimelineItemProxy]()
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError> {
.failure(.failedPaginatingBackwards)
@@ -51,14 +51,23 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
self.roomProxy = roomProxy
self.timelineProvider
.callbacks
.itemsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
.sink { [weak self] _ in
guard let self else { return }
switch callback {
case .updatedMessages:
self.updateTimelineItems()
self.updateTimelineItems()
}
.store(in: &cancellables)
self.timelineProvider
.backPaginationPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] value in
if value {
self?.callbacks.send(.startedBackPaginating)
} else {
self?.callbacks.send(.finishedBackPaginating)
}
}
.store(in: &cancellables)
@@ -71,7 +80,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineControllerError> {
switch await timelineProvider.paginateBackwards(count) {
case .success:
updateTimelineItems()
return .success(())
case .failure:
return .failure(.generic)
@@ -135,13 +143,13 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private func asyncUpdateTimelineItems() async {
var newTimelineItems = [RoomTimelineItemProtocol]()
for (index, itemProxy) in timelineProvider.itemProxies.enumerated() {
for (index, itemProxy) in timelineProvider.itemsPublisher.value.enumerated() {
if Task.isCancelled {
return
}
let previousItemProxy = timelineProvider.itemProxies[safe: index - 1]
let nextItemProxy = timelineProvider.itemProxies[safe: index + 1]
let previousItemProxy = timelineProvider.itemsPublisher.value[safe: index - 1]
let nextItemProxy = timelineProvider.itemsPublisher.value[safe: index + 1]
let inGroupState = inGroupState(for: itemProxy, previousItemProxy: previousItemProxy, nextItemProxy: nextItemProxy)
@@ -149,16 +157,9 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
case .event(let eventItem):
guard eventItem.isMessage || eventItem.isRedacted else { break } // To be handled in the future
newTimelineItems.append(await timelineItemFactory.buildTimelineItemFor(eventItemProxy: eventItem,
inGroupState: inGroupState))
case .virtual:
// case .virtual(let virtualItem):
// newTimelineItems.append(SeparatorRoomTimelineItem(id: message.originServerTs.ISO8601Format(),
// text: message.originServerTs.formatted(date: .complete, time: .omitted)))
#warning("Fix the UUID or \"bad things will happen\"")
newTimelineItems.append(SeparatorRoomTimelineItem(id: UUID().uuidString,
text: "The day before"))
case .other:
newTimelineItems.append(timelineItemFactory.buildTimelineItemFor(eventItemProxy: eventItem,
inGroupState: inGroupState))
default:
break
}
}
@@ -20,6 +20,8 @@ import Foundation
enum RoomTimelineControllerCallback {
case updatedTimelineItems
case updatedTimelineItem(_ itemId: String)
case startedBackPaginating
case finishedBackPaginating
}
enum RoomTimelineControllerError: Error {
@@ -17,16 +17,11 @@
import Combine
import MatrixRustSDK
#warning("Rename to RoomTimelineListener???")
class WeakRoomTimelineProviderWrapper: TimelineListener {
private weak var timelineProvider: RoomTimelineProvider?
init(timelineProvider: RoomTimelineProvider) {
self.timelineProvider = timelineProvider
}
private class RoomTimelineListener: TimelineListener {
let itemsUpdatePublisher = PassthroughSubject<TimelineDiff, Never>()
func onUpdate(update: TimelineDiff) {
timelineProvider?.onUpdate(update: update)
itemsUpdatePublisher.send(update)
}
}
@@ -34,25 +29,49 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
private let roomProxy: RoomProxyProtocol
private var cancellables = Set<AnyCancellable>()
let callbacks = PassthroughSubject<RoomTimelineProviderCallback, Never>()
let itemsPublisher = CurrentValueSubject<[TimelineItemProxy], Never>([])
let backPaginationPublisher = CurrentValueSubject<Bool, Never>(false)
private(set) var itemProxies: [TimelineItemProxy]
private var itemProxies: [TimelineItemProxy] {
didSet {
itemsPublisher.send(itemProxies)
if backPaginationPublisher.value == true {
backPaginationPublisher.send(false)
}
}
}
init(roomProxy: RoomProxyProtocol) {
self.roomProxy = roomProxy
itemProxies = []
Task {
await roomProxy.addTimelineListener(listener: WeakRoomTimelineProviderWrapper(timelineProvider: self))
let roomTimelineListener = RoomTimelineListener()
await roomProxy.addTimelineListener(listener: roomTimelineListener)
roomTimelineListener
.itemsUpdatePublisher
.collect(.byTime(DispatchQueue.global(qos: .background), 0.5))
.sink { self.updateItemsWithDiffs($0) }
.store(in: &cancellables)
}
}
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError> {
// Set this back to false after actually updating the items or if failed
backPaginationPublisher.send(true)
switch await roomProxy.paginateBackwards(count: count) {
case .success:
return .success(())
case .failure(let error):
if error == .noMoreMessagesToBackPaginate { return .failure(.noMoreMessagesToBackPaginate) }
backPaginationPublisher.send(false)
if error == .noMoreMessagesToBackPaginate {
return .failure(.noMoreMessagesToBackPaginate)
}
return .failure(.failedPaginatingBackwards)
}
}
@@ -74,74 +93,70 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
return .failure(.failedRedactingItem)
}
}
}
// MARK: - Private
// MARK: - TimelineListener
private extension RoomTimelineProvider {
func onUpdate(update: TimelineDiff) {
let change = update.change()
MXLog.verbose("Change: \(change)")
private func updateItemsWithDiffs(_ diffs: [TimelineDiff]) {
itemProxies = diffs
.compactMap(buildDiff)
.reduce(itemProxies) { $0.applying($1) ?? $0 }
}
// swiftlint:disable:next cyclomatic_complexity
private func buildDiff(from diff: TimelineDiff) -> CollectionDifference<TimelineItemProxy>? {
var changes = [CollectionDifference<TimelineItemProxy>.Change]()
switch change {
case .replace:
replaceItems(update.replace())
case .insertAt:
insertItem(update.insertAt())
case .updateAt:
updateItem(update.updateAt())
case .removeAt:
removeItem(at: update.removeAt())
case .move:
moveItem(update.move())
switch diff.change() {
case .push:
pushItem(update.push())
case .pop:
popItem()
if let item = diff.push() {
let itemProxy = TimelineItemProxy(item: item)
changes.append(.insert(offset: Int(itemProxies.count), element: itemProxy, associatedWith: nil))
}
case .updateAt:
if let update = diff.updateAt() {
let itemProxy = TimelineItemProxy(item: update.item)
changes.append(.remove(offset: Int(update.index), element: itemProxy, associatedWith: nil))
changes.append(.insert(offset: Int(update.index), element: itemProxy, associatedWith: nil))
}
case .insertAt:
if let update = diff.insertAt() {
let itemProxy = TimelineItemProxy(item: update.item)
changes.append(.insert(offset: Int(update.index), element: itemProxy, associatedWith: nil))
}
case .move:
if let update = diff.move() {
let itemProxy = itemProxies[Int(update.oldIndex)]
changes.append(.remove(offset: Int(update.oldIndex), element: itemProxy, associatedWith: nil))
changes.append(.insert(offset: Int(update.newIndex), element: itemProxy, associatedWith: nil))
}
case .removeAt:
if let index = diff.removeAt() {
let itemProxy = itemProxies[Int(index)]
changes.append(.remove(offset: Int(index), element: itemProxy, associatedWith: nil))
}
case .replace:
if let items = diff.replace() {
for (index, itemProxy) in itemProxies.enumerated() {
changes.append(.remove(offset: index, element: itemProxy, associatedWith: nil))
}
items
.reversed()
.map { TimelineItemProxy(item: $0) }
.forEach { itemProxy in
changes.append(.insert(offset: 0, element: itemProxy, associatedWith: nil))
}
}
case .clear:
clearAllItems()
for (index, itemProxy) in itemProxies.enumerated() {
changes.append(.remove(offset: index, element: itemProxy, associatedWith: nil))
}
case .pop:
if let itemProxy = itemProxies.last {
changes.append(.remove(offset: itemProxies.count - 1, element: itemProxy, associatedWith: nil))
}
}
callbacks.send(.updatedMessages)
}
private func replaceItems(_ items: [MatrixRustSDK.TimelineItem]?) {
guard let items else { return }
itemProxies = items.map(TimelineItemProxy.init)
}
private func insertItem(_ data: InsertAtData?) {
guard let data else { return }
let itemProxy = TimelineItemProxy(item: data.item)
itemProxies.insert(itemProxy, at: Int(data.index))
}
private func updateItem(_ data: UpdateAtData?) {
guard let data else { return }
let itemProxy = TimelineItemProxy(item: data.item)
itemProxies[Int(data.index)] = itemProxy
}
private func removeItem(at index: UInt32?) {
guard let index else { return }
itemProxies.remove(at: Int(index))
}
private func moveItem(_ data: MoveData?) {
guard let data else { return }
itemProxies.move(fromOffsets: IndexSet(integer: Int(data.oldIndex)), toOffset: Int(data.newIndex))
}
private func pushItem(_ item: MatrixRustSDK.TimelineItem?) {
guard let item else { return }
itemProxies.append(TimelineItemProxy(item: item))
}
private func popItem() {
itemProxies.removeLast()
}
private func clearAllItems() {
itemProxies.removeAll()
return CollectionDifference(changes)
}
}
@@ -17,10 +17,6 @@
import Combine
import Foundation
enum RoomTimelineProviderCallback {
case updatedMessages
}
enum RoomTimelineProviderError: Error {
case noMoreMessagesToBackPaginate
case failedPaginatingBackwards
@@ -30,9 +26,9 @@ enum RoomTimelineProviderError: Error {
}
protocol RoomTimelineProviderProtocol {
var callbacks: PassthroughSubject<RoomTimelineProviderCallback, Never> { get }
var itemsPublisher: CurrentValueSubject<[TimelineItemProxy], Never> { get }
var itemProxies: [TimelineItemProxy] { get }
var backPaginationPublisher: CurrentValueSubject<Bool, Never> { get }
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError>
@@ -36,7 +36,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
}
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy,
inGroupState: TimelineItemInGroupState) async -> RoomTimelineItemProtocol {
inGroupState: TimelineItemInGroupState) -> RoomTimelineItemProtocol {
let displayName = roomProxy.displayNameForUserId(eventItemProxy.sender)
let avatarURL = roomProxy.avatarURLStringForUserId(eventItemProxy.sender)
let avatarImage = mediaProvider.imageFromURLString(avatarURL, avatarSize: .user(on: .timeline))
@@ -51,18 +51,18 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
switch messageContent.msgtype() {
case .text(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return await buildTextTimelineItemFromMessage(message, isOutgoing, inGroupState, displayName, avatarImage)
return buildTextTimelineItemFromMessage(message, isOutgoing, inGroupState, displayName, avatarImage)
case .image(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return await buildImageTimelineItemFromMessage(message, isOutgoing, inGroupState, displayName, avatarImage)
return buildImageTimelineItemFromMessage(message, isOutgoing, inGroupState, displayName, avatarImage)
case .notice(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return await buildNoticeTimelineItemFromMessage(message, isOutgoing, inGroupState, displayName, avatarImage)
return buildNoticeTimelineItemFromMessage(message, isOutgoing, inGroupState, displayName, avatarImage)
case .emote(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return await buildEmoteTimelineItemFromMessage(message, isOutgoing, inGroupState, displayName, avatarImage)
return buildEmoteTimelineItemFromMessage(message, isOutgoing, inGroupState, displayName, avatarImage)
case .none:
return await buildFallbackTimelineItem(eventItemProxy, isOutgoing, inGroupState, displayName, avatarImage)
return buildFallbackTimelineItem(eventItemProxy, isOutgoing, inGroupState, displayName, avatarImage)
}
}
@@ -88,8 +88,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
_ isOutgoing: Bool,
_ inGroupState: TimelineItemInGroupState,
_ displayName: String?,
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
let attributedText = await attributedStringBuilder.fromPlain(eventItemProxy.body)
_ avatarImage: UIImage?) -> RoomTimelineItemProtocol {
let attributedText = attributedStringBuilder.fromPlain(eventItemProxy.body)
let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText)
return TextRoomTimelineItem(id: eventItemProxy.id,
@@ -109,8 +109,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
_ isOutgoing: Bool,
_ inGroupState: TimelineItemInGroupState,
_ displayName: String?,
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
_ avatarImage: UIImage?) -> RoomTimelineItemProtocol {
let attributedText = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText)
return TextRoomTimelineItem(id: message.id,
@@ -130,7 +130,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
_ isOutgoing: Bool,
_ inGroupState: TimelineItemInGroupState,
_ displayName: String?,
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
_ avatarImage: UIImage?) -> RoomTimelineItemProtocol {
var aspectRatio: CGFloat?
if let width = message.width,
let height = message.height {
@@ -159,8 +159,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
_ isOutgoing: Bool,
_ inGroupState: TimelineItemInGroupState,
_ displayName: String?,
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
_ avatarImage: UIImage?) -> RoomTimelineItemProtocol {
let attributedText = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText)
return NoticeRoomTimelineItem(id: message.id,
@@ -180,8 +180,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
_ isOutgoing: Bool,
_ inGroupState: TimelineItemInGroupState,
_ displayName: String?,
_ avatarImage: UIImage?) async -> RoomTimelineItemProtocol {
let attributedText = await (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
_ avatarImage: UIImage?) -> RoomTimelineItemProtocol {
let attributedText = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
let attributedComponents = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedText)
return EmoteRoomTimelineItem(id: message.id,
@@ -19,5 +19,5 @@ import Foundation
@MainActor
protocol RoomTimelineItemFactoryProtocol {
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy,
inGroupState: TimelineItemInGroupState) async -> RoomTimelineItemProtocol
inGroupState: TimelineItemInGroupState) -> RoomTimelineItemProtocol
}