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
@@ -21,13 +21,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
private let temporaryBlockquoteMarkingColor = UIColor.magenta
private let temporaryCodeBlockMarkingColor = UIColor.cyan
private let linkColor = UIColor.blue
func fromPlain(_ string: String?) async -> AttributedString? {
await Task.dispatch(on: .global()) {
fromPlain(string)
}
}
func fromPlain(_ string: String?) -> AttributedString? {
guard let string else {
return nil
@@ -39,13 +33,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
return try? AttributedString(mutableAttributedString, including: \.elementX)
}
func fromHTML(_ htmlString: String?) async -> AttributedString? {
await Task.dispatch(on: .global()) {
fromHTML(htmlString)
}
}
// Do not use the default HTML renderer of NSAttributedString because this method
// runs on the UI thread which we want to avoid because renderHTMLString is called
// most of the time from a background thread.
@@ -23,10 +23,8 @@ struct AttributedStringBuilderComponent: Hashable {
protocol AttributedStringBuilderProtocol {
func fromPlain(_ string: String?) -> AttributedString?
func fromPlain(_ string: String?) async -> AttributedString?
func fromHTML(_ htmlString: String?) -> AttributedString?
func fromHTML(_ htmlString: String?) async -> AttributedString?
func blockquoteCoalescedComponentsFrom(_ attributedString: AttributedString?) -> [AttributedStringBuilderComponent]?
}
@@ -0,0 +1,82 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
/// A SwiftUI scroll view that lays out its content starting at the bottom or trailing
/// https://www.thirdrocktechkno.com/blog/implementing-reversed-scrolling-behaviour-in-swiftui/
struct ReversedScrollView<Content: View>: View {
private let axis: Axis.Set
private let leadingSpace: CGFloat
private let content: Content
init(_ axis: Axis.Set = .horizontal, leadingSpace: CGFloat = 0, @ViewBuilder builder: () -> Content) {
self.axis = axis
self.leadingSpace = leadingSpace
content = builder()
}
var body: some View {
GeometryReader { proxy in
ScrollView(axis, showsIndicators: false) {
Stack(axis) {
Spacer(minLength: leadingSpace)
content
}
.frame(
minWidth: minWidth(in: proxy, for: axis),
minHeight: minHeight(in: proxy, for: axis)
)
}
}
}
private func minWidth(in proxy: GeometryProxy, for axis: Axis.Set) -> CGFloat? {
axis.contains(.horizontal) ? proxy.size.width : nil
}
private func minHeight(in proxy: GeometryProxy, for axis: Axis.Set) -> CGFloat? {
axis.contains(.vertical) ? proxy.size.height : nil
}
}
private struct Stack<Content: View>: View {
var axis: Axis.Set
var content: Content
init(_ axis: Axis.Set = .vertical, @ViewBuilder builder: () -> Content) {
self.axis = axis
content = builder()
}
var body: some View {
switch axis {
case .horizontal:
HStack {
content
}
case .vertical:
VStack {
content
}
default:
VStack {
content
}
}
}
}
@@ -19,12 +19,14 @@ import UIKit
enum RoomScreenViewModelAction { }
enum TimelineItemContextMenuAction: Hashable {
enum TimelineItemContextMenuAction: Identifiable, Hashable {
case copy
case quote
case copyPermalink
case redact
case reply
var id: Self { self }
}
enum RoomScreenComposerMode: Equatable {
@@ -58,6 +58,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
self.state.items[viewIndex] = timelineViewFactory.buildTimelineViewFor(timelineItem: timelineItem)
case .startedBackPaginating:
self.state.isBackPaginating = true
case .finishedBackPaginating:
self.state.isBackPaginating = false
}
}
.store(in: &cancellables)
@@ -81,11 +85,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
override func process(viewAction: RoomScreenViewAction) async {
switch viewAction {
case .loadPreviousPage:
state.isBackPaginating = true
guard !state.isBackPaginating else {
return
}
switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) {
default:
state.isBackPaginating = false
#warning("Treat errors")
}
case .itemAppeared(let id):
await timelineController.processItemAppearance(id)
@@ -1,223 +0,0 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import UIKit
class ListCollectionViewAdapter: NSObject, UICollectionViewDelegate {
private enum ContentOffsetDetails {
case topOffset(previousVisibleIndexPath: IndexPath, previousItemCount: Int)
case bottomOffset
}
private let topDetectionOffset: CGFloat
private let bottomDetectionOffset: CGFloat
private var contentOffsetObserverToken: NSKeyValueObservation?
private var boundsObserverToken: NSKeyValueObservation?
private var offsetDetails: ContentOffsetDetails?
private var draggingInitiated = false
private var isAnimatingKeyboardAppearance = false
private var previousFrame: CGRect = .zero
private(set) var collectionView: UICollectionView?
let scrollViewDidRestPublisher = PassthroughSubject<Void, Never>()
let scrollViewTopVisiblePublisher = CurrentValueSubject<Bool, Never>(false)
let scrollViewBottomVisiblePublisher = CurrentValueSubject<Bool, Never>(false)
override init() {
topDetectionOffset = 0.0
bottomDetectionOffset = 0.0
}
init(collectionView: UICollectionView, topDetectionOffset: CGFloat, bottomDetectionOffset: CGFloat) {
self.collectionView = collectionView
self.topDetectionOffset = topDetectionOffset
self.bottomDetectionOffset = bottomDetectionOffset
super.init()
collectionView.clipsToBounds = true
collectionView.keyboardDismissMode = .onDrag
registerContentOffsetObserver()
registerBoundsObserver()
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow(notification:)), name: UIResponder.keyboardDidShowNotification, object: nil)
collectionView.panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture(_:)))
}
func saveCurrentOffset() {
guard let collectionView,
collectionView.numberOfSections > 0 else {
return
}
if computeIsBottomVisible() {
offsetDetails = .bottomOffset
} else if computeIsTopVisible(), let topIndexPath = collectionView.indexPathsForVisibleItems.first {
offsetDetails = .topOffset(previousVisibleIndexPath: topIndexPath,
previousItemCount: collectionView.numberOfItems(inSection: 0))
}
}
func restoreSavedOffset() {
defer {
offsetDetails = nil
}
guard let collectionView,
collectionView.numberOfSections > 0 else {
return
}
let currentItemCount = collectionView.numberOfItems(inSection: 0)
switch offsetDetails {
case .bottomOffset:
collectionView.scrollToItem(at: .init(item: max(0, currentItemCount - 1), section: 0), at: .bottom, animated: false)
case .topOffset(let indexPath, let previousItemCount):
let item = indexPath.item + max(0, currentItemCount - previousItemCount)
if item < currentItemCount {
collectionView.scrollToItem(at: .init(item: item, section: 0), at: .top, animated: false)
}
case .none:
break
}
}
var isTracking: Bool {
collectionView?.isTracking == true
}
var isDecelerating: Bool {
collectionView?.isDecelerating == true
}
func scrollToBottom(animated: Bool = false) {
guard let collectionView,
collectionView.numberOfSections > 0 else {
return
}
let currentItemCount = collectionView.numberOfItems(inSection: 0)
guard currentItemCount > 1 else {
return
}
collectionView.scrollToItem(at: .init(item: currentItemCount - 1, section: 0), at: .bottom, animated: animated)
}
// MARK: - Private
private func registerContentOffsetObserver() {
// Don't attempt stealing the UICollectionView delegate away from the List.
// Doing so results in undefined behavior e.g. context menus not working
contentOffsetObserverToken = collectionView?.observe(\.contentOffset, options: .new) { [weak self] _, _ in
self?.handleScrollViewScroll()
}
}
private func deregisterContentOffsetObserver() {
contentOffsetObserverToken?.invalidate()
}
private func registerBoundsObserver() {
boundsObserverToken = collectionView?.observe(\.frame, options: .new) { [weak self] collectionView, _ in
self?.previousFrame = collectionView.frame
self?.handleScrollViewScroll()
}
}
private func deregisterBoundsObserver() {
boundsObserverToken?.invalidate()
}
@objc private func keyboardWillShow(notification: NSNotification) {
isAnimatingKeyboardAppearance = true
}
@objc private func keyboardDidShow(notification: NSNotification) {
isAnimatingKeyboardAppearance = false
}
private func handleScrollViewScroll() {
guard let collectionView else {
return
}
let hasScrolledBecauseOfFrameChange = (previousFrame != collectionView.frame)
let shouldPinToBottom = scrollViewBottomVisiblePublisher.value && (isAnimatingKeyboardAppearance || hasScrolledBecauseOfFrameChange)
if shouldPinToBottom {
deregisterContentOffsetObserver()
scrollToBottom()
DispatchQueue.main.async {
self.registerContentOffsetObserver()
}
return
}
let isTopVisible = computeIsTopVisible()
if isTopVisible != scrollViewTopVisiblePublisher.value {
scrollViewTopVisiblePublisher.send(isTopVisible)
}
let isBottomVisible = computeIsBottomVisible()
if isBottomVisible != scrollViewBottomVisiblePublisher.value {
scrollViewBottomVisiblePublisher.send(isBottomVisible)
}
if !draggingInitiated, collectionView.isDragging {
draggingInitiated = true
} else if draggingInitiated, !collectionView.isDragging {
draggingInitiated = false
scrollViewDidRestPublisher.send(())
}
}
@objc private func handlePanGesture(_ sender: UIPanGestureRecognizer) {
guard let collectionView,
sender.state == .ended,
draggingInitiated,
!collectionView.isDecelerating else {
return
}
draggingInitiated = false
scrollViewDidRestPublisher.send(())
}
private func computeIsTopVisible() -> Bool {
guard let scrollView = collectionView else {
return false
}
return (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) <= topDetectionOffset
}
private func computeIsBottomVisible() -> Bool {
guard let scrollView = collectionView else {
return false
}
return (scrollView.contentOffset.y + bottomDetectionOffset) >= (scrollView.contentSize.height - scrollView.frame.size.height)
}
}
@@ -22,7 +22,7 @@ enum TimelineStyle: String, CaseIterable {
case bubbles
/// List row insets for a timeline
var listRowInsets: EdgeInsets {
var rowInsets: EdgeInsets {
switch self {
case .plain:
return EdgeInsets(top: 4, leading: 20, bottom: 4, trailing: 20)
@@ -25,9 +25,9 @@ struct EmoteRoomTimelineView: View {
HStack(alignment: .top) {
Image(systemName: "face.dashed").padding(.top, 1.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
FormattedBodyText(attributedComponents: attributedComponents)
} else {
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
FormattedBodyText(text: timelineItem.text)
}
}
}
@@ -18,8 +18,6 @@ import Foundation
import SwiftUI
struct FormattedBodyText: View {
#warning("this is a dirty fix for demo, should be refactored after new timeline api")
let isOutgoing: Bool
let attributedComponents: [AttributedStringBuilderComponent]
var body: some View {
@@ -51,8 +49,7 @@ struct FormattedBodyText: View {
}
extension FormattedBodyText {
init(isOutgoing: Bool, text: String) {
self.isOutgoing = isOutgoing
init(text: String) {
attributedComponents = [.init(attributedString: AttributedString(text), isBlockquote: false)]
}
}
@@ -94,11 +91,11 @@ struct FormattedBodyText_Previews: PreviewProvider {
let attributedString = attributedStringBuilder.fromHTML(htmlString)
if let components = attributedStringBuilder.blockquoteCoalescedComponentsFrom(attributedString) {
FormattedBodyText(isOutgoing: true, attributedComponents: components)
FormattedBodyText(attributedComponents: components)
.fixedSize()
}
}
FormattedBodyText(isOutgoing: true, text: "Some plain text that's not an attributed component.")
FormattedBodyText(text: "Some plain text that's not an attributed component.")
}
}
}
@@ -25,9 +25,9 @@ struct NoticeRoomTimelineView: View {
HStack(alignment: .top) {
Image(systemName: "exclamationmark.bubble").padding(.top, 2.0)
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
FormattedBodyText(attributedComponents: attributedComponents)
} else {
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
FormattedBodyText(text: timelineItem.text)
}
}
}
@@ -24,7 +24,7 @@ struct RedactedRoomTimelineView: View {
TimelineStyler(timelineItem: timelineItem) {
HStack {
Image(systemName: "trash")
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
FormattedBodyText(text: timelineItem.text)
}
}
.id(timelineItem.id)
@@ -23,9 +23,9 @@ struct TextRoomTimelineView: View {
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let attributedComponents = timelineItem.attributedComponents {
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, attributedComponents: attributedComponents)
FormattedBodyText(attributedComponents: attributedComponents)
} else {
FormattedBodyText(isOutgoing: timelineItem.isOutgoing, text: timelineItem.text)
FormattedBodyText(text: timelineItem.text)
}
}
.id(timelineItem.id)
@@ -15,134 +15,167 @@
//
import Combine
import Introspect
import SwiftUI
struct TimelineItemList: View {
@State private var collectionViewObserver = ListCollectionViewAdapter()
@State private var timelineItems: [RoomTimelineViewProvider] = []
@State private var hasPendingChanges = false
@ObservedObject private var settings = ElementSettings.shared
@State private var timelineItems: [RoomTimelineViewProvider] = []
@State private var viewFrame: CGRect = .zero
@State private var pinnedItem: PinnedItem?
@State private var visibleItemIdentifiers: Set<String> = []
@State private var topVisiblePublisher = CurrentValueSubject<Bool, Never>(false)
@EnvironmentObject var context: RoomScreenViewModel.Context
let bottomVisiblePublisher: PassthroughSubject<Bool, Never>
let bottomVisiblePublisher: CurrentValueSubject<Bool, Never>
let scrollToBottomPublisher: PassthroughSubject<Void, Never>
@State private var viewFrame: CGRect = .zero
var body: some View {
List {
ProgressView()
.frame(maxWidth: .infinity)
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
.animation(.elementDefault, value: context.viewState.isBackPaginating)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
// timelineItems won't be set for static Xcode Previews until they're run.
ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in
timelineItem
.contextMenu {
context.viewState.contextMenuBuilder?(timelineItem.id)
ScrollViewReader { proxy in
ReversedScrollView(.vertical) {
ProgressView()
.frame(maxWidth: .infinity)
.opacity(context.viewState.isBackPaginating ? 1.0 : 0.0)
.animation(.elementDefault, value: context.viewState.isBackPaginating)
LazyVStack(spacing: 0.0) {
ForEach(isRunningPreviews ? context.viewState.items : timelineItems) { item in
item
.contextMenu {
context.viewState.contextMenuBuilder?(item.id)
.id(item.id)
}
.opacity(opacityForItem(item))
.padding(settings.timelineStyle.rowInsets)
.onAppear {
context.send(viewAction: .itemAppeared(id: item.id))
visibleItemIdentifiers.insert(item.id)
if timelineItems.first == item {
topVisiblePublisher.send(true)
}
if timelineItems.last == item {
bottomVisiblePublisher.send(true)
}
}
.onDisappear {
context.send(viewAction: .itemDisappeared(id: item.id))
visibleItemIdentifiers.remove(item.id)
if timelineItems.first == item {
topVisiblePublisher.send(false)
}
if timelineItems.last == item {
bottomVisiblePublisher.send(false)
}
}
.environment(\.openURL, OpenURLAction { url in
context.send(viewAction: .linkClicked(url: url))
return .systemAction
})
}
.opacity(opacityForItem(timelineItem))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(settings.timelineStyle.listRowInsets)
.onAppear {
context.send(viewAction: .itemAppeared(id: timelineItem.id))
}
}
.onChange(of: pinnedItem) { item in
guard let item else {
return
}
if item.animated {
withAnimation(Animation.elementDefault) {
proxy.scrollTo(item.id, anchor: item.anchor)
}
.onDisappear {
context.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
.environment(\.openURL, OpenURLAction { url in
context.send(viewAction: .linkClicked(url: url))
return .systemAction
})
} else {
proxy.scrollTo(item.id, anchor: item.anchor)
}
pinnedItem = nil
}
}
.listStyle(.plain)
.scrollDismissesKeyboard(.immediately)
.background(ViewFrameReader(frame: $viewFrame))
.environment(\.timelineWidth, viewFrame.width)
.timelineStyle(settings.timelineStyle)
.environment(\.defaultMinListRowHeight, 0.0)
.introspectCollectionView { collectionView in
if collectionView == collectionViewObserver.collectionView { return }
collectionViewObserver = ListCollectionViewAdapter(collectionView: collectionView,
topDetectionOffset: collectionView.bounds.size.height / 3.0,
bottomDetectionOffset: 10.0)
collectionViewObserver.scrollToBottom()
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
}
.onAppear {
if timelineItems != context.viewState.items {
timelineItems = context.viewState.items
requestBackPagination()
}
// Allow SwiftUI to layout the views properly before checking if the top is visible
.onReceive(topVisiblePublisher.collect(.byTime(DispatchQueue.main, 0.5))) { values in
if values.last == true {
requestBackPagination()
}
}
.onReceive(scrollToBottomPublisher) {
collectionViewObserver.scrollToBottom(animated: true)
scrollToBottom(animated: true)
}
.onReceive(collectionViewObserver.scrollViewTopVisiblePublisher) { isTopVisible in
if !isTopVisible || context.viewState.isBackPaginating {
.onChange(of: context.viewState.items.count) { _ in
guard !context.viewState.items.isEmpty,
context.viewState.items.count != timelineItems.count else {
return
}
attemptBackPagination()
}
.onReceive(collectionViewObserver.scrollViewBottomVisiblePublisher) { isBottomVisible in
bottomVisiblePublisher.send(isBottomVisible)
}
.onChange(of: context.viewState.items) { _ in
// If the count hasn't changed then don't observe a pagination
guard context.viewState.items.count != timelineItems.count else {
// Pin to the bottom if empty
if timelineItems.isEmpty {
if let lastItem = context.viewState.items.last {
let pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: false)
timelineItems = context.viewState.items
self.pinnedItem = pinnedItem
}
return
}
// Pin to the new bottom if visible
if let currentLastItem = timelineItems.last,
visibleItemIdentifiers.contains(currentLastItem.id),
let newLastItem = context.viewState.items.last {
let pinnedItem = PinnedItem(id: newLastItem.id, anchor: .bottom, animated: false)
timelineItems = context.viewState.items
self.pinnedItem = pinnedItem
return
}
// Don't update the list while moving
if collectionViewObserver.isDecelerating || collectionViewObserver.isTracking {
hasPendingChanges = true
// Pin to the old topmost visible
if let currentFirstItem = timelineItems.first,
visibleItemIdentifiers.contains(currentFirstItem.id) {
let pinnedItem = PinnedItem(id: currentFirstItem.id, anchor: .top, animated: false)
timelineItems = context.viewState.items
self.pinnedItem = pinnedItem
return
}
collectionViewObserver.saveCurrentOffset()
// Otherwise just update the items
timelineItems = context.viewState.items
}
.onReceive(collectionViewObserver.scrollViewDidRestPublisher) {
if hasPendingChanges == false {
.background(GeometryReader { geo in
Color.clear.preference(key: ViewFramePreferenceKey.self, value: [geo.frame(in: .global)])
})
.onPreferenceChange(ViewFramePreferenceKey.self) { _ in
guard bottomVisiblePublisher.value == true else {
return
}
collectionViewObserver.saveCurrentOffset()
timelineItems = context.viewState.items
hasPendingChanges = false
}
.onChange(of: timelineItems.count) { _ in
collectionViewObserver.restoreSavedOffset()
// Check if there are enough items. Otherwise ask for more
attemptBackPagination()
// Pin the timeline to the bottom if was there on the frame change
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollToBottom(animated: true)
}
}
}
func scrollToBottom(animated: Bool = false) {
collectionViewObserver.scrollToBottom(animated: animated)
// MARK: - Private
private func scrollToBottom(animated: Bool = false) {
if let lastItem = timelineItems.last {
pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: animated)
}
}
private func attemptBackPagination() {
if context.viewState.isBackPaginating {
return
}
if collectionViewObserver.scrollViewTopVisiblePublisher.value == false {
return
}
private func requestBackPagination() {
context.send(viewAction: .loadPreviousPage)
}
@@ -154,7 +187,7 @@ struct TimelineItemList: View {
return selectedItemId == item.id ? 1.0 : 0.5
}
private var isPreview: Bool {
private var isRunningPreviews: Bool {
#if DEBUG
return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
#else
@@ -163,6 +196,20 @@ struct TimelineItemList: View {
}
}
private struct PinnedItem: Equatable {
let id: String
let anchor: UnitPoint
let animated: Bool
}
private struct ViewFramePreferenceKey: PreferenceKey {
static var defaultValue = [CGRect]() // Doesn't work with plain CGRects
static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
value += nextValue()
}
}
struct TimelineItemList_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
@@ -176,7 +223,7 @@ struct TimelineItemList_Previews: PreviewProvider {
mediaProvider: MockMediaProvider(),
roomName: nil)
TimelineItemList(bottomVisiblePublisher: PassthroughSubject(), scrollToBottomPublisher: PassthroughSubject())
TimelineItemList(bottomVisiblePublisher: CurrentValueSubject(false), scrollToBottomPublisher: PassthroughSubject())
.environmentObject(viewModel.context)
}
}
@@ -21,7 +21,7 @@ import SwiftUI
import Introspect
struct TimelineView: View {
@State private var bottomVisiblePublisher = PassthroughSubject<Bool, Never>()
@State private var bottomVisiblePublisher = CurrentValueSubject<Bool, Never>(true)
@State private var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
@State private var scollToBottomButtonVisible = false
@@ -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
}
-1
View File
@@ -105,7 +105,6 @@ targets:
- package: DTCoreText
- package: KeychainAccess
- package: Kingfisher
- package: Introspect
- package: PostHog
- package: SwiftyBeaver
- package: SwiftState