diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 0017b88085..57fa307a28 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -86,7 +86,6 @@ 206F0DBAB6AF042CA1FF2C0D /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */; }; 208C19811613F9A10F8A7B75 /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; }; 2185C1F6724C78FFF355D6FA /* WelcomeScreenScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB10FA6570DD08B3966C159 /* WelcomeScreenScreenUITests.swift */; }; - 2198B4458AFF69102BBCC370 /* RoomTimelineItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7D3C686C214470F4911618 /* RoomTimelineItemViewModel.swift */; }; 21BF2B7CEDFE3CA67C5355AD /* test_image.png in Resources */ = {isa = PBXBuildFile; fileRef = C733D11B421CFE3A657EF230 /* test_image.png */; }; 22882C710BC99EC34A5024A0 /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; }; 2352C541AF857241489756FF /* MockRoomSummaryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */; }; @@ -172,6 +171,7 @@ 40B79D20A873620F7F128A2C /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FA991289149D31F4286747 /* UserPreference.swift */; }; 414F50CFCFEEE2611127DCFB /* RestorationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3558A15CFB934F9229301527 /* RestorationToken.swift */; }; 4166A7DD2A4E2EFF0EB9369B /* FormRowLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1897720266C036471AD9D1B /* FormRowLabelStyle.swift */; }; + 41CE5E1289C8768FC5B6490C /* RoomTimelineItemViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */; }; 41DFDD212D1BE57CA50D783B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 0DD568A494247444A4B56031 /* Kingfisher */; }; 41F553349AF44567184822D8 /* APNSPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D670124FC3E84F23A62CCF /* APNSPayload.swift */; }; 4219391CD2351E410554B3E8 /* AggregratedReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B858A61F2A570DFB8DE570A7 /* AggregratedReaction.swift */; }; @@ -296,7 +296,6 @@ 6F2D5D4F2590310DFAE973E4 /* WaitingDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6D698BFD68B061350553930 /* WaitingDialog.swift */; }; 6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF7BF82A950B91BC5469E91 /* ViewFrameReader.swift */; }; 6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */; }; - 702694459B649B9D3A3C34F8 /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */; }; 70394ECD2DCC70741538620D /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BB8DDE245ED86C489BA983 /* AccessibilityIdentifiers.swift */; }; 70558528EF68CAAEF09972D5 /* RoomTimelineItemFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96ED747FF90332EA1333C22 /* RoomTimelineItemFixtures.swift */; }; 706289B086B0A6B0C211763F /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; }; @@ -1372,6 +1371,7 @@ D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = ""; }; D09A267106B9585D3D0CFC0D /* ClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError.swift; sourceTree = ""; }; D0A45283CF1DB96E583BECA6 /* ImageRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomTimelineView.swift; sourceTree = ""; }; + D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewState.swift; sourceTree = ""; }; D1897720266C036471AD9D1B /* FormRowLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormRowLabelStyle.swift; sourceTree = ""; }; D263254AFE5B7993FFBBF324 /* NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NSE.entitlements; sourceTree = ""; }; D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -1472,10 +1472,8 @@ F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = ""; }; F899D02CF26EA7675EEBE74C /* UserSessionScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionScreenTests.swift; sourceTree = ""; }; F8CEB4634C0DD7779C4AB504 /* CreateRoomScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreenUITests.swift; sourceTree = ""; }; - F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = ""; }; F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineView.swift; sourceTree = ""; }; F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineView.swift; sourceTree = ""; }; - FA7D3C686C214470F4911618 /* RoomTimelineItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewModel.swift; sourceTree = ""; }; FB0D6CB491777E7FC6B5BA12 /* CreateRoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreen.swift; sourceTree = ""; }; FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModel.swift; sourceTree = ""; }; FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = ""; }; @@ -2590,7 +2588,6 @@ 4A4AD793D50748F8997E5B15 /* TimelineItemMacContextMenu.swift */, A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */, 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */, - F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */, 874A1842477895F199567BD7 /* TimelineView.swift */, 1D8572B713A11CFDBF009B2F /* Replies */, A312471EA62EFB0FD94E60DC /* Style */, @@ -2892,7 +2889,7 @@ 7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */, ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */, 90F2F8998E5632668B0AD848 /* RoomTimelineItemView.swift */, - FA7D3C686C214470F4911618 /* RoomTimelineItemViewModel.swift */, + D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */, 75D1D02F7F3AC1122FCFB4F3 /* Items */, ); path = TimelineItems; @@ -3627,7 +3624,7 @@ path = Timeline; sourceTree = ""; }; - "TEMP_62CE5EF1-8768-4933-9547-E5F60DAC11F4" /* element-x-ios */ = { + "TEMP_43147A32-A06C-4EEF-8C45-FDEA43A25C1E" /* element-x-ios */ = { isa = PBXGroup; children = ( 41553551C55AD59885840F0E /* secrets.xcconfig */, @@ -4532,7 +4529,7 @@ 878070573C7BF19E735707B4 /* RoomTimelineItemProperties.swift in Sources */, 1AE4AEA0FA8DEF52671832E0 /* RoomTimelineItemProtocol.swift in Sources */, AD55E245FE686D7DB4C86406 /* RoomTimelineItemView.swift in Sources */, - 2198B4458AFF69102BBCC370 /* RoomTimelineItemViewModel.swift in Sources */, + 41CE5E1289C8768FC5B6490C /* RoomTimelineItemViewState.swift in Sources */, 9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */, 77D7DAA41AAB36800C1F2E2D /* RoomTimelineProviderProtocol.swift in Sources */, B2F8E01ABA1BA30265B4ECBE /* RoundedCornerShape.swift in Sources */, @@ -4631,7 +4628,6 @@ 6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */, 69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */, FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */, - 702694459B649B9D3A3C34F8 /* TimelineTableViewController.swift in Sources */, 500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */, 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */, A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */, diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ed6f308542..b78e373659 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -228,8 +228,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "245d527002f29c5a616c5b7131f6a50ac7f41cbf", - "version" : "0.8.6" + "revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865", + "version" : "0.9.0" } } ], diff --git a/ElementX/Sources/Other/ScrollViewAdapter.swift b/ElementX/Sources/Other/ScrollViewAdapter.swift index bdb8d395e0..1256d3ea57 100644 --- a/ElementX/Sources/Other/ScrollViewAdapter.swift +++ b/ElementX/Sources/Other/ScrollViewAdapter.swift @@ -29,7 +29,9 @@ class ScrollViewAdapter: NSObject, UIScrollViewDelegate { scrollView?.delegate = self } } - + + var shouldScrollToTopClosure: ((UIScrollView) -> Bool)? + private let didScrollSubject = PassthroughSubject() var didScroll: AnyPublisher { didScrollSubject.eraseToAnyPublisher() @@ -73,6 +75,14 @@ class ScrollViewAdapter: NSObject, UIScrollViewDelegate { func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { updateDidScroll(scrollView) } + + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + guard let shouldScrollToTopClosure else { + // Default behaviour + return true + } + return shouldScrollToTopClosure(scrollView) + } // MARK: - Private diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 990e154947..fff188ed96 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -51,7 +51,6 @@ enum RoomScreenComposerMode: Equatable { enum RoomScreenViewAction { case displayRoomDetails - case paginateBackwards case itemAppeared(itemID: TimelineItemIdentifier) case itemDisappeared(itemID: TimelineItemIdentifier) case itemTapped(itemID: TimelineItemIdentifier) @@ -86,18 +85,14 @@ struct RoomScreenViewState: BindableState { var roomId: String var roomTitle = "" var roomAvatarURL: URL? - var itemsDictionary = OrderedDictionary() var members: [String: RoomMemberState] = [:] - var canBackPaginate = true - var isBackPaginating = false var showLoading = false var timelineStyle: TimelineStyle var readReceiptsEnabled: Bool var isEncryptedOneToOneRoom = false - + var timelineViewState = TimelineViewState() // check the doc before changing this var composerMode: RoomScreenComposerMode = .default - let scrollToBottomPublisher = PassthroughSubject() - + var bindings: RoomScreenViewStateBindings /// A closure providing the actions to show when long pressing on an item in the timeline. @@ -106,14 +101,6 @@ struct RoomScreenViewState: BindableState { var sendButtonDisabled: Bool { bindings.composerText.count == 0 } - - var timelineIDs: [String] { - itemsDictionary.keys.elements - } - - var itemViewModels: [RoomTimelineItemViewModel] { - itemsDictionary.values.elements - } } struct RoomScreenViewStateBindings { @@ -184,3 +171,21 @@ struct RoomMemberState { let displayName: String? let avatarURL: URL? } + +/// Used as the state for the TimelineView, to avoid having the context continuously refresh the list of items on each small change. +/// Is also nice to have this as a wrapper for any state that is directly connected to the timeline. +struct TimelineViewState { + var canBackPaginate = true + var isBackPaginating = false + var itemsDictionary = OrderedDictionary() + + var timelineIDs: [String] { + itemsDictionary.keys.elements + } + + var itemViewStates: [RoomTimelineItemViewState] { + itemsDictionary.values.elements + } + + var paginateAction: (() -> Void)? +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index b5a74d5c4d..169dafcb41 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -37,6 +37,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private var canCurrentUserRedact = false + private var paginateBackwardsTask: Task? + init(timelineController: RoomTimelineControllerProtocol, mediaProvider: MediaProviderProtocol, roomProxy: RoomProxyProtocol, @@ -60,6 +62,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol bindings: .init(composerText: "", composerFocused: false, reactionsCollapsed: [:])), imageProvider: mediaProvider) + state.timelineViewState.paginateAction = { [weak self] in + self?.paginateBackwards() + } + setupSubscriptions() state.timelineItemMenuActionProvider = { [weak self] itemId -> TimelineItemMenuActions? in @@ -84,8 +90,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol switch viewAction { case .displayRoomDetails: callback?(.displayRoomDetails) - case .paginateBackwards: - Task { await paginateBackwards() } case .itemAppeared(let id): Task { await timelineController.processItemAppearance(id) } case .itemDisappeared(let id): @@ -151,12 +155,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol case .updatedTimelineItems: self.buildTimelineViews() case .canBackPaginate(let canBackPaginate): - if self.state.canBackPaginate != canBackPaginate { - self.state.canBackPaginate = canBackPaginate + if self.state.timelineViewState.canBackPaginate != canBackPaginate { + self.state.timelineViewState.canBackPaginate = canBackPaginate } case .isBackPaginating(let isBackPaginating): - if self.state.isBackPaginating != isBackPaginating { - self.state.isBackPaginating = isBackPaginating + if self.state.timelineViewState.isBackPaginating != isBackPaginating { + self.state.timelineViewState.isBackPaginating = isBackPaginating } } } @@ -215,12 +219,23 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol .store(in: &cancellables) } - private func paginateBackwards() async { - switch await timelineController.paginateBackwards(requestSize: Constants.backPaginationEventLimit, untilNumberOfItems: Constants.backPaginationPageSize) { - case .failure: - displayError(.toast(L10n.errorFailedLoadingMessages)) - default: - break + private func paginateBackwards() { + guard paginateBackwardsTask == nil else { + return + } + + paginateBackwardsTask = Task { [weak self] in + guard let self else { + return + } + + switch await timelineController.paginateBackwards(requestSize: Constants.backPaginationEventLimit, untilNumberOfItems: Constants.backPaginationPageSize) { + case .failure: + displayError(.toast(L10n.errorFailedLoadingMessages)) + default: + break + } + paginateBackwardsTask = nil } } @@ -245,7 +260,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } private func buildTimelineViews() { - var timelineItemsDictionary = OrderedDictionary() + var timelineItemsDictionary = OrderedDictionary() let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in canGroupItem(timelineItem: current, with: next) @@ -259,36 +274,26 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol if itemGroup.count == 1 { if let firstItem = itemGroup.first { - timelineItemsDictionary.updateValue(updateViewModel(item: firstItem, groupStyle: .single), + timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: firstItem, groupStyle: .single), forKey: firstItem.id.timelineID) } } else { for (index, item) in itemGroup.enumerated() { if index == 0 { - timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .first), + timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .first), forKey: item.id.timelineID) } else if index == itemGroup.count - 1 { - timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .last), + timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .last), forKey: item.id.timelineID) } else { - timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .middle), + timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .middle), forKey: item.id.timelineID) } } } } - state.itemsDictionary = timelineItemsDictionary - } - - private func updateViewModel(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewModel { - if let timelineItemViewModel = state.itemsDictionary[item.id.timelineID] { - timelineItemViewModel.groupStyle = groupStyle - timelineItemViewModel.type = .init(item: item) - return timelineItemViewModel - } else { - return RoomTimelineItemViewModel(item: item, groupStyle: groupStyle) - } + state.timelineViewState.itemsDictionary = timelineItemsDictionary } private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool { @@ -666,7 +671,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol // MARK: - Reactions private func showEmojiPicker(for itemID: TimelineItemIdentifier) { - guard let item = state.itemsDictionary[itemID.timelineID], item.isReactable else { return } + guard let item = state.timelineViewState.itemsDictionary[itemID.timelineID], item.isReactable else { return } callback?(.displayEmojiPicker(itemID: itemID)) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 2e65dbb66d..6882aa703d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -24,7 +24,7 @@ struct RoomScreen: View { var body: some View { timeline - .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) // Kills the toolbar translucency. + .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) .safeAreaInset(edge: .bottom, spacing: 0) { HStack(alignment: .bottom, spacing: attachmentButtonPadding) { RoomAttachmentPicker(context: context) @@ -36,6 +36,7 @@ struct RoomScreen: View { .padding(.trailing, 12) .padding(.top, 8) .padding(.bottom) + .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) } .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } @@ -80,14 +81,13 @@ struct RoomScreen: View { } } } - + private var timeline: some View { - TimelineView() + TimelineView(viewState: context.viewState.timelineViewState) .id(context.viewState.roomId) .environmentObject(context) .environment(\.timelineStyle, context.viewState.timelineStyle) .environment(\.readReceiptsEnabled, context.viewState.readReceiptsEnabled) - .overlay(alignment: .bottomTrailing) { scrollToBottomButton } } private var messageComposer: some View { @@ -108,27 +108,6 @@ struct RoomScreen: View { } } - private var scrollToBottomButton: some View { - Button { context.viewState.scrollToBottomPublisher.send(()) } label: { - Image(systemName: "chevron.down") - .font(.compound.bodyLG) - .fontWeight(.semibold) - .foregroundColor(.compound.iconSecondary) - .padding(13) - .offset(y: 1) - .background { - Circle() - .fill(Color.compound.iconOnSolidPrimary) - // Intentionally using system primary colour to get white/black. - .shadow(color: .primary.opacity(0.33), radius: 2.0) - } - .padding() - } - .opacity(context.scrollToBottomButtonVisible ? 1.0 : 0.0) - .accessibilityHidden(!context.scrollToBottomButtonVisible) - .animation(.elementDefault, value: context.scrollToBottomButtonVisible) - } - @ViewBuilder private var loadingIndicator: some View { if context.viewState.showLoading { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 6fea8e8e61..30ded2fd4a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -328,8 +328,8 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider { static var mockTimeline: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { - ForEach(viewModel.state.itemViewModels) { itemViewModel in - RoomTimelineItemView(viewModel: itemViewModel) + ForEach(viewModel.state.timelineViewState.itemViewStates) { viewState in + RoomTimelineItemView(viewState: viewState) .padding(TimelineStyle.bubbles.rowInsets) // Insets added in the table view cells } @@ -341,7 +341,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider { static var replies: some View { VStack { - RoomTimelineItemView(viewModel: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""), + RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""), timestamp: "10:42", isOutgoing: true, isEditable: false, @@ -350,7 +350,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider { replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), contentType: .text(.init(body: "Short")))), groupStyle: .single)) - RoomTimelineItemView(viewModel: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""), + RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""), timestamp: "10:42", isOutgoing: true, isEditable: false, diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index 9474b466c2..b6bca5adc9 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -144,7 +144,7 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider { VStack(alignment: .leading, spacing: 0) { ForEach(1.. some View { VStack(spacing: 0.0) { - Button { configuration.isExpanded.toggle() } label: { + Button { + withElementAnimation { + configuration.isExpanded.toggle() + } + } label: { HStack(alignment: .center) { configuration.label Text(Image(systemName: "chevron.forward")) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift index 4ebd0abc80..74bdd41791 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift @@ -42,8 +42,8 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider { static var previews: some View { VStack(alignment: .leading, spacing: 0) { - RoomTimelineItemView(viewModel: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single)) - RoomTimelineItemView(viewModel: .init(type: .text(.init(id: .init(timelineID: ""), + RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single)) + RoomTimelineItemView(viewState: .init(type: .text(.init(id: .init(timelineID: ""), timestamp: "", isOutgoing: true, isEditable: false, @@ -52,8 +52,8 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider { ReadMarkerRoomTimelineView(timelineItem: item) - RoomTimelineItemView(viewModel: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single)) - RoomTimelineItemView(viewModel: .init(type: .text(.init(id: .init(timelineID: ""), + RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single)) + RoomTimelineItemView(viewState: .init(type: .text(.init(id: .init(timelineID: ""), timestamp: "", isOutgoing: false, isEditable: false, diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift deleted file mode 100644 index 240e0e8661..0000000000 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift +++ /dev/null @@ -1,283 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Combine -import 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: RoomTimelineItemViewModel? - - override func prepareForReuse() { - item = nil - } -} - -/// A table view controller that displays the timeline of a room. -/// -/// This class subclasses `UIViewController` as `UITableViewController` adds some -/// extra keyboard handling magic that wasn't playing well with SwiftUI (as of iOS 16.1). -/// Also this TableViewController uses a **flipped tableview** -class TimelineTableViewController: UIViewController { - private let coordinator: TimelineView.Coordinator - private let tableView = UITableView(frame: .zero, style: .plain) - - var timelineStyle: TimelineStyle - var timelineItemsDictionary = OrderedDictionary() { - didSet { - applySnapshot() - - 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 - - /// Whether or not the timeline has more messages to back paginate. - var canBackPaginate = true - - /// Whether or not the timeline is waiting for more messages to be added to the top. - var isBackPaginating = false { - didSet { - // Paginate again if the threshold hasn't been satisfied. - paginateBackwardsPublisher.send(()) - } - } - - var contextMenuActionProvider: (@MainActor (_ itemID: TimelineItemIdentifier) -> TimelineItemMenuActions?)? - - @Binding private var scrollToBottomButtonVisible: Bool - - private var timelineItemsIDs: [String] { - timelineItemsDictionary.keys.elements.reversed() - } - - /// The table's diffable data source. - private var dataSource: UITableViewDiffableDataSource? - private var cancellables: Set = [] - - /// A publisher used to throttle back pagination requests. - /// - /// Our view actions get wrapped in a `Task` so it is possible that a second call in - /// quick succession can execute before ``isBackPaginating`` becomes `true`. - private let paginateBackwardsPublisher = PassthroughSubject() - /// Whether or not the view has been shown on screen yet. - private var hasAppearedOnce = false - /// Whether the scroll and the animations should happen - private var shouldAnimate = false - - init(coordinator: TimelineView.Coordinator, - timelineStyle: TimelineStyle, - scrollToBottomButtonVisible: Binding, - scrollToBottomPublisher: PassthroughSubject) { - self.coordinator = coordinator - self.timelineStyle = timelineStyle - _scrollToBottomButtonVisible = scrollToBottomButtonVisible - - super.init(nibName: nil, bundle: nil) - - tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier) - tableView.separatorStyle = .none - tableView.allowsSelection = false - tableView.keyboardDismissMode = .onDrag - tableView.backgroundColor = UIColor(.compound.bgCanvasDefault) - tableView.transform = CGAffineTransform(scaleX: 1, y: -1) - view.addSubview(tableView) - - // Prevents XCUITest from invoking the diffable dataSource's cellProvider - // for each possible cell, causing layout issues - tableView.accessibilityElementsHidden = Tests.shouldDisableTimelineAccessibility - - scrollToBottomPublisher - .sink { [weak self] _ in - self?.scrollToBottom(animated: true) - } - .store(in: &cancellables) - - paginateBackwardsPublisher - .collect(.byTime(DispatchQueue.main, 0.1)) - .sink { [weak self] _ in - self?.paginateBackwardsIfNeeded() - } - .store(in: &cancellables) - - configureDataSource() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) is not available.") } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - guard !hasAppearedOnce else { return } - tableView.contentOffset.y = -1 - hasAppearedOnce = true - paginateBackwardsPublisher.send() - - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.shouldAnimate = true - } - } - - override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - - guard tableView.frame.size != view.frame.size else { - return - } - - tableView.frame = CGRect(origin: .zero, size: view.frame.size) - } - - /// Configures a diffable data source for the timeline's table view. - private func configureDataSource() { - 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 - - let viewModel = timelineItemsDictionary[id] - cell.item = viewModel - guard let viewModel else { - return cell - } - cell.contentConfiguration = UIHostingConfiguration { - 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(itemID: viewModel.id)) - } - .onDisappear { - coordinator.send(viewAction: .itemDisappeared(itemID: viewModel.id)) - } - .environment(\.openURL, OpenURLAction { url in - coordinator.send(viewAction: .linkClicked(url: url)) - return .systemAction - }) - } - .margins(.all, self.timelineStyle.rowInsets) - .minSize(height: 1) - .background(Color.clear) - - // Flipping the cell can create some issues with cell resizing, so flip the content View - cell.contentView.transform = CGAffineTransform(scaleX: 1, y: -1) - return cell - } - - dataSource?.defaultRowAnimation = .fade - tableView.delegate = self - } - - /// Updates the table view with the latest items from the ``timelineItems`` array. After - /// updating the data, the table will be scrolled to the bottom if it was visible otherwise - /// the scroll position will be updated to maintain the position of the last visible item. - private func applySnapshot() { - guard let dataSource else { return } - - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(timelineItemsIDs) - - let currentSnapshot = dataSource.snapshot() - MXLog.verbose("DIFF: \(snapshot.itemIdentifiers.difference(from: currentSnapshot.itemIdentifiers))") - - // We only animate when new items come at the end of the timeline - let animated = shouldAnimate && - snapshot.itemIdentifiers.first != currentSnapshot.itemIdentifiers.first - dataSource.apply(snapshot, animatingDifferences: animated) - } - - /// Scrolls to the bottom of the timeline. - private func scrollToBottom(animated: Bool) { - guard !timelineItemsIDs.isEmpty else { - return - } - tableView.scrollToRow(at: IndexPath(item: 0, section: 0), at: .top, animated: animated) - } - - /// Scrolls to the top of the timeline. - private func scrollToTop(animated: Bool) { - guard !timelineItemsIDs.isEmpty else { - return - } - tableView.scrollToRow(at: IndexPath(item: timelineItemsIDs.count - 1, section: 0), at: .bottom, animated: animated) - } - - /// Checks whether or a backwards pagination is needed and requests one if so. - /// - /// Prefer not to call this directly, instead using ``paginateBackwardsPublisher`` to throttle requests. - private func paginateBackwardsIfNeeded() { - guard canBackPaginate, - !isBackPaginating, - tableView.contentOffset.y > tableView.contentSize.height - tableView.visibleSize.height * 2.0 - else { return } - - coordinator.send(viewAction: .paginateBackwards) - } -} - -// MARK: - UITableViewDelegate - -extension TimelineTableViewController: UITableViewDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - paginateBackwardsPublisher.send(()) - - // Dispatch to fix runtime warning about making changes during a view update. - DispatchQueue.main.async { [weak self] in - guard let self else { return } - - let scrollToBottomButtonVisible = scrollView.contentOffset.y > 0 - - // Only update the binding on changes to avoid needlessly recomputing the hierarchy when scrolling. - if self.scrollToBottomButtonVisible != scrollToBottomButtonVisible { - self.scrollToBottomButtonVisible = scrollToBottomButtonVisible - } - } - - // We never want the table view to be fully at the bottom to allow the status bar tap to work properly - if scrollView.contentOffset.y == 0 { - scrollView.contentOffset.y = -1 - } - } - - func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { - scrollToTop(animated: true) - return false - } -} - -// MARK: - Layout Types - -extension TimelineTableViewController { - /// The sections of the table view used in the diffable data source. - enum TimelineSection { - case main - } -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index 4aaf0274e7..c1979eab7f 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -14,68 +14,146 @@ // limitations under the License. // +import Combine import SwiftUI -/// A table view wrapper that displays the timeline of a room. -struct TimelineView: UIViewControllerRepresentable { - @EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context +import SwiftUIIntrospect + +struct TimelineView: View { + let viewState: TimelineViewState @Environment(\.timelineStyle) private var timelineStyle - - func makeUIViewController(context: Context) -> TimelineTableViewController { - let tableViewController = TimelineTableViewController(coordinator: context.coordinator, - timelineStyle: timelineStyle, - scrollToBottomButtonVisible: $viewModelContext.scrollToBottomButtonVisible, - scrollToBottomPublisher: viewModelContext.viewState.scrollToBottomPublisher) - return tableViewController - } - - func updateUIViewController(_ uiViewController: TimelineTableViewController, context: Context) { - context.coordinator.update(tableViewController: uiViewController, timelineStyle: timelineStyle) - } - - func makeCoordinator() -> Coordinator { - Coordinator(viewModelContext: viewModelContext) - } - - // MARK: - Coordinator - - @MainActor - class Coordinator { - let context: RoomScreenViewModel.Context - - init(viewModelContext: RoomScreenViewModel.Context) { - context = viewModelContext - - if viewModelContext.viewState.itemViewModels.isEmpty { - viewModelContext.send(viewAction: .paginateBackwards) + + private let bottomID = "RoomTimelineBottomPinIdentifier" + private let topID = "RoomTimelineTopPinIdentifier" + + @State private var scrollViewAdapter = ScrollViewAdapter() + @State private var paginateBackwardsPublisher = PassthroughSubject() + @State private var scrollToBottomPublisher = PassthroughSubject() + @State private var scrollToBottomButtonVisible = false + + var body: some View { + ScrollViewReader { scrollView in + ScrollView { + bottomPin + + LazyVStack(spacing: 0) { + ForEach(viewState.itemViewStates.reversed()) { viewState in + RoomTimelineItemView(viewState: viewState) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(timelineStyle.rowInsets) + .scaleEffect(x: 1, y: -1) + } + } + + topPin } - } - - /// Updates the specified table view's properties from the current view state. - func update(tableViewController: TimelineTableViewController, timelineStyle: TimelineStyle) { - if tableViewController.timelineStyle != timelineStyle { - tableViewController.timelineStyle = timelineStyle + .introspect(.scrollView, on: .iOS(.v16)) { uiScrollView in + guard uiScrollView != scrollViewAdapter.scrollView else { + return + } + + scrollViewAdapter.scrollView = uiScrollView + scrollViewAdapter.shouldScrollToTopClosure = { _ in + withElementAnimation { + scrollView.scrollTo(topID) + } + return false + } + + // Allows the scroll to top to work properly + uiScrollView.contentOffset.y -= 1 } - if tableViewController.timelineItemsDictionary != context.viewState.itemsDictionary { - tableViewController.timelineItemsDictionary = context.viewState.itemsDictionary + .scaleEffect(x: 1, y: -1) + .onReceive(scrollToBottomPublisher) { _ in + withElementAnimation { + scrollView.scrollTo(bottomID) + } } - if tableViewController.canBackPaginate != context.viewState.canBackPaginate { - tableViewController.canBackPaginate = context.viewState.canBackPaginate + .scrollDismissesKeyboard(.interactively) + } + .overlay(scrollToBottomButton, alignment: .bottomTrailing) + .animation(.elementDefault, value: viewState.itemViewStates) + .onReceive(scrollViewAdapter.didScroll) { _ in + guard let scrollView = scrollViewAdapter.scrollView else { + return } - if tableViewController.isBackPaginating != context.viewState.isBackPaginating { - tableViewController.isBackPaginating = context.viewState.isBackPaginating + let offset = scrollView.contentOffset.y + scrollView.contentInset.top + let scrollToBottomButtonVisibleValue = offset > 0 + if scrollToBottomButtonVisibleValue != scrollToBottomButtonVisible { + scrollToBottomButtonVisible = scrollToBottomButtonVisibleValue } - if tableViewController.composerMode != context.viewState.composerMode { - tableViewController.composerMode = context.viewState.composerMode + paginateBackwardsPublisher.send() + + // Allows the scroll to top to work properly + if offset == 0 { + scrollView.contentOffset.y -= 1 } - - // Doesn't have an equatable conformance :( - tableViewController.contextMenuActionProvider = context.viewState.timelineItemMenuActionProvider } - - func send(viewAction: RoomScreenViewAction) { - context.send(viewAction: viewAction) + .onReceive(paginateBackwardsPublisher.collect(.byTime(DispatchQueue.main, 0.1))) { _ in + paginateBackwardsIfNeeded() + } + .onAppear { + paginateBackwardsPublisher.send() + } + } + + /// Used to mark the top of the scroll view and easily scroll to it + private var topPin: some View { + Divider() + .id(topID) + .hidden() + .frame(height: 0) + } + + /// Used to mark the bottom of the scroll view and easily scroll to it + private var bottomPin: some View { + Divider() + .id(bottomID) + .hidden() + .frame(height: 0) + } + + private var scrollToBottomButton: some View { + Button { + scrollToBottomPublisher.send() + } label: { + Image(systemName: "chevron.down") + .font(.compound.bodyLG) + .fontWeight(.semibold) + .foregroundColor(.compound.iconSecondary) + .padding(13) + .offset(y: 1) + .background { + Circle() + .fill(Color.compound.iconOnSolidPrimary) + // Intentionally using system primary colour to get white/black. + .shadow(color: .primary.opacity(0.33), radius: 2.0) + } + .padding() + } + .opacity(scrollToBottomButtonVisible ? 1.0 : 0.0) + .accessibilityHidden(!scrollToBottomButtonVisible) + .animation(.elementDefault, value: scrollToBottomButtonVisible) + } + + private func paginateBackwardsIfNeeded() { + guard let paginateAction = viewState.paginateAction, + let scrollView = scrollViewAdapter.scrollView, + viewState.canBackPaginate, + !viewState.isBackPaginating else { + return + } + + let visibleHeight = scrollView.visibleSize.height + let contentHeight = scrollView.contentSize.height + let offset = scrollView.contentOffset.y + scrollView.contentInset.top + let threshold = contentHeight - visibleHeight * 2 + + guard offset > threshold else { + return } + + paginateAction() } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift index 2c8dbf9554..4aec32929a 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift @@ -16,17 +16,27 @@ import SwiftUI struct RoomTimelineItemView: View { - @ObservedObject var viewModel: RoomTimelineItemViewModel + @EnvironmentObject private var context: RoomScreenViewModel.Context + let viewState: RoomTimelineItemViewState var body: some View { timelineView - .environment(\.timelineGroupStyle, viewModel.groupStyle) - .animation(.elementDefault, value: viewModel.type) - .animation(.elementDefault, value: viewModel.groupStyle) + .environmentObject(context) + .environment(\.timelineGroupStyle, viewState.groupStyle) + .onAppear { + context.send(viewAction: .itemAppeared(itemID: viewState.identifier)) + } + .onDisappear { + context.send(viewAction: .itemDisappeared(itemID: viewState.identifier)) + } + .environment(\.openURL, OpenURLAction { url in + context.send(viewAction: .linkClicked(url: url)) + return .systemAction + }) } @ViewBuilder private var timelineView: some View { - switch viewModel.type { + switch viewState.type { case .text(let item): TextRoomTimelineView(timelineItem: item) case .separator(let item): @@ -69,6 +79,6 @@ struct RoomTimelineItemView: View { } var timelineGroupStyle: TimelineGroupStyle { - viewModel.groupStyle + viewState.groupStyle } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewModel.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift similarity index 89% rename from ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewModel.swift rename to ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift index 1ed1ba1e86..aa8cf50633 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewModel.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift @@ -16,25 +16,18 @@ 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 +struct RoomTimelineItemViewState: Identifiable, Equatable { + let type: RoomTimelineItemType + let groupStyle: TimelineGroupStyle - var id: TimelineItemIdentifier { + /// Contains all the identification info of the item, `timelineID`, `eventID` and `transactionID` + var identifier: TimelineItemIdentifier { 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 + /// The `timelineID` of the item, used for the timeline view level identification, do not use for any business logic use `identifier` instead + var id: String { + identifier.timelineID } var isReactable: Bool { @@ -42,6 +35,12 @@ final class RoomTimelineItemViewModel: Identifiable, Equatable, ObservableObject } } +extension RoomTimelineItemViewState { + init(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) { + self.init(type: .init(item: item), groupStyle: groupStyle) + } +} + enum RoomTimelineItemType: Equatable { case text(TextRoomTimelineItem) case separator(SeparatorRoomTimelineItem) diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index 6cefb3e0a6..e7a455faa1 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.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.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the middle message from being grouped.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[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.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.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "A message should not be grouped when the sender changes.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .single, "A message should not be grouped when the sender changes.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[3].groupStyle, .last, "A group should be ended when the sender changes in the next message.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[4].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[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.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.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "When the first message has reactions it should not be grouped.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .first, "A new group should be made when the preceding message has reactions.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[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.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.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .last, "When the message has reactions, the group should end here.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[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.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.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.") + XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.") } func testGoToUserDetailsSuccessNoDelay() async {