From 99b5bf5150465d5997a4873869757d3c5c63e355 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Wed, 26 Jul 2023 15:53:43 +0200 Subject: [PATCH] Reverting to TableView but with SwiftUI compatibility (#1407) * revert but also allows us to keep the existing SwiftUI implementation if we need it * code improvement * pr review * FF is volatile * message that communicates that is volatile --- ElementX.xcodeproj/project.pbxproj | 24 +- .../Sources/Application/AppSettings.swift | 4 + .../Screens/RoomScreen/RoomScreenModels.swift | 8 +- .../RoomScreen/RoomScreenViewModel.swift | 30 +- .../Screens/RoomScreen/View/RoomScreen.swift | 38 ++- .../TimelineTableViewController.swift | 283 ++++++++++++++++++ .../View/Timeline/UITimelineView.swift | 97 ++++++ .../RoomScreen/View/TimelineView.swift | 21 +- .../DeveloperOptionsScreenModels.swift | 1 + .../View/DeveloperOptionsScreen.swift | 5 + .../TimelineItems/RoomTimelineItemView.swift | 4 +- .../RoomTimelineItemViewState.swift | 19 +- 12 files changed, 497 insertions(+), 37 deletions(-) create mode 100644 ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift create mode 100644 ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 8c911b41f8..0ae19bd9e5 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 */ @@ -480,6 +480,8 @@ 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 */; }; + A70DACFC2A7146D2007F184C /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70DACFB2A7146D2007F184C /* TimelineTableViewController.swift */; }; + A70DACFE2A7146DE007F184C /* UITimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70DACFD2A7146DE007F184C /* UITimelineView.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 */; }; @@ -866,7 +868,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 = ""; }; @@ -1007,7 +1009,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 = ""; }; @@ -1189,7 +1191,7 @@ 8D55702474F279D910D2D162 /* RoomStateEventStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomStateEventStringBuilder.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 = ""; }; @@ -1255,6 +1257,8 @@ 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 = ""; }; + A70DACFB2A7146D2007F184C /* TimelineTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = ""; }; + A70DACFD2A7146DE007F184C /* UITimelineView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITimelineView.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 = ""; }; @@ -1299,7 +1303,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 = ""; }; @@ -1380,7 +1384,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 = ""; }; D0140615D2232612C813FD6C /* EncryptedHistoryRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedHistoryRoomTimelineItem.swift; sourceTree = ""; }; D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = ""; }; @@ -1454,7 +1458,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 = ""; }; @@ -1468,7 +1472,7 @@ F174A5627CDB3CAF280D1880 /* EmojiPickerScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenModels.swift; sourceTree = ""; }; F17EFA1D3D09FC2F9C5E1CB2 /* MediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProvider.swift; sourceTree = ""; }; F1B8500C152BC59445647DA8 /* UnsupportedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsupportedRoomTimelineItem.swift; sourceTree = ""; }; - F2D513D2477B57F90E98EEC0 /* portrait_test_video.mp4 */ = {isa = PBXFileReference; path = portrait_test_video.mp4; sourceTree = ""; }; + F2D513D2477B57F90E98EEC0 /* portrait_test_video.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = portrait_test_video.mp4; sourceTree = ""; }; F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixEntityRegexTests.swift; sourceTree = ""; }; F348B5F2C12F9D4F4B4D3884 /* VideoRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineItem.swift; sourceTree = ""; }; F36C0A6D59717193F49EA986 /* UserSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionTests.swift; sourceTree = ""; }; @@ -3209,6 +3213,8 @@ F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */, A2AC3C656E960E15B5905E05 /* UnsupportedRoomTimelineView.swift */, 1941C8817E6B6971BA4415F5 /* VideoRoomTimelineView.swift */, + A70DACFD2A7146DE007F184C /* UITimelineView.swift */, + A70DACFB2A7146D2007F184C /* TimelineTableViewController.swift */, ); path = Timeline; sourceTree = ""; @@ -4535,6 +4541,7 @@ 899793EFC63DF93C3E0141E7 /* RoomMemberDetailsScreenCoordinator.swift in Sources */, A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */, EAF2B3E6C6AEC4AD3A8BD454 /* RoomMemberDetailsScreenViewModel.swift in Sources */, + A70DACFE2A7146DE007F184C /* UITimelineView.swift in Sources */, 5B6E5AD224509E6C0B520D6E /* RoomMemberDetailsScreenViewModelProtocol.swift in Sources */, 6448F8D1D3CA4CD27BB4CADD /* RoomMemberProxy.swift in Sources */, 92D9088B901CEBB1A99ECA4E /* RoomMemberProxyMock.swift in Sources */, @@ -4674,6 +4681,7 @@ C4FE0E11A907C8999F92D5A8 /* TimelineStartRoomTimelineItem.swift in Sources */, 6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */, 69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */, + A70DACFC2A7146D2007F184C /* TimelineTableViewController.swift in Sources */, FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */, 500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */, 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */, diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index b62365f728..1cc20c5c44 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -34,6 +34,7 @@ final class AppSettings { case readReceiptsEnabled case hasShownWelcomeScreen case notificationSettingsEnabled + case swiftUITimelineEnabled } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -213,4 +214,7 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.notificationSettingsEnabled, defaultValue: false, storageType: .userDefaults(store)) var notificationSettingsEnabled + + @UserPreference(key: UserDefaultsKeys.swiftUITimelineEnabled, defaultValue: false, storageType: .volatile) + var swiftUITimelineEnabled } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index fff188ed96..6e5fd71bad 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -61,6 +61,7 @@ enum RoomScreenViewAction { case cancelEdit /// Mark the entire room as read - this is heavy handed as a starting point for now. case markRoomAsRead + case paginateBackwards case timelineItemMenu(itemID: TimelineItemIdentifier) case timelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction) @@ -92,6 +93,7 @@ struct RoomScreenViewState: BindableState { var isEncryptedOneToOneRoom = false var timelineViewState = TimelineViewState() // check the doc before changing this var composerMode: RoomScreenComposerMode = .default + var swiftUITimelineEnabled = false var bindings: RoomScreenViewStateBindings @@ -177,6 +179,10 @@ struct RoomMemberState { struct TimelineViewState { var canBackPaginate = true var isBackPaginating = false + + // These can be removed when we have full swiftUI and moved as @State values in the view + var scrollToBottomPublisher = PassthroughSubject() + var itemsDictionary = OrderedDictionary() var timelineIDs: [String] { @@ -186,6 +192,4 @@ struct TimelineViewState { 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 169dafcb41..ef504f0d67 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -61,11 +61,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom, bindings: .init(composerText: "", composerFocused: false, reactionsCollapsed: [:])), imageProvider: mediaProvider) - - state.timelineViewState.paginateAction = { [weak self] in - self?.paginateBackwards() - } - + setupSubscriptions() state.timelineItemMenuActionProvider = { [weak self] itemId -> TimelineItemMenuActions? in @@ -140,12 +136,18 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol Task { await handleRetrySend(itemID: itemID) } case .cancelSend(let itemID): Task { await handleCancelSend(itemID: itemID) } + case .paginateBackwards: + paginateBackwards() } } // MARK: - Private private func setupSubscriptions() { + appSettings.$swiftUITimelineEnabled + .weakAssign(to: \.state.swiftUITimelineEnabled, on: self) + .store(in: &cancellables) + timelineController.callbacks .receive(on: DispatchQueue.main) .sink { [weak self] callback in @@ -274,19 +276,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol if itemGroup.count == 1 { if let firstItem = itemGroup.first { - timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: firstItem, groupStyle: .single), + timelineItemsDictionary.updateValue(updateViewstate(item: firstItem, groupStyle: .single), forKey: firstItem.id.timelineID) } } else { for (index, item) in itemGroup.enumerated() { if index == 0 { - timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .first), + timelineItemsDictionary.updateValue(updateViewstate(item: item, groupStyle: .first), forKey: item.id.timelineID) } else if index == itemGroup.count - 1 { - timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .last), + timelineItemsDictionary.updateValue(updateViewstate(item: item, groupStyle: .last), forKey: item.id.timelineID) } else { - timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .middle), + timelineItemsDictionary.updateValue(updateViewstate(item: item, groupStyle: .middle), forKey: item.id.timelineID) } } @@ -296,6 +298,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.timelineViewState.itemsDictionary = timelineItemsDictionary } + private func updateViewstate(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewState { + if let timelineItemViewState = state.timelineViewState.itemsDictionary[item.id.timelineID] { + timelineItemViewState.groupStyle = groupStyle + timelineItemViewState.type = .init(item: item) + return timelineItemViewState + } else { + return RoomTimelineItemViewState(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/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 6882aa703d..064382255c 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -83,12 +83,48 @@ struct RoomScreen: View { } private var timeline: some View { - TimelineView(viewState: context.viewState.timelineViewState) + timelineSwitch .id(context.viewState.roomId) .environmentObject(context) .environment(\.timelineStyle, context.viewState.timelineStyle) .environment(\.readReceiptsEnabled, context.viewState.readReceiptsEnabled) } + + @ViewBuilder + private var timelineSwitch: some View { + if context.viewState.swiftUITimelineEnabled { + TimelineView(viewState: context.viewState.timelineViewState, + scrollToBottomButtonVisible: $context.scrollToBottomButtonVisible) { + context.send(viewAction: .paginateBackwards) + } + } else { + UITimelineView() + .overlay(alignment: .bottomTrailing) { + scrollToBottomButton + } + } + } + + private var scrollToBottomButton: some View { + Button { context.viewState.timelineViewState.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) + } private var messageComposer: some View { MessageComposer(text: $context.composerText, diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift new file mode 100644 index 0000000000..4185637ae2 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TimelineTableViewController.swift @@ -0,0 +1,283 @@ +// +// 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: RoomTimelineItemViewState? + + 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: UITimelineView.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: UITimelineView.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 viewState = timelineItemsDictionary[id] + cell.item = viewState + guard let viewState else { + return cell + } + cell.contentConfiguration = UIHostingConfiguration { + RoomTimelineItemView(viewState: viewState) + .id(id) + .frame(maxWidth: .infinity, alignment: .leading) + .environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu + .onAppear { + coordinator.send(viewAction: .itemAppeared(itemID: viewState.identifier)) + } + .onDisappear { + coordinator.send(viewAction: .itemDisappeared(itemID: viewState.identifier)) + } + .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/Timeline/UITimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift new file mode 100644 index 0000000000..27c223b0e4 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift @@ -0,0 +1,97 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/// A table view wrapper that displays the timeline of a room. +struct UITimelineView: UIViewControllerRepresentable { + @EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context + @Environment(\.timelineStyle) private var timelineStyle + + func makeUIViewController(context: Context) -> TimelineTableViewController { + let tableViewController = TimelineTableViewController(coordinator: context.coordinator, + timelineStyle: timelineStyle, + scrollToBottomButtonVisible: $viewModelContext.scrollToBottomButtonVisible, + scrollToBottomPublisher: viewModelContext.viewState.timelineViewState.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.timelineViewState.itemViewStates.isEmpty { + viewModelContext.send(viewAction: .paginateBackwards) + } + } + + /// 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 + } + if tableViewController.timelineItemsDictionary != context.viewState.timelineViewState.itemsDictionary { + tableViewController.timelineItemsDictionary = context.viewState.timelineViewState.itemsDictionary + } + if tableViewController.canBackPaginate != context.viewState.timelineViewState.canBackPaginate { + tableViewController.canBackPaginate = context.viewState.timelineViewState.canBackPaginate + } + if tableViewController.isBackPaginating != context.viewState.timelineViewState.isBackPaginating { + tableViewController.isBackPaginating = context.viewState.timelineViewState.isBackPaginating + } + if tableViewController.composerMode != context.viewState.composerMode { + tableViewController.composerMode = context.viewState.composerMode + } + + // Doesn't have an equatable conformance :( + tableViewController.contextMenuActionProvider = context.viewState.timelineItemMenuActionProvider + } + + func send(viewAction: RoomScreenViewAction) { + context.send(viewAction: viewAction) + } + } +} + +// MARK: - Previews + +struct UITimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + mediaProvider: MockMediaProvider(), + roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")), + appSettings: ServiceLocator.shared.settings, + analytics: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + + static var previews: some View { + NavigationStack { + RoomScreen(context: viewModel.context) + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index 13149cf6b7..5b490fe4cc 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -21,6 +21,9 @@ import SwiftUIIntrospect struct TimelineView: View { let viewState: TimelineViewState + @Binding var scrollToBottomButtonVisible: Bool + let paginationAction: () -> Void + @Environment(\.timelineStyle) private var timelineStyle private let bottomID = "RoomTimelineBottomPinIdentifier" @@ -28,8 +31,6 @@ struct TimelineView: View { @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 @@ -49,9 +50,10 @@ struct TimelineView: View { // Allows the scroll to top to work properly uiScrollView.contentOffset.y -= 1 + paginateBackwardsPublisher.send() } .scaleEffect(x: 1, y: -1) - .onReceive(scrollToBottomPublisher) { _ in + .onReceive(viewState.scrollToBottomPublisher) { _ in withElementAnimation { scrollView.scrollTo(bottomID) } @@ -59,7 +61,7 @@ struct TimelineView: View { .scrollDismissesKeyboard(.immediately) } .overlay(scrollToBottomButton, alignment: .bottomTrailing) - .animation(.elementDefault, value: viewState.itemViewStates) + .animation(.elementDefault, value: viewState.timelineIDs) .onReceive(scrollViewAdapter.didScroll) { _ in guard let scrollView = scrollViewAdapter.scrollView else { return @@ -120,7 +122,7 @@ struct TimelineView: View { private var scrollToBottomButton: some View { Button { - scrollToBottomPublisher.send() + viewState.scrollToBottomPublisher.send() } label: { Image(systemName: "chevron.down") .font(.compound.bodyLG) @@ -142,8 +144,7 @@ struct TimelineView: View { } private func paginateBackwardsIfNeeded() { - guard let paginateAction = viewState.paginateAction, - let scrollView = scrollViewAdapter.scrollView, + guard let scrollView = scrollViewAdapter.scrollView, viewState.canBackPaginate, !viewState.isBackPaginating else { return @@ -158,20 +159,20 @@ struct TimelineView: View { return } - paginateAction() + paginationAction() } } // MARK: - Previews -struct TimelineTableView_Previews: PreviewProvider { +struct TimelineView_Previews: PreviewProvider { static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, userIndicatorController: ServiceLocator.shared.userIndicatorController) - + static var previews: some View { NavigationStack { RoomScreen(context: viewModel.context) diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 1a81b139e8..f1a35d8931 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -47,6 +47,7 @@ protocol DeveloperOptionsProtocol: AnyObject { var userSuggestionsEnabled: Bool { get set } var readReceiptsEnabled: Bool { get set } var notificationSettingsEnabled: Bool { get set } + var swiftUITimelineEnabled: Bool { get set } } extension AppSettings: DeveloperOptionsProtocol { } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 186ece1f36..2a564fea64 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -31,6 +31,11 @@ struct DeveloperOptionsScreen: View { Text("Show read receipts") Text("Requires app reboot") } + + Toggle(isOn: $context.swiftUITimelineEnabled) { + Text("SwiftUI Timeline") + Text("Resets on reboot") + } } Section("Notifications") { diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift index 4aec32929a..4b874c7fb3 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift @@ -17,10 +17,12 @@ import SwiftUI struct RoomTimelineItemView: View { @EnvironmentObject private var context: RoomScreenViewModel.Context - let viewState: RoomTimelineItemViewState + @ObservedObject var viewState: RoomTimelineItemViewState var body: some View { timelineView + .animation(.elementDefault, value: viewState.groupStyle) + .animation(.elementDefault, value: viewState.type) .environmentObject(context) .environment(\.timelineGroupStyle, viewState.groupStyle) .onAppear { diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift index aa8cf50633..f65497e461 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift @@ -16,9 +16,13 @@ import Foundation -struct RoomTimelineItemViewState: Identifiable, Equatable { - let type: RoomTimelineItemType - let groupStyle: TimelineGroupStyle +final class RoomTimelineItemViewState: Identifiable, Equatable, ObservableObject { + static func == (lhs: RoomTimelineItemViewState, rhs: RoomTimelineItemViewState) -> Bool { + lhs.type == rhs.type && lhs.groupStyle == rhs.groupStyle + } + + @Published var type: RoomTimelineItemType + @Published var groupStyle: TimelineGroupStyle /// Contains all the identification info of the item, `timelineID`, `eventID` and `transactionID` var identifier: TimelineItemIdentifier { @@ -33,10 +37,13 @@ struct RoomTimelineItemViewState: Identifiable, Equatable { var isReactable: Bool { type.isReactable } -} -extension RoomTimelineItemViewState { - init(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) { + init(type: RoomTimelineItemType, groupStyle: TimelineGroupStyle) { + self.type = type + self.groupStyle = groupStyle + } + + convenience init(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) { self.init(type: .init(item: item), groupStyle: groupStyle) } }