diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index a2b43b177..7b5338b88 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; 12EDAFB64FA5F6812D54F39A /* MigrationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationScreenViewModel.swift; sourceTree = ""; }; 12F1E7F9C2BE8BB751037826 /* WaitlistScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistScreenCoordinator.swift; sourceTree = ""; }; - 1304D9191300873EADA52D6E /* IntegrationTests.xctestplan */ = {isa = PBXFileReference; path = IntegrationTests.xctestplan; sourceTree = ""; }; + 1304D9191300873EADA52D6E /* IntegrationTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = IntegrationTests.xctestplan; sourceTree = ""; }; 130ED565A078F7E0B59D9D25 /* UNTextInputNotificationResponse+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNTextInputNotificationResponse+Creator.swift"; sourceTree = ""; }; 13802897C7AFA360EA74C0B0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; 1423AB065857FA546444DB15 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; @@ -959,7 +960,7 @@ 47111410B6E659A697D472B5 /* RoomProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxyProtocol.swift; sourceTree = ""; }; 471EB7D96AFEA8D787659686 /* EmoteRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineView.swift; sourceTree = ""; }; 47873756E45B46683D97DC32 /* LegalInformationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenModels.swift; sourceTree = ""; }; - 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 = ""; }; 47EBB5D698CE9A25BB553A2D /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 47F29139BC2A804CE5E0757E /* MediaUploadPreviewScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModel.swift; sourceTree = ""; }; @@ -1126,7 +1127,7 @@ 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; - 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; + 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = ""; }; 8E1BBA73B611EDEEA6E20E05 /* InvitesScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenModels.swift; sourceTree = ""; }; 8EC57A32ABC80D774CC663DB /* SettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenUITests.swift; sourceTree = ""; }; 8F21ED7205048668BEB44A38 /* AppActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActivityView.swift; sourceTree = ""; }; @@ -1189,6 +1190,7 @@ A58E93D91DE3288010390DEE /* EmojiDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetectionTests.swift; sourceTree = ""; }; A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = ""; }; A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; + A72F51AA2A54364E0038A02F /* RoomTimelineItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewModel.swift; sourceTree = ""; }; A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = ""; }; A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = ""; }; A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = ""; }; @@ -1205,7 +1207,7 @@ ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = ""; }; AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageCache.swift; sourceTree = ""; }; AC3F82523D6F48B926D6AF68 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; - ACB6C5E4950B6C9842F35A38 /* RoomTimelineViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewProvider.swift; sourceTree = ""; }; + ACB6C5E4950B6C9842F35A38 /* RoomTimelineItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemView.swift; sourceTree = ""; }; ACCC1874C122E2BBE648B8F5 /* LegalInformationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenUITests.swift; sourceTree = ""; }; AD378D580A41E42560C60E9C /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; ADB35E2DB4EFE8E6F3959629 /* InviteUsersScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenUITests.swift; sourceTree = ""; }; @@ -1232,7 +1234,7 @@ B4CFE236419E830E8946639C /* Analytics+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Analytics+SwiftUI.swift"; sourceTree = ""; }; B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableAvatarImage.swift; sourceTree = ""; }; B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineView.swift; sourceTree = ""; }; - B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */ = {isa = PBXFileReference; path = ConfettiScene.scn; sourceTree = ""; }; + B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; path = ConfettiScene.scn; sourceTree = ""; }; B6311F21F911E23BE4DF51B4 /* ReadMarkerRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadMarkerRoomTimelineView.swift; sourceTree = ""; }; B697816AF93DA06EC58C5D70 /* WaitlistScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitlistScreenViewModelProtocol.swift; sourceTree = ""; }; B6E89E530A8E92EC44301CA1 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; @@ -1309,7 +1311,7 @@ CD6B0C4639E066915B5E6463 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; CDB3227C7A74B734924942E9 /* RoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProvider.swift; sourceTree = ""; }; CEE0E6043EFCF6FD2A341861 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = ""; }; - CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; path = UnitTests.xctestplan; sourceTree = ""; }; + CEE41494C837AA403A06A5D9 /* UnitTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UnitTests.xctestplan; sourceTree = ""; }; CF48AF076424DBC1615C74AD /* AuthenticationServiceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProxy.swift; sourceTree = ""; }; D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = ""; }; D09A267106B9585D3D0CFC0D /* ClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError.swift; sourceTree = ""; }; @@ -1379,7 +1381,7 @@ ECF79FB25E2D4BD6F50CE7C9 /* RoomMembersListScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenViewModel.swift; sourceTree = ""; }; ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRoomCell.swift; sourceTree = ""; }; ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; - ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = ""; }; + ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = message.caf; sourceTree = ""; }; ED983D4DCA5AFA6E1ED96099 /* StateRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRoomTimelineView.swift; sourceTree = ""; }; EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = ""; }; EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItemContent.swift; sourceTree = ""; }; @@ -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 */, diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index c2b4a2dcc..b135d581d 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -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() 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() } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 2607d1652..51c612ecd 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -16,6 +16,7 @@ import Algorithms import Combine +import OrderedCollections import SwiftUI typealias RoomScreenViewModelType = StateStoreViewModel @@ -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() + 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 diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 4fca32f14..6ca666e0d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -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) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index 103460682..12a40ade5 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -135,12 +135,12 @@ struct TimelineItemPlainStylerView: View { struct TimelineItemPlainStylerView_Previews: PreviewProvider { static let viewModel = RoomScreenViewModel.mock - + static var previews: some View { VStack(alignment: .leading, spacing: 0) { ForEach(1..() { 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? + private var dataSource: UITableViewDiffableDataSource? private var cancellables: Set = [] /// 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() + var snapshot = NSDiffableDataSourceSnapshot() 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) { - guard let item = snapshot.itemIdentifiers.first(where: { $0.id == pinnedItem.id }), + private func restoreScrollPosition(using pinnedItem: PinnedItem, and snapshot: NSDiffableDataSourceSnapshot) { + 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 } diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index bec515e08..4aaf0274e 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -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 diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift new file mode 100644 index 000000000..3c3fa232d --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift @@ -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 + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewModel.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewModel.swift new file mode 100644 index 000000000..72cc4422b --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewModel.swift @@ -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 + } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift deleted file mode 100644 index 7f0130b82..000000000 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift +++ /dev/null @@ -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 - } - } -} diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index 55732df08..222643b69 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -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 {