mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
* 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:
@@ -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,
|
||||
|
||||
+1
-1
@@ -19,5 +19,5 @@ import Foundation
|
||||
@MainActor
|
||||
protocol RoomTimelineItemFactoryProtocol {
|
||||
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy,
|
||||
inGroupState: TimelineItemInGroupState) async -> RoomTimelineItemProtocol
|
||||
inGroupState: TimelineItemInGroupState) -> RoomTimelineItemProtocol
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@ targets:
|
||||
- package: DTCoreText
|
||||
- package: KeychainAccess
|
||||
- package: Kingfisher
|
||||
- package: Introspect
|
||||
- package: PostHog
|
||||
- package: SwiftyBeaver
|
||||
- package: SwiftState
|
||||
|
||||
Reference in New Issue
Block a user