mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Refactor the NSDiffableDataSourceLayer (#1235)
* first part, using references * better animations * delete view provider and replaced wth two files * ordered dictionary usage * bubbled styler view previews * plain style is back * fix * read marker previews restored * updated tests * code improvements * better naming
This commit is contained in:
@@ -18,6 +18,8 @@ import Combine
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
import OrderedCollections
|
||||
|
||||
enum RoomScreenViewModelAction {
|
||||
case displayRoomDetails
|
||||
case displayEmojiPicker(itemID: String)
|
||||
@@ -84,7 +86,7 @@ struct RoomScreenViewState: BindableState {
|
||||
var roomId: String
|
||||
var roomTitle = ""
|
||||
var roomAvatarURL: URL?
|
||||
var items: [RoomTimelineViewProvider] = []
|
||||
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>()
|
||||
var members: [String: RoomMemberState] = [:]
|
||||
var canBackPaginate = true
|
||||
var isBackPaginating = false
|
||||
@@ -102,6 +104,14 @@ struct RoomScreenViewState: BindableState {
|
||||
var sendButtonDisabled: Bool {
|
||||
bindings.composerText.count == 0
|
||||
}
|
||||
|
||||
var itemIDs: [String] {
|
||||
itemsDictionary.keys.elements
|
||||
}
|
||||
|
||||
var itemViewModels: [RoomTimelineItemViewModel] {
|
||||
itemsDictionary.values.elements
|
||||
}
|
||||
|
||||
let scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import Algorithms
|
||||
import Combine
|
||||
import OrderedCollections
|
||||
import SwiftUI
|
||||
|
||||
typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, RoomScreenViewAction>
|
||||
@@ -116,7 +117,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
case .tappedOnUser(userID: let userID):
|
||||
Task { await handleTappedUser(userID: userID) }
|
||||
case .displayEmojiPicker(let itemID):
|
||||
guard let item = state.items.first(where: { $0.id == itemID }), item.isReactable else { return }
|
||||
guard let item = state.itemsDictionary[itemID], item.isReactable else { return }
|
||||
callback?(.displayEmojiPicker(itemID: itemID))
|
||||
case .reactionSummary(let itemId, let key):
|
||||
showReactionSummary(for: itemId, selectedKey: key)
|
||||
@@ -232,8 +233,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
|
||||
private func buildTimelineViews() {
|
||||
var timelineViews = [RoomTimelineViewProvider]()
|
||||
|
||||
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>()
|
||||
|
||||
let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in
|
||||
canGroupItem(timelineItem: current, with: next)
|
||||
}
|
||||
@@ -246,24 +247,38 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
if itemGroup.count == 1 {
|
||||
if let firstItem = itemGroup.first {
|
||||
timelineViews.append(RoomTimelineViewProvider(timelineItem: firstItem, groupStyle: .single))
|
||||
timelineItemsDictionary.updateValue(updateViewModel(item: firstItem, groupStyle: .single),
|
||||
forKey: firstItem.id)
|
||||
}
|
||||
} else {
|
||||
for (index, item) in itemGroup.enumerated() {
|
||||
if index == 0 {
|
||||
timelineViews.append(RoomTimelineViewProvider(timelineItem: item, groupStyle: .first))
|
||||
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .first),
|
||||
forKey: item.id)
|
||||
} else if index == itemGroup.count - 1 {
|
||||
timelineViews.append(RoomTimelineViewProvider(timelineItem: item, groupStyle: .last))
|
||||
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .last),
|
||||
forKey: item.id)
|
||||
} else {
|
||||
timelineViews.append(RoomTimelineViewProvider(timelineItem: item, groupStyle: .middle))
|
||||
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .middle),
|
||||
forKey: item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.items = timelineViews
|
||||
state.itemsDictionary = timelineItemsDictionary
|
||||
}
|
||||
|
||||
|
||||
private func updateViewModel(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewModel {
|
||||
if let timelineItemViewModel = state.itemsDictionary[item.id] {
|
||||
timelineItemViewModel.groupStyle = groupStyle
|
||||
timelineItemViewModel.type = .init(item: item)
|
||||
return timelineItemViewModel
|
||||
} else {
|
||||
return RoomTimelineItemViewModel(item: item, groupStyle: groupStyle)
|
||||
}
|
||||
}
|
||||
|
||||
private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool {
|
||||
if timelineItem is CollapsibleTimelineItem || otherTimelineItem is CollapsibleTimelineItem {
|
||||
return false
|
||||
|
||||
+24
-24
@@ -307,7 +307,7 @@ private extension View {
|
||||
|
||||
struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
|
||||
static var previews: some View {
|
||||
mockTimeline
|
||||
.previewDisplayName("Mock Timeline")
|
||||
@@ -320,40 +320,40 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
||||
replies
|
||||
.previewDisplayName("Replies")
|
||||
}
|
||||
|
||||
|
||||
static var mockTimeline: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(viewModel.state.items) { item in
|
||||
item.padding(TimelineStyle.bubbles.rowInsets) // Insets added in the table view cells
|
||||
ForEach(viewModel.state.itemViewModels) { itemViewModel in
|
||||
RoomTimelineItemView(viewModel: itemViewModel)
|
||||
.padding(TimelineStyle.bubbles.rowInsets)
|
||||
// Insets added in the table view cells
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.timelineStyle, .bubbles)
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
|
||||
static var replies: some View {
|
||||
VStack {
|
||||
RoomTimelineViewProvider.text(TextRoomTimelineItem(id: "",
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
sender: .init(id: "whoever"),
|
||||
content: .init(body: "A long message that should be on multiple lines."),
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
contentType: .text(.init(body: "Short")))),
|
||||
.single)
|
||||
|
||||
RoomTimelineViewProvider.text(TextRoomTimelineItem(id: "",
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
sender: .init(id: "whoever"),
|
||||
content: .init(body: "Short message"),
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
contentType: .text(.init(body: "A long message that should be on more than 2 lines and so will be clipped by the layout.")))),
|
||||
.single)
|
||||
RoomTimelineItemView(viewModel: .init(item: TextRoomTimelineItem(id: "",
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
sender: .init(id: "whoever"),
|
||||
content: .init(body: "A long message that should be on multiple lines."),
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
contentType: .text(.init(body: "Short")))), groupStyle: .single))
|
||||
|
||||
RoomTimelineItemView(viewModel: .init(item: TextRoomTimelineItem(id: "",
|
||||
timestamp: "10:42",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
sender: .init(id: "whoever"),
|
||||
content: .init(body: "Short message"),
|
||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
contentType: .text(.init(body: "A long message that should be on more than 2 lines and so will be clipped by the layout.")))), groupStyle: .single))
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
@@ -135,12 +135,12 @@ struct TimelineItemPlainStylerView<Content: View>: View {
|
||||
|
||||
struct TimelineItemPlainStylerView_Previews: PreviewProvider {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
|
||||
static var previews: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
|
||||
let item = MockRoomTimelineController().timelineItems[index]
|
||||
RoomTimelineViewProvider(timelineItem: item, groupStyle: .single)
|
||||
RoomTimelineItemView(viewModel: .init(item: item, groupStyle: .single))
|
||||
.padding(TimelineStyle.plain.rowInsets) // Insets added in the table view cells
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ struct TimelineItemStyler_Previews: PreviewProvider {
|
||||
}()
|
||||
|
||||
static let sendingLast: TextRoomTimelineItem = {
|
||||
let id = viewModel.state.items.last?.id ?? UUID().uuidString
|
||||
let id = viewModel.state.itemIDs.last ?? UUID().uuidString
|
||||
var result = TextRoomTimelineItem(id: id, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
|
||||
result.properties.deliveryStatus = .sending
|
||||
return result
|
||||
@@ -66,7 +66,7 @@ struct TimelineItemStyler_Previews: PreviewProvider {
|
||||
}()
|
||||
|
||||
static let sentLast: TextRoomTimelineItem = {
|
||||
let id = viewModel.state.items.last?.id ?? UUID().uuidString
|
||||
let id = viewModel.state.itemIDs.last ?? UUID().uuidString
|
||||
let result = TextRoomTimelineItem(id: id, timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
|
||||
return result
|
||||
}()
|
||||
|
||||
@@ -23,14 +23,10 @@ struct TimelineItemStatusView: View {
|
||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||
|
||||
private var isLastOutgoingMessage: Bool {
|
||||
context.viewState.items.last?.id == timelineItem.id &&
|
||||
context.viewState.itemIDs.last == timelineItem.id &&
|
||||
timelineItem.isOutgoing
|
||||
}
|
||||
|
||||
private var isLast: Bool {
|
||||
context.viewState.items.last?.id == timelineItem.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !timelineItem.properties.orderedReadReceipts.isEmpty, readReceiptsEnabled {
|
||||
readReceipts
|
||||
|
||||
@@ -18,20 +18,20 @@ import SwiftUI
|
||||
|
||||
struct CollapsibleRoomTimelineView: View {
|
||||
private let timelineItem: CollapsibleTimelineItem
|
||||
private let timelineViews: [RoomTimelineViewProvider]
|
||||
private let timelineViewModels: [RoomTimelineItemViewModel]
|
||||
|
||||
@State private var isExpanded = false
|
||||
|
||||
init(timelineItem: CollapsibleTimelineItem) {
|
||||
self.timelineItem = timelineItem
|
||||
timelineViews = timelineItem.items.map { RoomTimelineViewProvider(timelineItem: $0, groupStyle: .single) }
|
||||
timelineViewModels = timelineItem.items.map { .init(item: $0, groupStyle: .single) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup(L10n.roomTimelineStateChanges(timelineItem.items.count), isExpanded: $isExpanded) {
|
||||
Group {
|
||||
ForEach(timelineViews) { timelineView in
|
||||
timelineView.body
|
||||
ForEach(timelineViewModels) { viewModel in
|
||||
RoomTimelineItemView(viewModel: viewModel)
|
||||
}
|
||||
}.transition(.opacity.animation(.elementDefault))
|
||||
}
|
||||
|
||||
+17
-17
@@ -37,27 +37,27 @@ struct ReadMarkerRoomTimelineView: View {
|
||||
|
||||
struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
|
||||
static let item = ReadMarkerRoomTimelineItem()
|
||||
static var previews: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
RoomTimelineViewProvider.separator(.init(id: "Separator", text: "Today"), .single)
|
||||
RoomTimelineViewProvider.text(.init(id: "",
|
||||
timestamp: "",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
sender: .init(id: "1", displayName: "Bob"),
|
||||
content: .init(body: "This is another message")), .single)
|
||||
|
||||
RoomTimelineItemView(viewModel: .init(type: .separator(.init(id: "Separator", text: "Today")), groupStyle: .single))
|
||||
RoomTimelineItemView(viewModel: .init(type: .text(.init(id: "",
|
||||
timestamp: "",
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
sender: .init(id: "1", displayName: "Bob"),
|
||||
content: .init(body: "This is another message"))), groupStyle: .single))
|
||||
|
||||
ReadMarkerRoomTimelineView(timelineItem: item)
|
||||
|
||||
RoomTimelineViewProvider.separator(.init(id: "Separator", text: "Today"), .single)
|
||||
RoomTimelineViewProvider.text(.init(id: "",
|
||||
timestamp: "",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "", displayName: "Alice"),
|
||||
content: .init(body: "This is a message")), .single)
|
||||
|
||||
RoomTimelineItemView(viewModel: .init(type: .separator(.init(id: "Separator", text: "Today")), groupStyle: .single))
|
||||
RoomTimelineItemView(viewModel: .init(type: .text(.init(id: "",
|
||||
timestamp: "",
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "", displayName: "Alice"),
|
||||
content: .init(body: "This is a message"))), groupStyle: .single))
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.environmentObject(viewModel.context)
|
||||
|
||||
@@ -17,12 +17,14 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
import OrderedCollections
|
||||
|
||||
/// A table view cell that displays a timeline item in a room. The cell is intended
|
||||
/// to be configured to display a SwiftUI view and not use any UIKit.
|
||||
class TimelineItemCell: UITableViewCell {
|
||||
static let reuseIdentifier = "TimelineItemCell"
|
||||
|
||||
var item: RoomTimelineViewProvider?
|
||||
var item: RoomTimelineItemViewModel?
|
||||
|
||||
override func prepareForReuse() {
|
||||
item = nil
|
||||
@@ -38,7 +40,7 @@ class TimelineTableViewController: UIViewController {
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
|
||||
var timelineStyle: TimelineStyle
|
||||
var timelineItems: [RoomTimelineViewProvider] = [] {
|
||||
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>() {
|
||||
didSet {
|
||||
guard !scrollAdapter.isScrolling.value else {
|
||||
// Delay updating until scrolling has stopped as programatic
|
||||
@@ -46,15 +48,15 @@ class TimelineTableViewController: UIViewController {
|
||||
hasPendingUpdates = true
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
applySnapshot()
|
||||
|
||||
if timelineItems.isEmpty {
|
||||
|
||||
if timelineItemsDictionary.isEmpty {
|
||||
paginateBackwardsPublisher.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// The mode of the message composer. This is used to render selected
|
||||
/// items in the timeline when replying, editing etc.
|
||||
var composerMode: RoomScreenComposerMode = .default
|
||||
@@ -73,9 +75,13 @@ class TimelineTableViewController: UIViewController {
|
||||
var contextMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemMenuActions?)?
|
||||
|
||||
@Binding private var scrollToBottomButtonVisible: Bool
|
||||
|
||||
private var timelineItemsIDs: [String] {
|
||||
timelineItemsDictionary.keys.elements
|
||||
}
|
||||
|
||||
/// The table's diffable data source.
|
||||
private var dataSource: UITableViewDiffableDataSource<TimelineSection, RoomTimelineViewProvider>?
|
||||
private var dataSource: UITableViewDiffableDataSource<TimelineSection, String>?
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
/// The scroll view adapter used to detect whether scrolling is in progress.
|
||||
@@ -183,24 +189,28 @@ class TimelineTableViewController: UIViewController {
|
||||
|
||||
/// Configures a diffable data source for the timeline's table view.
|
||||
private func configureDataSource() {
|
||||
dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, timelineItem in
|
||||
dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, id in
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
|
||||
guard let self, let cell = cell as? TimelineItemCell else { return cell }
|
||||
|
||||
// A local reference to avoid capturing self in the cell configuration.
|
||||
let coordinator = self.coordinator
|
||||
|
||||
cell.item = timelineItem
|
||||
let viewModel = timelineItemsDictionary[id]
|
||||
cell.item = viewModel
|
||||
guard let viewModel else {
|
||||
return cell
|
||||
}
|
||||
cell.contentConfiguration = UIHostingConfiguration {
|
||||
timelineItem
|
||||
.id(timelineItem.id)
|
||||
RoomTimelineItemView(viewModel: viewModel)
|
||||
.id(id)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
|
||||
.onAppear {
|
||||
coordinator.send(viewAction: .itemAppeared(id: timelineItem.id))
|
||||
coordinator.send(viewAction: .itemAppeared(id: id))
|
||||
}
|
||||
.onDisappear {
|
||||
coordinator.send(viewAction: .itemDisappeared(id: timelineItem.id))
|
||||
coordinator.send(viewAction: .itemDisappeared(id: id))
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
coordinator.send(viewAction: .linkClicked(url: url))
|
||||
@@ -213,6 +223,8 @@ class TimelineTableViewController: UIViewController {
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
dataSource?.defaultRowAnimation = .fade
|
||||
|
||||
tableView.delegate = self
|
||||
}
|
||||
@@ -226,13 +238,13 @@ class TimelineTableViewController: UIViewController {
|
||||
let previousLayout = layout()
|
||||
self.previousLayout = previousLayout
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, RoomTimelineViewProvider>()
|
||||
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, String>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(timelineItems)
|
||||
snapshot.appendItems(timelineItemsIDs)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
|
||||
updateTopPadding()
|
||||
|
||||
|
||||
if previousLayout.isBottomVisible {
|
||||
scrollToBottom(animated: false)
|
||||
} else if let pinnedItem = previousLayout.pinnedItem {
|
||||
@@ -254,12 +266,12 @@ class TimelineTableViewController: UIViewController {
|
||||
}
|
||||
|
||||
guard let bottomItemIndexPath = tableView.indexPathsForVisibleRows?.last,
|
||||
let bottomItem = dataSource.itemIdentifier(for: bottomItemIndexPath)
|
||||
let bottomID = dataSource.itemIdentifier(for: bottomItemIndexPath)
|
||||
else { return layout }
|
||||
|
||||
let bottomCellFrame = tableView.cellFrame(for: bottomItem)
|
||||
layout.pinnedItem = PinnedItem(id: bottomItem.id, position: .bottom, frame: bottomCellFrame)
|
||||
layout.isBottomVisible = bottomItem == snapshot.itemIdentifiers.last
|
||||
let bottomCellFrame = tableView.cellFrame(for: bottomID)
|
||||
layout.pinnedItem = PinnedItem(id: bottomID, position: .bottom, frame: bottomCellFrame)
|
||||
layout.isBottomVisible = bottomID == snapshot.itemIdentifiers.last
|
||||
|
||||
return layout
|
||||
}
|
||||
@@ -288,16 +300,16 @@ class TimelineTableViewController: UIViewController {
|
||||
|
||||
/// Scrolls to the bottom of the timeline.
|
||||
private func scrollToBottom(animated: Bool) {
|
||||
guard let lastItem = timelineItems.last,
|
||||
let lastIndexPath = dataSource?.indexPath(for: lastItem)
|
||||
guard let lastItemID = timelineItemsIDs.last,
|
||||
let lastIndexPath = dataSource?.indexPath(for: lastItemID)
|
||||
else { return }
|
||||
|
||||
tableView.scrollToRow(at: lastIndexPath, at: .bottom, animated: animated)
|
||||
}
|
||||
|
||||
/// Restores the position of the timeline using the supplied item and snapshot.
|
||||
private func restoreScrollPosition(using pinnedItem: PinnedItem, and snapshot: NSDiffableDataSourceSnapshot<TimelineSection, RoomTimelineViewProvider>) {
|
||||
guard let item = snapshot.itemIdentifiers.first(where: { $0.id == pinnedItem.id }),
|
||||
private func restoreScrollPosition(using pinnedItem: PinnedItem, and snapshot: NSDiffableDataSourceSnapshot<TimelineSection, String>) {
|
||||
guard let item = snapshot.itemIdentifiers.first(where: { $0 == pinnedItem.id }),
|
||||
let indexPath = dataSource?.indexPath(for: item)
|
||||
else { return }
|
||||
|
||||
@@ -398,8 +410,8 @@ extension TimelineTableViewController {
|
||||
|
||||
private extension UITableView {
|
||||
/// Returns the frame of the cell for a particular timeline item.
|
||||
func cellFrame(for item: RoomTimelineViewProvider) -> CGRect? {
|
||||
guard let timelineCell = visibleCells.last(where: { ($0 as? TimelineItemCell)?.item == item }) else {
|
||||
func cellFrame(for id: String) -> CGRect? {
|
||||
guard let timelineCell = visibleCells.last(where: { ($0 as? TimelineItemCell)?.item?.id == id }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ struct TimelineView: UIViewControllerRepresentable {
|
||||
init(viewModelContext: RoomScreenViewModel.Context) {
|
||||
context = viewModelContext
|
||||
|
||||
if viewModelContext.viewState.items.isEmpty {
|
||||
if viewModelContext.viewState.itemViewModels.isEmpty {
|
||||
viewModelContext.send(viewAction: .paginateBackwards)
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,8 @@ struct TimelineView: UIViewControllerRepresentable {
|
||||
if tableViewController.timelineStyle != timelineStyle {
|
||||
tableViewController.timelineStyle = timelineStyle
|
||||
}
|
||||
if tableViewController.timelineItems != context.viewState.items {
|
||||
tableViewController.timelineItems = context.viewState.items
|
||||
if tableViewController.timelineItemsDictionary != context.viewState.itemsDictionary {
|
||||
tableViewController.timelineItemsDictionary = context.viewState.itemsDictionary
|
||||
}
|
||||
if tableViewController.canBackPaginate != context.viewState.canBackPaginate {
|
||||
tableViewController.canBackPaginate = context.viewState.canBackPaginate
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
struct RoomTimelineItemView: View {
|
||||
@ObservedObject var viewModel: RoomTimelineItemViewModel
|
||||
|
||||
var body: some View {
|
||||
timelineView
|
||||
.environment(\.timelineGroupStyle, viewModel.groupStyle)
|
||||
}
|
||||
|
||||
@ViewBuilder private var timelineView: some View {
|
||||
switch viewModel.type {
|
||||
case .text(let item):
|
||||
TextRoomTimelineView(timelineItem: item)
|
||||
case .separator(let item):
|
||||
SeparatorRoomTimelineView(timelineItem: item)
|
||||
case .image(let item):
|
||||
ImageRoomTimelineView(timelineItem: item)
|
||||
case .video(let item):
|
||||
VideoRoomTimelineView(timelineItem: item)
|
||||
case .audio(let item):
|
||||
AudioRoomTimelineView(timelineItem: item)
|
||||
case .file(let item):
|
||||
FileRoomTimelineView(timelineItem: item)
|
||||
case .emote(let item):
|
||||
EmoteRoomTimelineView(timelineItem: item)
|
||||
case .notice(let item):
|
||||
NoticeRoomTimelineView(timelineItem: item)
|
||||
case .redacted(let item):
|
||||
RedactedRoomTimelineView(timelineItem: item)
|
||||
case .encrypted(let item):
|
||||
EncryptedRoomTimelineView(timelineItem: item)
|
||||
case .readMarker(let item):
|
||||
ReadMarkerRoomTimelineView(timelineItem: item)
|
||||
case .paginationIndicator(let item):
|
||||
PaginationIndicatorRoomTimelineView(timelineItem: item)
|
||||
case .sticker(let item):
|
||||
StickerRoomTimelineView(timelineItem: item)
|
||||
case .unsupported(let item):
|
||||
UnsupportedRoomTimelineView(timelineItem: item)
|
||||
case .timelineStart(let item):
|
||||
TimelineStartRoomTimelineView(timelineItem: item)
|
||||
case .state(let item):
|
||||
StateRoomTimelineView(timelineItem: item)
|
||||
case .group(let item):
|
||||
CollapsibleRoomTimelineView(timelineItem: item)
|
||||
case .location(let item):
|
||||
LocationRoomTimelineView(timelineItem: item)
|
||||
}
|
||||
}
|
||||
|
||||
var timelineGroupStyle: TimelineGroupStyle {
|
||||
viewModel.groupStyle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//
|
||||
// Copyright 2023 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 Foundation
|
||||
|
||||
final class RoomTimelineItemViewModel: Identifiable, Equatable, ObservableObject {
|
||||
static func == (lhs: RoomTimelineItemViewModel, rhs: RoomTimelineItemViewModel) -> Bool {
|
||||
lhs.type == rhs.type && lhs.groupStyle == rhs.groupStyle
|
||||
}
|
||||
|
||||
@Published var type: RoomTimelineItemType
|
||||
@Published var groupStyle: TimelineGroupStyle
|
||||
|
||||
var id: String {
|
||||
type.id
|
||||
}
|
||||
|
||||
convenience init(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) {
|
||||
self.init(type: .init(item: item), groupStyle: groupStyle)
|
||||
}
|
||||
|
||||
init(type: RoomTimelineItemType, groupStyle: TimelineGroupStyle) {
|
||||
self.type = type
|
||||
self.groupStyle = groupStyle
|
||||
}
|
||||
|
||||
var isReactable: Bool {
|
||||
type.isReactable
|
||||
}
|
||||
}
|
||||
|
||||
enum RoomTimelineItemType: Equatable {
|
||||
case text(TextRoomTimelineItem)
|
||||
case separator(SeparatorRoomTimelineItem)
|
||||
case image(ImageRoomTimelineItem)
|
||||
case video(VideoRoomTimelineItem)
|
||||
case audio(AudioRoomTimelineItem)
|
||||
case file(FileRoomTimelineItem)
|
||||
case emote(EmoteRoomTimelineItem)
|
||||
case notice(NoticeRoomTimelineItem)
|
||||
case redacted(RedactedRoomTimelineItem)
|
||||
case encrypted(EncryptedRoomTimelineItem)
|
||||
case readMarker(ReadMarkerRoomTimelineItem)
|
||||
case paginationIndicator(PaginationIndicatorRoomTimelineItem)
|
||||
case sticker(StickerRoomTimelineItem)
|
||||
case unsupported(UnsupportedRoomTimelineItem)
|
||||
case timelineStart(TimelineStartRoomTimelineItem)
|
||||
case state(StateRoomTimelineItem)
|
||||
case group(CollapsibleTimelineItem)
|
||||
case location(LocationRoomTimelineItem)
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
init(item: RoomTimelineItemProtocol) {
|
||||
switch item {
|
||||
case let item as TextRoomTimelineItem:
|
||||
self = .text(item)
|
||||
case let item as ImageRoomTimelineItem:
|
||||
self = .image(item)
|
||||
case let item as VideoRoomTimelineItem:
|
||||
self = .video(item)
|
||||
case let item as AudioRoomTimelineItem:
|
||||
self = .audio(item)
|
||||
case let item as FileRoomTimelineItem:
|
||||
self = .file(item)
|
||||
case let item as SeparatorRoomTimelineItem:
|
||||
self = .separator(item)
|
||||
case let item as NoticeRoomTimelineItem:
|
||||
self = .notice(item)
|
||||
case let item as EmoteRoomTimelineItem:
|
||||
self = .emote(item)
|
||||
case let item as RedactedRoomTimelineItem:
|
||||
self = .redacted(item)
|
||||
case let item as EncryptedRoomTimelineItem:
|
||||
self = .encrypted(item)
|
||||
case let item as ReadMarkerRoomTimelineItem:
|
||||
self = .readMarker(item)
|
||||
case let item as PaginationIndicatorRoomTimelineItem:
|
||||
self = .paginationIndicator(item)
|
||||
case let item as StickerRoomTimelineItem:
|
||||
self = .sticker(item)
|
||||
case let item as UnsupportedRoomTimelineItem:
|
||||
self = .unsupported(item)
|
||||
case let item as TimelineStartRoomTimelineItem:
|
||||
self = .timelineStart(item)
|
||||
case let item as StateRoomTimelineItem:
|
||||
self = .state(item)
|
||||
case let item as CollapsibleTimelineItem:
|
||||
self = .group(item)
|
||||
case let item as LocationRoomTimelineItem:
|
||||
self = .location(item)
|
||||
default:
|
||||
fatalError("Unknown timeline item")
|
||||
}
|
||||
}
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .text(let item as RoomTimelineItemProtocol),
|
||||
.separator(let item as RoomTimelineItemProtocol),
|
||||
.image(let item as RoomTimelineItemProtocol),
|
||||
.video(let item as RoomTimelineItemProtocol),
|
||||
.audio(let item as RoomTimelineItemProtocol),
|
||||
.file(let item as RoomTimelineItemProtocol),
|
||||
.emote(let item as RoomTimelineItemProtocol),
|
||||
.notice(let item as RoomTimelineItemProtocol),
|
||||
.redacted(let item as RoomTimelineItemProtocol),
|
||||
.encrypted(let item as RoomTimelineItemProtocol),
|
||||
.readMarker(let item as RoomTimelineItemProtocol),
|
||||
.paginationIndicator(let item as RoomTimelineItemProtocol),
|
||||
.sticker(let item as RoomTimelineItemProtocol),
|
||||
.unsupported(let item as RoomTimelineItemProtocol),
|
||||
.timelineStart(let item as RoomTimelineItemProtocol),
|
||||
.state(let item as RoomTimelineItemProtocol),
|
||||
.group(let item as RoomTimelineItemProtocol),
|
||||
.location(let item as RoomTimelineItemProtocol):
|
||||
return item.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not it is possible to send a reaction to this timeline item.
|
||||
var isReactable: Bool {
|
||||
switch self {
|
||||
case .text, .image, .video, .audio, .file, .emote, .notice, .sticker, .location:
|
||||
return true
|
||||
case .redacted, .encrypted, .unsupported, .state: // Event based items that aren't reactable
|
||||
return false
|
||||
case .timelineStart, .separator, .readMarker, .paginationIndicator: // Virtual items are never reactable
|
||||
return false
|
||||
case .group:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,193 +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 Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum RoomTimelineViewProvider: Identifiable, Hashable {
|
||||
case text(TextRoomTimelineItem, TimelineGroupStyle)
|
||||
case separator(SeparatorRoomTimelineItem, TimelineGroupStyle)
|
||||
case image(ImageRoomTimelineItem, TimelineGroupStyle)
|
||||
case video(VideoRoomTimelineItem, TimelineGroupStyle)
|
||||
case audio(AudioRoomTimelineItem, TimelineGroupStyle)
|
||||
case file(FileRoomTimelineItem, TimelineGroupStyle)
|
||||
case emote(EmoteRoomTimelineItem, TimelineGroupStyle)
|
||||
case notice(NoticeRoomTimelineItem, TimelineGroupStyle)
|
||||
case redacted(RedactedRoomTimelineItem, TimelineGroupStyle)
|
||||
case encrypted(EncryptedRoomTimelineItem, TimelineGroupStyle)
|
||||
case readMarker(ReadMarkerRoomTimelineItem, TimelineGroupStyle)
|
||||
case paginationIndicator(PaginationIndicatorRoomTimelineItem, TimelineGroupStyle)
|
||||
case sticker(StickerRoomTimelineItem, TimelineGroupStyle)
|
||||
case unsupported(UnsupportedRoomTimelineItem, TimelineGroupStyle)
|
||||
case timelineStart(TimelineStartRoomTimelineItem, TimelineGroupStyle)
|
||||
case state(StateRoomTimelineItem, TimelineGroupStyle)
|
||||
case group(CollapsibleTimelineItem, TimelineGroupStyle)
|
||||
case location(LocationRoomTimelineItem, TimelineGroupStyle)
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
init(timelineItem: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) {
|
||||
switch timelineItem {
|
||||
case let item as TextRoomTimelineItem:
|
||||
self = .text(item, groupStyle)
|
||||
case let item as ImageRoomTimelineItem:
|
||||
self = .image(item, groupStyle)
|
||||
case let item as VideoRoomTimelineItem:
|
||||
self = .video(item, groupStyle)
|
||||
case let item as AudioRoomTimelineItem:
|
||||
self = .audio(item, groupStyle)
|
||||
case let item as FileRoomTimelineItem:
|
||||
self = .file(item, groupStyle)
|
||||
case let item as SeparatorRoomTimelineItem:
|
||||
self = .separator(item, groupStyle)
|
||||
case let item as NoticeRoomTimelineItem:
|
||||
self = .notice(item, groupStyle)
|
||||
case let item as EmoteRoomTimelineItem:
|
||||
self = .emote(item, groupStyle)
|
||||
case let item as RedactedRoomTimelineItem:
|
||||
self = .redacted(item, groupStyle)
|
||||
case let item as EncryptedRoomTimelineItem:
|
||||
self = .encrypted(item, groupStyle)
|
||||
case let item as ReadMarkerRoomTimelineItem:
|
||||
self = .readMarker(item, groupStyle)
|
||||
case let item as PaginationIndicatorRoomTimelineItem:
|
||||
self = .paginationIndicator(item, groupStyle)
|
||||
case let item as StickerRoomTimelineItem:
|
||||
self = .sticker(item, groupStyle)
|
||||
case let item as UnsupportedRoomTimelineItem:
|
||||
self = .unsupported(item, groupStyle)
|
||||
case let item as TimelineStartRoomTimelineItem:
|
||||
self = .timelineStart(item, groupStyle)
|
||||
case let item as StateRoomTimelineItem:
|
||||
self = .state(item, groupStyle)
|
||||
case let item as CollapsibleTimelineItem:
|
||||
self = .group(item, groupStyle)
|
||||
case let item as LocationRoomTimelineItem:
|
||||
self = .location(item, groupStyle)
|
||||
default:
|
||||
fatalError("Unknown timeline item")
|
||||
}
|
||||
}
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .text(let item as RoomTimelineItemProtocol, _),
|
||||
.separator(let item as RoomTimelineItemProtocol, _),
|
||||
.image(let item as RoomTimelineItemProtocol, _),
|
||||
.video(let item as RoomTimelineItemProtocol, _),
|
||||
.audio(let item as RoomTimelineItemProtocol, _),
|
||||
.file(let item as RoomTimelineItemProtocol, _),
|
||||
.emote(let item as RoomTimelineItemProtocol, _),
|
||||
.notice(let item as RoomTimelineItemProtocol, _),
|
||||
.redacted(let item as RoomTimelineItemProtocol, _),
|
||||
.encrypted(let item as RoomTimelineItemProtocol, _),
|
||||
.readMarker(let item as RoomTimelineItemProtocol, _),
|
||||
.paginationIndicator(let item as RoomTimelineItemProtocol, _),
|
||||
.sticker(let item as RoomTimelineItemProtocol, _),
|
||||
.unsupported(let item as RoomTimelineItemProtocol, _),
|
||||
.timelineStart(let item as RoomTimelineItemProtocol, _),
|
||||
.state(let item as RoomTimelineItemProtocol, _),
|
||||
.group(let item as RoomTimelineItemProtocol, _),
|
||||
.location(let item as RoomTimelineItemProtocol, _):
|
||||
return item.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not it is possible to send a reaction to this timeline item.
|
||||
var isReactable: Bool {
|
||||
switch self {
|
||||
case .text, .image, .video, .audio, .file, .emote, .notice, .sticker, .location:
|
||||
return true
|
||||
case .redacted, .encrypted, .unsupported, .state: // Event based items that aren't reactable
|
||||
return false
|
||||
case .timelineStart, .separator, .readMarker, .paginationIndicator: // Virtual items are never reactable
|
||||
return false
|
||||
case .group:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RoomTimelineViewProvider: View {
|
||||
var body: some View {
|
||||
timelineView
|
||||
.environment(\.timelineGroupStyle, timelineGroupStyle)
|
||||
}
|
||||
|
||||
@ViewBuilder private var timelineView: some View {
|
||||
switch self {
|
||||
case .text(let item, _):
|
||||
TextRoomTimelineView(timelineItem: item)
|
||||
case .separator(let item, _):
|
||||
SeparatorRoomTimelineView(timelineItem: item)
|
||||
case .image(let item, _):
|
||||
ImageRoomTimelineView(timelineItem: item)
|
||||
case .video(let item, _):
|
||||
VideoRoomTimelineView(timelineItem: item)
|
||||
case .audio(let item, _):
|
||||
AudioRoomTimelineView(timelineItem: item)
|
||||
case .file(let item, _):
|
||||
FileRoomTimelineView(timelineItem: item)
|
||||
case .emote(let item, _):
|
||||
EmoteRoomTimelineView(timelineItem: item)
|
||||
case .notice(let item, _):
|
||||
NoticeRoomTimelineView(timelineItem: item)
|
||||
case .redacted(let item, _):
|
||||
RedactedRoomTimelineView(timelineItem: item)
|
||||
case .encrypted(let item, _):
|
||||
EncryptedRoomTimelineView(timelineItem: item)
|
||||
case .readMarker(let item, _):
|
||||
ReadMarkerRoomTimelineView(timelineItem: item)
|
||||
case .paginationIndicator(let item, _):
|
||||
PaginationIndicatorRoomTimelineView(timelineItem: item)
|
||||
case .sticker(let item, _):
|
||||
StickerRoomTimelineView(timelineItem: item)
|
||||
case .unsupported(let item, _):
|
||||
UnsupportedRoomTimelineView(timelineItem: item)
|
||||
case .timelineStart(let item, _):
|
||||
TimelineStartRoomTimelineView(timelineItem: item)
|
||||
case .state(let item, _):
|
||||
StateRoomTimelineView(timelineItem: item)
|
||||
case .group(let item, _):
|
||||
CollapsibleRoomTimelineView(timelineItem: item)
|
||||
case .location(let item, _):
|
||||
LocationRoomTimelineView(timelineItem: item)
|
||||
}
|
||||
}
|
||||
|
||||
var timelineGroupStyle: TimelineGroupStyle {
|
||||
switch self {
|
||||
case .text(_, let groupStyle),
|
||||
.separator(_, let groupStyle),
|
||||
.image(_, let groupStyle),
|
||||
.video(_, let groupStyle),
|
||||
.audio(_, let groupStyle),
|
||||
.file(_, let groupStyle),
|
||||
.emote(_, let groupStyle),
|
||||
.notice(_, let groupStyle),
|
||||
.redacted(_, let groupStyle),
|
||||
.encrypted(_, let groupStyle),
|
||||
.readMarker(_, let groupStyle),
|
||||
.paginationIndicator(_, let groupStyle),
|
||||
.sticker(_, let groupStyle),
|
||||
.unsupported(_, let groupStyle),
|
||||
.timelineStart(_, let groupStyle),
|
||||
.state(_, let groupStyle),
|
||||
.group(_, let groupStyle),
|
||||
.location(_, let groupStyle):
|
||||
return groupStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user