diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index d60dc539e..02ce20a3b 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 */ @@ -98,7 +98,6 @@ 323F36D880363C473B81A9EA /* MediaProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3D31C2DA6910AA0079678A /* MediaProxyProtocol.swift */; }; 3274219F7F26A5C6C2C55630 /* FilePreviewViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F652E88106B855A2A55ADE /* FilePreviewViewModelProtocol.swift */; }; 32BA37B01B05261FCF2D4B45 /* WeakDictionaryKeyReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090CA61A835C151CEDF8F372 /* WeakDictionaryKeyReference.swift */; }; - 33665CE55037D029ED7D867E /* TimelineScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A627CE1429374617334FA5E9 /* TimelineScrollView.swift */; }; 33CAC1226DFB8B5D8447D286 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 3853B78FB8531B83936C5DA6 /* SwiftState */; }; 33D630461FC4562CC767EE9F /* FileCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B0B1226DA8DB55918B34CD /* FileCache.swift */; }; 34966D4C1C2C6D37FE3F7F50 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD2D50A7EAA4FC78417730E /* SettingsCoordinator.swift */; }; @@ -114,6 +113,7 @@ 38C76D586404C1FDED095F3A /* LoginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31B01468022EC826CB2FD2C0 /* LoginModels.swift */; }; 39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */; }; 3A64A93A651A3CB8774ADE8E /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 21C83087604B154AA30E9A8F /* SnapshotTesting */; }; + 3B0DF16FEA8481061C28A3BE /* TimelineTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 170A84E8957BF97A26E962D5 /* TimelineTableView.swift */; }; 3B770CB4DED51CC362C66D47 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4990FDBDA96B88E214F92F48 /* SettingsModels.swift */; }; 3C549A0BF39F8A854D45D9FD /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 4278261E147DB2DE5CFB7FC5 /* PostHog */; }; 3C73442084BF8A6939F0F80B /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */; }; @@ -139,11 +139,9 @@ 49F2E7DD8CAACE09CEECE3E6 /* SeparatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */; }; 4A2E0DBB63919AC8309B6D40 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A191D3FDB995309C7E2DE7D /* SettingsViewModel.swift */; }; 4C3365818DE1CEAEDF590FD3 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C888BCD78E2A55DCE364F160 /* MediaProviderProtocol.swift */; }; - 4D970CB606276717B43E2332 /* TimelineItemList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */; }; 4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; }; 4FC1EFE4968A259CBBACFAFB /* RoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */; }; 4FF90E2242DBD596E1ED2E27 /* AppCoordinatorStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077D7C3BE199B6E5DDEC07EC /* AppCoordinatorStateMachine.swift */; }; - 500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 874A1842477895F199567BD7 /* TimelineView.swift */; }; 50C90117FE25390BFBD40173 /* RustTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D4F49FABA056DEEEB3400 /* RustTracing.swift */; }; 518C93DC6516D3D018DE065F /* UNNotificationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E751D7EDB6043238111D90 /* UNNotificationRequest.swift */; }; 51DB67C5B5BC68B0A6FF54D4 /* MockRoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACBDC1D28EFB7789EB467E0 /* MockRoomProxy.swift */; }; @@ -527,6 +525,7 @@ 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryDetails.swift; sourceTree = ""; }; 167521635A1CC27624FCEB7F /* ServerSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModel.swift; sourceTree = ""; }; 16DC8C5B2991724903F1FA6A /* AppIcon.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = AppIcon.pdf; sourceTree = ""; }; + 170A84E8957BF97A26E962D5 /* TimelineTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableView.swift; sourceTree = ""; }; 1715E3D7F53C0748AA50C91C /* PostHogAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsClient.swift; sourceTree = ""; }; 1734A445A58ED855B977A0A8 /* TracingConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingConfigurationTests.swift; sourceTree = ""; }; 179423E34EE846E048E49CBF /* MediaSourceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceProxy.swift; sourceTree = ""; }; @@ -715,7 +714,6 @@ 7E154FEA1E6FE964D3DF7859 /* fy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fy; path = fy.lproj/Localizable.strings; sourceTree = ""; }; 7E532D95330139D118A9BF88 /* BugReportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportViewModel.swift; sourceTree = ""; }; 7FB27E1BE894F9F9F0134372 /* FilePreviewScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewScreen.swift; sourceTree = ""; }; - 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemList.swift; sourceTree = ""; }; 8140010A796DB2C7977B6643 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; 8166F121C79C7B62BF01D508 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = pt; path = pt.lproj/Localizable.stringsdict; sourceTree = ""; }; 81B17DB1BC3B0C62AF84D230 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -725,7 +723,6 @@ 858F8D0B0D51CC41BAA18E24 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; 85CB1DDCEE53B946D09DF4F6 /* bn-BD */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "bn-BD"; path = "bn-BD.lproj/Localizable.strings"; sourceTree = ""; }; 873718F8BD17B778C5141C45 /* ta */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ta; path = ta.lproj/Localizable.strings; sourceTree = ""; }; - 874A1842477895F199567BD7 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = ""; }; 878B7C1885486FB4BE41631D /* iw */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = iw; path = iw.lproj/Localizable.stringsdict; sourceTree = ""; }; 885D8C42DD17625B5261BEFF /* MediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProvider.swift; sourceTree = ""; }; 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlignedScrollView.swift; sourceTree = ""; }; @@ -733,12 +730,12 @@ 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = ""; }; 8A9AE4967817E9608E22EB44 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; 8AC1A01C3A745BDF1D3697D3 /* SessionVerificationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreen.swift; sourceTree = ""; }; - 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; path = LICENSE; sourceTree = ""; }; + 8B9A55AC2FB0FE0AEAA3DF1F /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 8C0AA893D6F8A2F563E01BB9 /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = in; path = in.lproj/Localizable.stringsdict; sourceTree = ""; }; 8D6094DEAAEB388E1AE118C6 /* MockRoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineProvider.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; - 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; + 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = ""; }; 8ED2D2F6A137A95EA50413BE /* UserNotificationControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationControllerProtocol.swift; sourceTree = ""; }; 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomSummaryProvider.swift; sourceTree = ""; }; 8FC803282F9268D49F4ABF14 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; @@ -788,7 +785,6 @@ A4756C5A8C8649AD6C10C615 /* MockUserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserSession.swift; sourceTree = ""; }; A4B5B19A10D3F7C2BC5315DF /* VideoRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRoomTimelineItem.swift; sourceTree = ""; }; A5B0B1226DA8DB55918B34CD /* FileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = ""; }; - A627CE1429374617334FA5E9 /* TimelineScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineScrollView.swift; sourceTree = ""; }; A64F0DB78E0AC23C91AD89EF /* mk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mk; path = mk.lproj/Localizable.strings; 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 = ""; }; @@ -929,7 +925,7 @@ EBE5502760CF6CA2D7201883 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ja; path = ja.lproj/Localizable.stringsdict; 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 = ""; }; EDAA4472821985BF868CC21C /* ServerSelectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionViewModelTests.swift; sourceTree = ""; }; EDB6E40BAD4504D899FAAC9A /* TemplateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModel.swift; sourceTree = ""; }; EE8BCD14EFED23459A43FDFF /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; @@ -1628,10 +1624,8 @@ 422724361B6555364C43281E /* RoomHeaderView.swift */, 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */, B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */, - 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */, - A627CE1429374617334FA5E9 /* TimelineScrollView.swift */, 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */, - 874A1842477895F199567BD7 /* TimelineView.swift */, + 170A84E8957BF97A26E962D5 /* TimelineTableView.swift */, A312471EA62EFB0FD94E60DC /* Style */, CCD48459CA34A1928EC7A26A /* Supplementary */, B7D3886505ECC85A06DA8258 /* Timeline */, @@ -1913,14 +1907,6 @@ path = NSE; sourceTree = ""; }; - B3E78735F63FA93FAAAF700A /* MockUserNotificationController.swift~refs */ = { - isa = PBXGroup; - children = ( - F798CDE87F83A94B8BC2E18A /* remotes */, - ); - path = "MockUserNotificationController.swift~refs"; - sourceTree = ""; - }; B442FCF47E0A6F28D7D50A4D /* FilePreview */ = { isa = PBXGroup; children = ( @@ -2008,13 +1994,6 @@ path = UITests; sourceTree = ""; }; - C5A8A8B1C16BBFEA4B9D5988 /* origin */ = { - isa = PBXGroup; - children = ( - ); - path = origin; - sourceTree = ""; - }; CA555F7C7CA382ACACF0D82B /* Keychain */ = { isa = PBXGroup; children = ( @@ -2072,13 +2051,6 @@ path = Vendor; sourceTree = ""; }; - D14F980E72A97D6169A499E8 /* ImageViewer */ = { - isa = PBXGroup; - children = ( - ); - path = ImageViewer; - sourceTree = ""; - }; D958761758AA1110476DE6A3 /* SessionVerification */ = { isa = PBXGroup; children = ( @@ -2102,7 +2074,6 @@ CD80F22830C2360F3F39DDCE /* UserNotificationModalView.swift */, 649759084B0C9FE1F8DF8D17 /* UserNotificationPresenter.swift */, F31A4E5941ACBA4BB9FEF94C /* UserNotificationToastView.swift */, - B3E78735F63FA93FAAAF700A /* MockUserNotificationController.swift~refs */, ); path = UserNotifications; sourceTree = ""; @@ -2143,7 +2114,6 @@ 4009BE2E791C16AC6EE39A7E /* BugReport */, B442FCF47E0A6F28D7D50A4D /* FilePreview */, B53CA9BECD3F97805E1432D0 /* HomeScreen */, - D14F980E72A97D6169A499E8 /* ImageViewer */, 3F38EAC92E2281990E65DAF2 /* OnboardingScreen */, A448A3A8F764174C60CD0CA1 /* Other */, 679E9837ECA8D6776079D16E /* RoomScreen */, @@ -2201,14 +2171,6 @@ path = Background; sourceTree = ""; }; - F798CDE87F83A94B8BC2E18A /* remotes */ = { - isa = PBXGroup; - children = ( - C5A8A8B1C16BBFEA4B9D5988 /* origin */, - ); - path = remotes; - sourceTree = ""; - }; FCDF06BDB123505F0334B4F9 /* Timeline */ = { isa = PBXGroup; children = ( @@ -2933,15 +2895,13 @@ 5D2AF8C0DF872E7985F8FE54 /* TimelineDeliveryStatusView.swift in Sources */, 157E5FDDF419C0B2CA7E2C28 /* TimelineItemBubbledStylerView.swift in Sources */, 01CB8ACFA5E143E89C168CA8 /* TimelineItemContextMenu.swift in Sources */, - 4D970CB606276717B43E2332 /* TimelineItemList.swift in Sources */, F508683B76EF7B23BB2CBD6D /* TimelineItemPlainStylerView.swift in Sources */, 440123E29E2F9B001A775BBE /* TimelineItemProxy.swift in Sources */, 9B582B3EEFEA615D4A6FBF1A /* TimelineReactionsView.swift in Sources */, - 33665CE55037D029ED7D867E /* TimelineScrollView.swift in Sources */, ABF3FAB234AD3565B214309B /* TimelineSenderAvatarView.swift in Sources */, 69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */, FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */, - 500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */, + 3B0DF16FEA8481061C28A3BE /* TimelineTableView.swift in Sources */, 7732B2F635626BE1C1CD92A4 /* UIActivityViewControllerWrapper.swift in Sources */, 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */, A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 2ac82259d..586fba321 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -16,6 +16,7 @@ "room_timeline_replying_to" = "Replying to %@"; "room_timeline_editing" = "Editing"; +"room_timeline_syncing" = "Syncing"; "session_verification_banner_title" = "Help keep your messages secure"; "session_verification_banner_message" = "Looks like you’re using a new device. Verify its you."; diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 8b413cb15..8565ec9f2 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -38,6 +38,8 @@ extension ElementL10n { public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description") /// Plain Timeline public static let roomTimelineStylePlainLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_plain_long_description") + /// Syncing + public static let roomTimelineSyncing = ElementL10n.tr("Untranslated", "room_timeline_syncing") /// Would you like to submit a bug report? public static let screenshotDetectedMessage = ElementL10n.tr("Untranslated", "screenshot_detected_message") /// You took a screenshot diff --git a/ElementX/Sources/Other/ScrollViewAdapter.swift b/ElementX/Sources/Other/ScrollViewAdapter.swift index 4df8a2cf1..996023d3a 100644 --- a/ElementX/Sources/Other/ScrollViewAdapter.swift +++ b/ElementX/Sources/Other/ScrollViewAdapter.swift @@ -25,30 +25,29 @@ class ScrollViewAdapter: NSObject, UIScrollViewDelegate { } } - var isScrolling = PassthroughSubject() + var isScrolling = CurrentValueSubject(false) - private func update() { - guard let scrollView else { return } + private func update(_ scrollView: UIScrollView) { isScrolling.send(scrollView.isDragging || scrollView.isDecelerating) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - update() + update(scrollView) } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - update() + update(scrollView) } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - update() + update(scrollView) } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - update() + update(scrollView) } func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { - update() + update(scrollView) } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 2795e437e..27510235c 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -14,7 +14,7 @@ // limitations under the License. // -import Foundation +import Combine import UIKit enum RoomScreenViewModelAction { @@ -29,7 +29,7 @@ enum RoomScreenComposerMode: Equatable { } enum RoomScreenViewAction { - case loadPreviousPage + case paginateBackwards case itemAppeared(id: String) case itemDisappeared(id: String) case itemTapped(id: String) @@ -56,12 +56,22 @@ struct RoomScreenViewState: BindableState { var sendButtonDisabled: Bool { bindings.composerText.count == 0 } + + let scrollToBottomPublisher = PassthroughSubject() + + /// Returns the opacity that the supplied timeline item's cell should be. + func opacity(for item: RoomTimelineViewProvider) -> CGFloat { + guard case let .reply(selectedItemID, _) = composerMode else { return 1.0 } + return selectedItemID == item.id ? 1.0 : 0.5 + } } struct RoomScreenViewStateBindings { var composerText: String var composerFocused: Bool + var scrollToBottomButtonVisible = false + /// Information describing the currently displayed alert. var alertInfo: AlertInfo? diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 8ec0a4ad6..b66af880e 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Combine import SwiftUI typealias RoomScreenViewModelType = StateStoreViewModel @@ -21,6 +22,7 @@ typealias RoomScreenViewModelType = StateStoreViewModel - - var body: some View { - ScrollViewReader { scrollView in - TimelineScrollView(visibleEdges: $visibleEdges) { - // The scroll view already contains a VStack so simply provide the content to fill it. - - ProgressView() - .frame(maxWidth: .infinity) - .opacity(context.viewState.isBackPaginating ? 1.0 : 0.0) - .animation(.elementDefault, value: context.viewState.isBackPaginating) - - ForEach(isRunningPreviews ? context.viewState.items : timelineItems) { item in - item - .contextMenu { - context.viewState.contextMenuBuilder?(item.id) - .id(item.id) - } - .opacity(opacityForItem(item)) - .padding(settings.timelineStyle.rowInsets) - .onAppear { - context.send(viewAction: .itemAppeared(id: item.id)) - } - .onDisappear { - context.send(viewAction: .itemDisappeared(id: item.id)) - } - .environment(\.openURL, OpenURLAction { url in - context.send(viewAction: .linkClicked(url: url)) - return .systemAction - }) - .onTapGesture { - context.send(viewAction: .itemTapped(id: item.id)) - } - } - } - .onChange(of: visibleEdges) { edges in - cachedVisibleEdges = edges - // Paginate when the top becomes visible - guard edges.contains(.top) else { return } - requestBackPagination() - } - .onChange(of: context.viewState.isBackPaginating) { isBackPaginating in - guard !isBackPaginating else { return } - - // Repeat the pagination if the top edge is still visible. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - guard visibleEdges.contains(.top) else { return } - requestBackPagination() - } - } - .onChange(of: pinnedItem) { item in - guard let item else { return } - - if item.animated { - withAnimation(Animation.elementDefault) { - scrollView.scrollTo(item.id, anchor: item.anchor) - } - } else { - scrollView.scrollTo(item.id, anchor: item.anchor) - } - - pinnedItem = nil - } - } - .scrollDismissesKeyboard(.immediately) - .background(ViewFrameReader(frame: $viewFrame)) - .timelineStyle(settings.timelineStyle) - .onAppear { - timelineItems = context.viewState.items - } - .onReceive(scrollToBottomPublisher) { - scrollToBottom(animated: true) - } - .onChange(of: context.viewState.items) { items in - guard - !context.viewState.items.isEmpty, - context.viewState.items.count != timelineItems.count - else { - // Update the items, but don't worry about scrolling if the count is unchanged. - timelineItems = items - return - } - - // Pin to the bottom if empty - if timelineItems.isEmpty { - if let lastItem = context.viewState.items.last { - let pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: false) - timelineItems = context.viewState.items - self.pinnedItem = pinnedItem - } - - return - } - - // Pin to the new bottom if visible - if visibleEdges.contains(.bottom), let newLastItem = context.viewState.items.last { - let pinnedItem = PinnedItem(id: newLastItem.id, anchor: .bottom, animated: false) - timelineItems = context.viewState.items - self.pinnedItem = pinnedItem - - return - } - - // Pin to the old topmost visible - if visibleEdges.contains(.top), let currentFirstItem = timelineItems.first { - let pinnedItem = PinnedItem(id: currentFirstItem.id, anchor: .top, animated: false) - timelineItems = context.viewState.items - self.pinnedItem = pinnedItem - - return - } - - // Otherwise just update the items - timelineItems = context.viewState.items - } - .onChange(of: viewFrame) { _ in - // Use the cached version as visibleEdges will already have changed - // (but its onChange handler is yet to be called - possible race condition?) - guard cachedVisibleEdges.contains(.bottom) else { return } - - // Pin the timeline to the bottom if was there on the frame change - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - scrollToBottom(animated: true) - } - } - } - - // MARK: - Private - - private func scrollToBottom(animated: Bool = false) { - if let lastItem = timelineItems.last { - pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: animated) - } - } - - private func requestBackPagination() { - guard !context.viewState.isBackPaginating else { - return - } - context.send(viewAction: .loadPreviousPage) - } - - private func opacityForItem(_ item: RoomTimelineViewProvider) -> Double { - guard case let .reply(selectedItemId, _) = context.viewState.composerMode else { - return 1.0 - } - - return selectedItemId == item.id ? 1.0 : 0.5 - } - - private var isRunningPreviews: Bool { - #if DEBUG - return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" - #else - return false - #endif - } -} - -private struct PinnedItem: Equatable { - let id: String - let anchor: UnitPoint - let animated: Bool -} - -struct TimelineItemList_Previews: PreviewProvider { - static var previews: some View { - let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), - timelineViewFactory: RoomTimelineViewFactory(), - mediaProvider: MockMediaProvider(), - roomName: nil) - - TimelineItemList(visibleEdges: .constant([]), scrollToBottomPublisher: PassthroughSubject()) - .environmentObject(viewModel.context) - } -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineScrollView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineScrollView.swift deleted file mode 100644 index 29f441476..000000000 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineScrollView.swift +++ /dev/null @@ -1,76 +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 SwiftUI - -struct VisibleEdgesKey: PreferenceKey { - static var defaultValue: [VerticalEdge] = [] - - static func reduce(value: inout [VerticalEdge], nextValue: () -> [VerticalEdge]) { - value = nextValue() - } -} - -/// A SwiftUI scroll view with the following customisations for a room timeline -/// - The content is laid out starting at the bottom. -/// - Top and bottom edge visibility detection for triggering other behaviours. -struct TimelineScrollView: View { - @Binding var visibleEdges: [VerticalEdge] - - @ViewBuilder var content: () -> Content - - /// A small threshold added to the edge detection to allow a bit of leniency. - private let edgeDetectionThreshold: CGFloat = 15 - - var body: some View { - GeometryReader { scrollViewGeometry in - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Spacer() - content() - } - .frame(minHeight: scrollViewGeometry.size.height) - .background { - GeometryReader { contentGeometry in - Color.clear - .preference(key: VisibleEdgesKey.self, - value: visibleEdges(of: contentGeometry, in: scrollViewGeometry)) - } - .onPreferenceChange(VisibleEdgesKey.self) { - visibleEdges = $0 - } - } - } - } - } - - func visibleEdges(of contentGeometry: GeometryProxy, in scrollViewGeometry: GeometryProxy) -> [VerticalEdge] { - let frame = contentGeometry.frame(in: .global) - let isTopVisible = scrollViewGeometry.frame(in: .global).contains(CGPoint(x: frame.midX, y: frame.minY + edgeDetectionThreshold)) - let isBottomVisible = scrollViewGeometry.frame(in: .global).contains(CGPoint(x: frame.midX, y: frame.maxY - edgeDetectionThreshold)) - - switch (isTopVisible, isBottomVisible) { - case (false, false): - return [] - case (true, false): - return [.top] - case (false, true): - return [.bottom] - case (true, true): - return [.top, .bottom] - } - } -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift new file mode 100644 index 000000000..edb35a8b8 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableView.swift @@ -0,0 +1,456 @@ +// +// 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 + +/// A table view cell that displays a timeline item in a room. The cell is intended +/// to be configured to display a SwiftUI view and not use any UIKit. +class TimelineItemCell: UITableViewCell { + static let reuseIdentifier = "TimelineItemCell" + + var item: RoomTimelineViewProvider? + + override func prepareForReuse() { + item = nil + } +} + +/// A table view wrapper that displays the timeline of a room. +struct TimelineTableView: UIViewRepresentable { + @EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context + @Environment(\.timelineStyle) private var timelineStyle + + func makeUIView(context: Context) -> UITableView { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier) + tableView.separatorStyle = .none + tableView.allowsSelection = false + tableView.keyboardDismissMode = .onDrag + context.coordinator.tableView = tableView + viewModelContext.send(viewAction: .paginateBackwards) + return tableView + } + + func updateUIView(_ uiView: UITableView, context: Context) { + context.coordinator.update() + + if context.coordinator.timelineStyle != timelineStyle { + context.coordinator.timelineStyle = timelineStyle + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(viewModelContext: viewModelContext) + } + + // MARK: - Coordinator + + @MainActor + class Coordinator: NSObject { + let viewModelContext: RoomScreenViewModel.Context + + var tableView: UITableView? { + didSet { + registerFrameObserver() + configureDataSource() + } + } + + var timelineStyle: TimelineStyle = .bubbles + var timelineItems: [RoomTimelineViewProvider] = [] { + didSet { + guard !scrollAdapter.isScrolling.value else { + // Delay updating until scrolling has stopped as programatic + // changes to the scroll position kills any inertia. + hasPendingUpdates = true + return + } + + applySnapshot() + } + } + + /// The mode of the message composer. This is used to render selected + /// items in the timeline when replying, editing etc. + var composerMode: RoomScreenComposerMode = .default { + didSet { + // Reload the visible items in order to update their opacity. + // Applying a snapshot won't work in this instance as the items don't change. + reloadVisibleItems() + } + } + + /// 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(()) + } + } + + /// The table's diffable data source. + private var dataSource: UITableViewDiffableDataSource? + private var cancellables: Set = [] + + /// The scroll view adapter used to detect whether scrolling is in progress. + private let scrollAdapter = ScrollViewAdapter() + /// 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 ``timelineItems`` value should be applied when scrolling stops. + private var hasPendingUpdates = false + /// The observation token used to handle frame changes. + private var frameObserverToken: NSKeyValueObservation? + /// Yucky hack to fix some layouts where the scroll view doesn't make it to the bottom on keyboard appearance. + var keyboardWillShowLayout: LayoutDescriptor? + + init(viewModelContext: RoomScreenViewModel.Context) { + self.viewModelContext = viewModelContext + super.init() + + viewModelContext.viewState.scrollToBottomPublisher + .sink { [weak self] _ in + self?.scrollToBottom(animated: true) + } + .store(in: &cancellables) + + scrollAdapter.isScrolling + .sink { [weak self] isScrolling in + guard !isScrolling, let self, self.hasPendingUpdates else { return } + // When scrolling has stopped, apply any pending updates. + self.applySnapshot() + self.hasPendingUpdates = false + self.paginateBackwardsPublisher.send(()) + } + .store(in: &cancellables) + + paginateBackwardsPublisher + .collect(.byTime(DispatchQueue.main, 0.1)) + .sink { [weak self] _ in + self?.paginateBackwardsIfNeeded() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) + .sink { [weak self] _ in + guard let self else { return } + self.keyboardWillShowLayout = self.layout() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification) + .sink { [weak self] _ in + guard let self, let layout = self.keyboardWillShowLayout, layout.isBottomVisible else { return } + self.scrollToBottom(animated: false) // Force the bottom to be visible as some timelines misbehave. + } + .store(in: &cancellables) + } + + /// Configures a diffable data source for the timeline's table view. + private func configureDataSource() { + guard let tableView else { return } + + dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, timelineItem 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 viewModelContext = self.viewModelContext + + cell.item = timelineItem + cell.contentConfiguration = UIHostingConfiguration { + timelineItem + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(viewModelContext.viewState.opacity(for: timelineItem)) + .contextMenu { + viewModelContext.viewState.contextMenuBuilder?(timelineItem.id) + } + .onAppear { + viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id)) + } + .onDisappear { + viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id)) + } + .environment(\.openURL, OpenURLAction { url in + viewModelContext.send(viewAction: .linkClicked(url: url)) + return .systemAction + }) + .onTapGesture { + viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id)) + } + } + .margins(.all, self.timelineStyle.rowInsets) + .minSize(height: 1) + + return cell + } + + tableView.delegate = self + } + + /// Adds an observer on the frame of the table view in order to keep the + /// last item visible when the keyboard is shown or the window resizes. + private func registerFrameObserver() { + // Remove the existing observer if necessary + frameObserverToken?.invalidate() + + frameObserverToken = tableView?.observe(\.frame, options: .new) { [weak self] _, _ in + self?.handleFrameChange() + } + } + + /// Updates the table's layout if necessary after the frame changed. + private nonisolated func handleFrameChange() { + Task { @MainActor in + guard self.composerMode == .default else { return } + + // The table view is yet to update its layout so layout() returns a + // description of the timeline before the frame change occurs. + let previousLayout = self.layout() + if previousLayout.isBottomVisible { + self.scrollToBottom(animated: false) + } + } + } + + /// Updates the table view's internal state from the view model's context. + func update() { + if timelineItems != viewModelContext.viewState.items { + timelineItems = viewModelContext.viewState.items + } + if isBackPaginating != viewModelContext.viewState.isBackPaginating { + isBackPaginating = viewModelContext.viewState.isBackPaginating + } + if composerMode != viewModelContext.viewState.composerMode { + composerMode = viewModelContext.viewState.composerMode + } + } + + /// 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 } + + let previousLayout = layout() + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(timelineItems) + dataSource.apply(snapshot, animatingDifferences: false) + + updateTopPadding() + + guard snapshot.numberOfItems != previousLayout.numberOfItems else { return } + + if previousLayout.isBottomVisible { + scrollToBottom(animated: false) + } else if let pinnedItem = previousLayout.pinnedItem { + restoreScrollPosition(using: pinnedItem, and: snapshot) + } + } + + /// Reloads all of the visible timeline items. + /// + /// This only needs to be called when some state internal to this table view changes that + /// will affect the appearance of those items. Any updates to the items themselves should + /// use ``applySnapshot()`` which handles everything in the diffable data source. + private func reloadVisibleItems() { + guard let tableView, let visibleIndexPaths = tableView.indexPathsForVisibleRows, let dataSource else { return } + var snapshot = dataSource.snapshot() + snapshot.reloadItems(visibleIndexPaths.compactMap { dataSource.itemIdentifier(for: $0) }) + dataSource.apply(snapshot) + } + + /// Returns a description of the current layout in order to update the + /// scroll position after adding/updating items to the timeline. + private func layout() -> LayoutDescriptor { + guard let tableView, let dataSource else { return LayoutDescriptor() } + + let snapshot = dataSource.snapshot() + var layout = LayoutDescriptor(numberOfItems: snapshot.numberOfItems) + + guard !snapshot.itemIdentifiers.isEmpty else { + layout.isBottomVisible = true + return layout + } + + guard let bottomItemIndexPath = tableView.indexPathsForVisibleRows?.last, + let bottomItem = dataSource.itemIdentifier(for: bottomItemIndexPath) + else { return layout } + + let bottomCellFrame = tableView.cellFrame(for: bottomItem) + layout.pinnedItem = PinnedItem(id: bottomItem.id, position: .bottom, frame: bottomCellFrame) + layout.isBottomVisible = bottomItem == snapshot.itemIdentifiers.last + + return layout + } + + /// Updates the additional padding added to the top of the table (via a header) + /// in order to fill the timeline from the bottom of the view upwards. + private func updateTopPadding() { + guard let tableView else { return } + + let contentHeight = tableView.contentSize.height - (tableView.tableHeaderView?.frame.height ?? 0) + let height = tableView.visibleSize.height - contentHeight + + if height > 0 { + let frame = CGRect(origin: .zero, size: CGSize(width: tableView.contentSize.width, height: height)) + tableView.tableHeaderView = UIView(frame: frame) // Updating an existing view's height doesn't move the cells. + } else { + tableView.tableHeaderView = nil + } + } + + /// Whether or not the bottom of the scroll view is visible (with some small tolerance added). + private func isAtBottom(of scrollView: UIScrollView) -> Bool { + scrollView.contentOffset.y < (scrollView.contentSize.height - scrollView.visibleSize.height - 15) + } + + /// Scrolls to the bottom of the timeline. + private func scrollToBottom(animated: Bool) { + guard let lastItem = timelineItems.last, + let lastIndexPath = dataSource?.indexPath(for: lastItem) + else { return } + + tableView?.scrollToRow(at: lastIndexPath, at: .bottom, animated: animated) + } + + /// Restores the position of the timeline using the supplied item and snapshot. + private func restoreScrollPosition(using pinnedItem: PinnedItem, and snapshot: NSDiffableDataSourceSnapshot) { + guard let tableView, + let item = snapshot.itemIdentifiers.first(where: { $0.id == pinnedItem.id }), + let indexPath = dataSource?.indexPath(for: item) + else { return } + + // Scroll the item into view. + tableView.scrollToRow(at: indexPath, at: pinnedItem.position, animated: false) + + guard let oldFrame = pinnedItem.frame, let newFrame = tableView.cellFrame(for: item) else { return } + + // Remove any unwanted offset that was added by scrollToRow. + let deltaY = newFrame.maxY - oldFrame.maxY + if deltaY != 0 { + tableView.contentOffset.y += deltaY + } + } + + /// 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 let tableView, + !isBackPaginating, + !hasPendingUpdates, + tableView.contentOffset.y < tableView.visibleSize.height * 2.0 + else { return } + + viewModelContext.send(viewAction: .paginateBackwards) + } + } +} + +// MARK: - UITableViewDelegate + +extension TimelineTableView.Coordinator: UITableViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let isAtBottom = isAtBottom(of: scrollView) + + if !viewModelContext.scrollToBottomButtonVisible, isAtBottom { + DispatchQueue.main.async { self.viewModelContext.scrollToBottomButtonVisible = true } + } else if viewModelContext.scrollToBottomButtonVisible, !isAtBottom { + DispatchQueue.main.async { self.viewModelContext.scrollToBottomButtonVisible = false } + } + + paginateBackwardsPublisher.send(()) + } + + // MARK: - ScrollViewAdapter + + // Required delegate methods are forwarded to the adapter so others can be implemented. + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollAdapter.scrollViewWillBeginDragging(scrollView) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollAdapter.scrollViewDidEndDragging(scrollView, willDecelerate: decelerate) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + scrollAdapter.scrollViewDidEndScrollingAnimation(scrollView) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + scrollAdapter.scrollViewDidEndDecelerating(scrollView) + } + + func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { + scrollAdapter.scrollViewDidScrollToTop(scrollView) + } +} + +// MARK: - Layout Types + +extension TimelineTableView.Coordinator { + /// The sections of the table view used in the diffable data source. + enum TimelineSection { case main } + + /// A description of the timeline's layout. + struct LayoutDescriptor { + var numberOfItems = 0 + var pinnedItem: PinnedItem? + var isBottomVisible = false + } + + /// An item that should have its position pinned after updates. + struct PinnedItem { + let id: String + let position: UITableView.ScrollPosition + let frame: CGRect? + } +} + +// MARK: - Cell Layout + +private extension UITableView { + /// Returns the frame of the cell for a particular timeline item. + func cellFrame(for item: RoomTimelineViewProvider) -> CGRect? { + guard let timelineCell = visibleCells.last(where: { ($0 as? TimelineItemCell)?.item == item }) else { + return nil + } + + return convert(timelineCell.frame, to: superview) + } +} + +// MARK: - Previews + +struct TimelineTableView_Previews: PreviewProvider { + static var previews: some View { + let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + timelineViewFactory: RoomTimelineViewFactory(), + mediaProvider: MockMediaProvider(), + roomName: "Preview room") + + NavigationView { + RoomScreen(context: viewModel.context) + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift deleted file mode 100644 index 280a0420f..000000000 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ /dev/null @@ -1,60 +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 Foundation -import SwiftUI - -import Introspect - -struct TimelineView: View { - @State private var visibleEdges: [VerticalEdge] = [] - @State private var scrollToBottomPublisher = PassthroughSubject() - @State private var scrollToBottomButtonVisible = false - - var body: some View { - ZStack(alignment: .bottomTrailing) { - TimelineItemList(visibleEdges: $visibleEdges, scrollToBottomPublisher: scrollToBottomPublisher) - scrollToBottomButton - } - } - - @ViewBuilder - private var scrollToBottomButton: some View { - Button { scrollToBottomPublisher.send(()) } label: { - Image(uiImage: Asset.Images.timelineScrollToBottom.image) - .shadow(radius: 2.0) - .padding() - } - .onChange(of: visibleEdges) { edges in - scrollToBottomButtonVisible = !edges.contains(.bottom) - } - .opacity(scrollToBottomButtonVisible ? 1.0 : 0.0) - .animation(.elementDefault, value: scrollToBottomButtonVisible) - } -} - -struct TimelineView_Previews: PreviewProvider { - static var previews: some View { - let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), - timelineViewFactory: RoomTimelineViewFactory(), - mediaProvider: MockMediaProvider(), - roomName: nil) - - TimelineView() - .environmentObject(viewModel.context) - } -} diff --git a/ElementX/Sources/Services/Media/MediaSourceProxy.swift b/ElementX/Sources/Services/Media/MediaSourceProxy.swift index 2f4c60981..c675873a2 100644 --- a/ElementX/Sources/Services/Media/MediaSourceProxy.swift +++ b/ElementX/Sources/Services/Media/MediaSourceProxy.swift @@ -17,7 +17,7 @@ import Foundation import MatrixRustSDK -struct MediaSourceProxy: Equatable { +struct MediaSourceProxy: Hashable { let underlyingSource: MediaSource init(source: MediaSource) { @@ -32,9 +32,16 @@ struct MediaSourceProxy: Equatable { underlyingSource.url() } - // MARK: - Equatable +} + +// MARK: - Hashable + +extension MediaSource: Hashable { + public static func == (lhs: MediaSource, rhs: MediaSource) -> Bool { + lhs.url() == rhs.url() + } - static func == (lhs: MediaSourceProxy, rhs: MediaSourceProxy) -> Bool { - lhs.url == rhs.url + public func hash(into hasher: inout Hasher) { + hasher.combine(url()) } } diff --git a/ElementX/Sources/Services/Timeline/TimeLineItemContent/AggregratedReaction.swift b/ElementX/Sources/Services/Timeline/TimeLineItemContent/AggregratedReaction.swift index 1cb39ebd7..b9767221e 100644 --- a/ElementX/Sources/Services/Timeline/TimeLineItemContent/AggregratedReaction.swift +++ b/ElementX/Sources/Services/Timeline/TimeLineItemContent/AggregratedReaction.swift @@ -17,7 +17,7 @@ import Foundation /// Represents all reactions of the same type for a single event. -struct AggregatedReaction: Equatable, Hashable { +struct AggregatedReaction: Hashable { /// The reaction that was sent. let key: String /// The number of times this reactions was sent. diff --git a/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift index 1f8385045..032dae273 100644 --- a/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift @@ -24,7 +24,7 @@ protocol MessageContentProtocol: RoomMessageEventContentProtocol { } /// The delivery status for the item. -enum MessageTimelineItemDeliveryStatus: Equatable { +enum MessageTimelineItemDeliveryStatus: Hashable { case unknown case sending case sent(elapsedTime: TimeInterval) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index 5c4d1931e..8fb375dc1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -enum TimelineItemInGroupState { +enum TimelineItemInGroupState: Hashable { case single case beginning case middle diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift index f3aaf56d2..23a7ede37 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String var attributedComponents: [AttributedStringBuilderComponent]? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift index 0853a31e1..0e9cc5853 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift @@ -16,8 +16,8 @@ import UIKit -struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { - enum EncryptionType: Equatable { +struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { + enum EncryptionType: Hashable { case megolmV1AesSha2(sessionId: String) case olmV1Curve25519AesSha2(senderKey: String) case unknown diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/FileRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/FileRoomTimelineItem.swift index 35f9c773e..278daf8b9 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/FileRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/FileRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String let timestamp: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift index edb5a35a6..2cc790e57 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String let timestamp: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift index 560c084e7..a94b15f7a 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String var attributedComponents: [AttributedStringBuilderComponent]? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift index 93d1ae557..7a0516b5d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String let timestamp: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/RoomTimelineItemProperties.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/RoomTimelineItemProperties.swift index f7a5092bd..33c237225 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/RoomTimelineItemProperties.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/RoomTimelineItemProperties.swift @@ -17,7 +17,7 @@ import Foundation /// Properties of a matrix event that are common between all timeline items. -struct RoomTimelineItemProperties: Equatable { +struct RoomTimelineItemProperties: Hashable { /// Whether the item has been edited. var isEdited = false /// The aggregated reactions that have been sent for this item. diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/SeparatorRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/SeparatorRoomTimelineItem.swift index 7d0567a97..81458ee29 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/SeparatorRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/SeparatorRoomTimelineItem.swift @@ -16,7 +16,7 @@ import Foundation -struct SeparatorRoomTimelineItem: DecorationTimelineItemProtocol, Identifiable, Equatable { +struct SeparatorRoomTimelineItem: DecorationTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift index 28895958d..3acff832d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String var attributedComponents: [AttributedStringBuilderComponent]? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift index 4577e768d..f9c0d7c06 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift @@ -17,7 +17,7 @@ import Foundation import UIKit -struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable { +struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String let text: String let timestamp: String diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift index 5a3aa349d..011a23d9d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift @@ -17,7 +17,7 @@ import Foundation import SwiftUI -enum RoomTimelineViewProvider: Identifiable, Equatable { +enum RoomTimelineViewProvider: Identifiable, Hashable { case text(TextRoomTimelineItem) case separator(SeparatorRoomTimelineItem) case image(ImageRoomTimelineItem) diff --git a/changelog.d/pr-349.change b/changelog.d/pr-349.change new file mode 100644 index 000000000..7139edc4b --- /dev/null +++ b/changelog.d/pr-349.change @@ -0,0 +1 @@ +Re-write the timeline view to be backed by a UITableView to fix scroll glitches. \ No newline at end of file