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:
Mauro 2023-07-04 16:08:43 +02:00 committed by GitHub
parent eba3e5d5e6
commit b294278170
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 376 additions and 316 deletions

View File

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 51;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@ -464,6 +464,7 @@
A680F54935A6ADEA4ED6C38F /* TimelineItemStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A4C9547BBFEEF30AA11329B /* TimelineItemStatusView.swift */; };
A6D4C5EEA85A6A0ABA1559D6 /* RoomDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */; };
A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */; };
A72F51AB2A54364E0038A02F /* RoomTimelineItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A72F51AA2A54364E0038A02F /* RoomTimelineItemViewModel.swift */; };
A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.swift */; };
A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; };
A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; };
@ -587,7 +588,7 @@
CEB8FB1269DE20536608B957 /* LoginMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41FABA2B0AEF4389986495 /* LoginMode.swift */; };
CF3827071B0BC9638BD44F5D /* WaitlistScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB58EF0176D4CFB1040DA22 /* WaitlistScreenViewModel.swift */; };
CF4044A8EED5C41BC0ED6ABE /* SoftLogoutScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */; };
CF82143AA4A4F7BD11D22946 /* RoomTimelineViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB6C5E4950B6C9842F35A38 /* RoomTimelineViewProvider.swift */; };
CF82143AA4A4F7BD11D22946 /* RoomTimelineItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB6C5E4950B6C9842F35A38 /* RoomTimelineItemView.swift */; };
D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */; };
D181AC8FF236B7F91C0A8C28 /* MapTiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AA3F4B285570805CB0CCDD /* MapTiler.swift */; };
D2A15D03F81342A09340BD56 /* AnalyticsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFEEE93B82937B2E86F92EB /* AnalyticsScreen.swift */; };
@ -824,7 +825,7 @@
127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentValuePublisher.swift; sourceTree = "<group>"; };
12EDAFB64FA5F6812D54F39A /* MigrationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationScreenViewModel.swift; sourceTree = "<group>"; };
12F1E7F9C2BE8BB751037826 /* WaitlistScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistScreenCoordinator.swift; sourceTree = "<group>"; };
1304D9191300873EADA52D6E /* IntegrationTests.xctestplan */ = {isa = PBXFileReference; path = IntegrationTests.xctestplan; sourceTree = "<group>"; };
1304D9191300873EADA52D6E /* IntegrationTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = IntegrationTests.xctestplan; sourceTree = "<group>"; };
130ED565A078F7E0B59D9D25 /* UNTextInputNotificationResponse+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNTextInputNotificationResponse+Creator.swift"; sourceTree = "<group>"; };
13802897C7AFA360EA74C0B0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
1423AB065857FA546444DB15 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
@ -959,7 +960,7 @@
47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxyProtocol.swift; sourceTree = "<group>"; };
471EB7D96AFEA8D787659686 /* EmoteRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineView.swift; sourceTree = "<group>"; };
47873756E45B46683D97DC32 /* LegalInformationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenModels.swift; sourceTree = "<group>"; };
478BE8591BD13E908EF70C0C /* DesignKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = DesignKit; path = DesignKit; sourceTree = SOURCE_ROOT; };
478BE8591BD13E908EF70C0C /* DesignKit */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DesignKit; sourceTree = SOURCE_ROOT; };
4798B3B7A1E8AE3901CEE8C6 /* FramePreferenceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramePreferenceKey.swift; sourceTree = "<group>"; };
47EBB5D698CE9A25BB553A2D /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; };
47F29139BC2A804CE5E0757E /* MediaUploadPreviewScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModel.swift; sourceTree = "<group>"; };
@ -1126,7 +1127,7 @@
8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = "<group>"; };
8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = "<group>"; };
8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = "<group>"; };
8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = "<group>"; };
8E1BBA73B611EDEEA6E20E05 /* InvitesScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenModels.swift; sourceTree = "<group>"; };
8EC57A32ABC80D774CC663DB /* SettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenUITests.swift; sourceTree = "<group>"; };
8F21ED7205048668BEB44A38 /* AppActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActivityView.swift; sourceTree = "<group>"; };
@ -1189,6 +1190,7 @@
A58E93D91DE3288010390DEE /* EmojiDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetectionTests.swift; sourceTree = "<group>"; };
A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = "<group>"; };
A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = "<group>"; };
A72F51AA2A54364E0038A02F /* RoomTimelineItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewModel.swift; sourceTree = "<group>"; };
A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = "<group>"; };
A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = "<group>"; };
A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = "<group>"; };
@ -1205,7 +1207,7 @@
ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = "<group>"; };
AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageCache.swift; sourceTree = "<group>"; };
AC3F82523D6F48B926D6AF68 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
ACB6C5E4950B6C9842F35A38 /* RoomTimelineViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewProvider.swift; sourceTree = "<group>"; };
ACB6C5E4950B6C9842F35A38 /* RoomTimelineItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemView.swift; sourceTree = "<group>"; };
ACCC1874C122E2BBE648B8F5 /* LegalInformationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenUITests.swift; sourceTree = "<group>"; };
AD378D580A41E42560C60E9C /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
ADB35E2DB4EFE8E6F3959629 /* InviteUsersScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenUITests.swift; sourceTree = "<group>"; };
@ -1232,7 +1234,7 @@
B4CFE236419E830E8946639C /* Analytics+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Analytics+SwiftUI.swift"; sourceTree = "<group>"; };
B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableAvatarImage.swift; sourceTree = "<group>"; };
B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineView.swift; sourceTree = "<group>"; };
B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */ = {isa = PBXFileReference; path = ConfettiScene.scn; sourceTree = "<group>"; };
B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; path = ConfettiScene.scn; sourceTree = "<group>"; };
B6311F21F911E23BE4DF51B4 /* ReadMarkerRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineView.swift; sourceTree = "<group>"; };
B697816AF93DA06EC58C5D70 /* WaitlistScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistScreenViewModelProtocol.swift; sourceTree = "<group>"; };
B6E89E530A8E92EC44301CA1 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = "<group>"; };
@ -1309,7 +1311,7 @@
CD6B0C4639E066915B5E6463 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = "<group>"; };
CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = "<group>"; };
CEE0E6043EFCF6FD2A341861 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = "<group>"; };
CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; path = UnitTests.xctestplan; sourceTree = "<group>"; };
CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UnitTests.xctestplan; sourceTree = "<group>"; };
CF48AF076424DBC1615C74AD /* AuthenticationServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProxy.swift; sourceTree = "<group>"; };
D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = "<group>"; };
D09A267106B9585D3D0CFC0D /* ClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError.swift; sourceTree = "<group>"; };
@ -1379,7 +1381,7 @@
ECF79FB25E2D4BD6F50CE7C9 /* RoomMembersListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenViewModel.swift; sourceTree = "<group>"; };
ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = "<group>"; };
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = "<group>"; };
ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = "<group>"; };
ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = message.caf; sourceTree = "<group>"; };
ED983D4DCA5AFA6E1ED96099 /* StateRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRoomTimelineView.swift; sourceTree = "<group>"; };
EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = "<group>"; };
EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItemContent.swift; sourceTree = "<group>"; };
@ -2778,8 +2780,9 @@
8D55702474F279D910D2D162 /* RoomStateEventStringBuilder.swift */,
105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */,
7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */,
A72F51AA2A54364E0038A02F /* RoomTimelineItemViewModel.swift */,
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */,
ACB6C5E4950B6C9842F35A38 /* RoomTimelineViewProvider.swift */,
ACB6C5E4950B6C9842F35A38 /* RoomTimelineItemView.swift */,
75D1D02F7F3AC1122FCFB4F3 /* Items */,
);
path = TimelineItems;
@ -4261,6 +4264,7 @@
0C58A846F61949B1D545D661 /* NoticeRoomTimelineItem.swift in Sources */,
9408CE8B8865C0C8DD4C9869 /* NoticeRoomTimelineItemContent.swift in Sources */,
368C8758FCD079E6AAA18C2C /* NoticeRoomTimelineView.swift in Sources */,
A72F51AB2A54364E0038A02F /* RoomTimelineItemViewModel.swift in Sources */,
3F70E237CE4C3FAB02FC227F /* NotificationConstants.swift in Sources */,
CE9530A4CA661E090635C2F2 /* NotificationItemProxy.swift in Sources */,
652ACCF104A8CEF30788963C /* NotificationManager.swift in Sources */,
@ -4347,7 +4351,7 @@
1AE4AEA0FA8DEF52671832E0 /* RoomTimelineItemProtocol.swift in Sources */,
9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */,
77D7DAA41AAB36800C1F2E2D /* RoomTimelineProviderProtocol.swift in Sources */,
CF82143AA4A4F7BD11D22946 /* RoomTimelineViewProvider.swift in Sources */,
CF82143AA4A4F7BD11D22946 /* RoomTimelineItemView.swift in Sources */,
B2F8E01ABA1BA30265B4ECBE /* RoundedCornerShape.swift in Sources */,
50C90117FE25390BFBD40173 /* RustTracing.swift in Sources */,
D43F0503EF2CBC55272538FE /* SDKGeneratedMocks.swift in Sources */,

View File

@ -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>()
}

View File

@ -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

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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
}()

View File

@ -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

View File

@ -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))
}

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -51,9 +51,9 @@ class RoomScreenViewModelTests: XCTestCase {
userIndicatorController: userIndicatorControllerMock)
// Then the messages should be grouped together.
XCTAssertEqual(viewModel.state.items[0].timelineGroupStyle, .first, "Nothing should prevent the first message from being grouped.")
XCTAssertEqual(viewModel.state.items[1].timelineGroupStyle, .middle, "Nothing should prevent the middle message from being grouped.")
XCTAssertEqual(viewModel.state.items[2].timelineGroupStyle, .last, "Nothing should prevent the last message from being grouped.")
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .middle, "Nothing should prevent the middle message from being grouped.")
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
}
func testMessageGroupingMultipleSenders() {
@ -84,12 +84,12 @@ class RoomScreenViewModelTests: XCTestCase {
userIndicatorController: userIndicatorControllerMock)
// Then the messages should be grouped by sender.
XCTAssertEqual(viewModel.state.items[0].timelineGroupStyle, .single, "A message should not be grouped when the sender changes.")
XCTAssertEqual(viewModel.state.items[1].timelineGroupStyle, .single, "A message should not be grouped when the sender changes.")
XCTAssertEqual(viewModel.state.items[2].timelineGroupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
XCTAssertEqual(viewModel.state.items[3].timelineGroupStyle, .last, "A group should be ended when the sender changes in the next message.")
XCTAssertEqual(viewModel.state.items[4].timelineGroupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
XCTAssertEqual(viewModel.state.items[5].timelineGroupStyle, .last, "A group should be ended when the sender changes in the next message.")
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .single, "A message should not be grouped when the sender changes.")
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .single, "A message should not be grouped when the sender changes.")
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
XCTAssertEqual(viewModel.state.itemViewModels[3].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
XCTAssertEqual(viewModel.state.itemViewModels[4].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
XCTAssertEqual(viewModel.state.itemViewModels[5].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
}
func testMessageGroupingWithLeadingReactions() {
@ -115,9 +115,9 @@ class RoomScreenViewModelTests: XCTestCase {
userIndicatorController: userIndicatorControllerMock)
// Then the first message should not be grouped but the other two should.
XCTAssertEqual(viewModel.state.items[0].timelineGroupStyle, .single, "When the first message has reactions it should not be grouped.")
XCTAssertEqual(viewModel.state.items[1].timelineGroupStyle, .first, "A new group should be made when the preceding message has reactions.")
XCTAssertEqual(viewModel.state.items[2].timelineGroupStyle, .last, "Nothing should prevent the last message from being grouped.")
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .single, "When the first message has reactions it should not be grouped.")
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .first, "A new group should be made when the preceding message has reactions.")
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
}
func testMessageGroupingWithInnerReactions() {
@ -143,9 +143,9 @@ class RoomScreenViewModelTests: XCTestCase {
userIndicatorController: userIndicatorControllerMock)
// Then the first and second messages should be grouped and the last one should not.
XCTAssertEqual(viewModel.state.items[0].timelineGroupStyle, .first, "Nothing should prevent the first message from being grouped.")
XCTAssertEqual(viewModel.state.items[1].timelineGroupStyle, .last, "When the message has reactions, the group should end here.")
XCTAssertEqual(viewModel.state.items[2].timelineGroupStyle, .single, "The last message should not be grouped when the preceding message has reactions.")
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .last, "When the message has reactions, the group should end here.")
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .single, "The last message should not be grouped when the preceding message has reactions.")
}
func testMessageGroupingWithTrailingReactions() {
@ -171,9 +171,9 @@ class RoomScreenViewModelTests: XCTestCase {
userIndicatorController: userIndicatorControllerMock)
// Then the messages should be grouped together.
XCTAssertEqual(viewModel.state.items[0].timelineGroupStyle, .first, "Nothing should prevent the first message from being grouped.")
XCTAssertEqual(viewModel.state.items[1].timelineGroupStyle, .middle, "Nothing should prevent the second message from being grouped.")
XCTAssertEqual(viewModel.state.items[2].timelineGroupStyle, .last, "Reactions on the last message should not prevent it from being grouped.")
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.")
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.")
}
func testGoToUserDetailsSuccessNoDelay() async {