mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:02:10 +00:00
SwiftUI timeline refactor + TimelineViewState decoupling (#1392)
* swiftUI conversion + scroll to bottom * remove unused * back pagination implemented, however when a lot of elements are in the scroll the perfomance diminishes not sure what may be causing it * scroll adapter solution * way better * works but height animation in cells are broken * code improvement * everything implemented * rebase fix * fix * doc * fix test compilation * code improvement and animation improvement * code improvement * code improvements * tests updated. * pr comments * better identifiers * fix * some PR comments * pr comments
This commit is contained in:
@@ -86,7 +86,6 @@
|
|||||||
206F0DBAB6AF042CA1FF2C0D /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */; };
|
206F0DBAB6AF042CA1FF2C0D /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */; };
|
||||||
208C19811613F9A10F8A7B75 /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; };
|
208C19811613F9A10F8A7B75 /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; };
|
||||||
2185C1F6724C78FFF355D6FA /* WelcomeScreenScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB10FA6570DD08B3966C159 /* WelcomeScreenScreenUITests.swift */; };
|
2185C1F6724C78FFF355D6FA /* WelcomeScreenScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB10FA6570DD08B3966C159 /* WelcomeScreenScreenUITests.swift */; };
|
||||||
2198B4458AFF69102BBCC370 /* RoomTimelineItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7D3C686C214470F4911618 /* RoomTimelineItemViewModel.swift */; };
|
|
||||||
21BF2B7CEDFE3CA67C5355AD /* test_image.png in Resources */ = {isa = PBXBuildFile; fileRef = C733D11B421CFE3A657EF230 /* test_image.png */; };
|
21BF2B7CEDFE3CA67C5355AD /* test_image.png in Resources */ = {isa = PBXBuildFile; fileRef = C733D11B421CFE3A657EF230 /* test_image.png */; };
|
||||||
22882C710BC99EC34A5024A0 /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; };
|
22882C710BC99EC34A5024A0 /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; };
|
||||||
2352C541AF857241489756FF /* MockRoomSummaryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */; };
|
2352C541AF857241489756FF /* MockRoomSummaryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */; };
|
||||||
@@ -172,6 +171,7 @@
|
|||||||
40B79D20A873620F7F128A2C /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FA991289149D31F4286747 /* UserPreference.swift */; };
|
40B79D20A873620F7F128A2C /* UserPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FA991289149D31F4286747 /* UserPreference.swift */; };
|
||||||
414F50CFCFEEE2611127DCFB /* RestorationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3558A15CFB934F9229301527 /* RestorationToken.swift */; };
|
414F50CFCFEEE2611127DCFB /* RestorationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3558A15CFB934F9229301527 /* RestorationToken.swift */; };
|
||||||
4166A7DD2A4E2EFF0EB9369B /* FormRowLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1897720266C036471AD9D1B /* FormRowLabelStyle.swift */; };
|
4166A7DD2A4E2EFF0EB9369B /* FormRowLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1897720266C036471AD9D1B /* FormRowLabelStyle.swift */; };
|
||||||
|
41CE5E1289C8768FC5B6490C /* RoomTimelineItemViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */; };
|
||||||
41DFDD212D1BE57CA50D783B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 0DD568A494247444A4B56031 /* Kingfisher */; };
|
41DFDD212D1BE57CA50D783B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 0DD568A494247444A4B56031 /* Kingfisher */; };
|
||||||
41F553349AF44567184822D8 /* APNSPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D670124FC3E84F23A62CCF /* APNSPayload.swift */; };
|
41F553349AF44567184822D8 /* APNSPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D670124FC3E84F23A62CCF /* APNSPayload.swift */; };
|
||||||
4219391CD2351E410554B3E8 /* AggregratedReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B858A61F2A570DFB8DE570A7 /* AggregratedReaction.swift */; };
|
4219391CD2351E410554B3E8 /* AggregratedReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B858A61F2A570DFB8DE570A7 /* AggregratedReaction.swift */; };
|
||||||
@@ -296,7 +296,6 @@
|
|||||||
6F2D5D4F2590310DFAE973E4 /* WaitingDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6D698BFD68B061350553930 /* WaitingDialog.swift */; };
|
6F2D5D4F2590310DFAE973E4 /* WaitingDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6D698BFD68B061350553930 /* WaitingDialog.swift */; };
|
||||||
6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF7BF82A950B91BC5469E91 /* ViewFrameReader.swift */; };
|
6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF7BF82A950B91BC5469E91 /* ViewFrameReader.swift */; };
|
||||||
6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */; };
|
6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */; };
|
||||||
702694459B649B9D3A3C34F8 /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */; };
|
|
||||||
70394ECD2DCC70741538620D /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BB8DDE245ED86C489BA983 /* AccessibilityIdentifiers.swift */; };
|
70394ECD2DCC70741538620D /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04BB8DDE245ED86C489BA983 /* AccessibilityIdentifiers.swift */; };
|
||||||
70558528EF68CAAEF09972D5 /* RoomTimelineItemFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96ED747FF90332EA1333C22 /* RoomTimelineItemFixtures.swift */; };
|
70558528EF68CAAEF09972D5 /* RoomTimelineItemFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = E96ED747FF90332EA1333C22 /* RoomTimelineItemFixtures.swift */; };
|
||||||
706289B086B0A6B0C211763F /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; };
|
706289B086B0A6B0C211763F /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; };
|
||||||
@@ -1372,6 +1371,7 @@
|
|||||||
D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = "<group>"; };
|
D071F86CD47582B9196C9D16 /* UserDiscoverySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoverySection.swift; sourceTree = "<group>"; };
|
||||||
D09A267106B9585D3D0CFC0D /* ClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError.swift; sourceTree = "<group>"; };
|
D09A267106B9585D3D0CFC0D /* ClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientError.swift; sourceTree = "<group>"; };
|
||||||
D0A45283CF1DB96E583BECA6 /* ImageRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomTimelineView.swift; sourceTree = "<group>"; };
|
D0A45283CF1DB96E583BECA6 /* ImageRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||||
|
D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewState.swift; sourceTree = "<group>"; };
|
||||||
D1897720266C036471AD9D1B /* FormRowLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormRowLabelStyle.swift; sourceTree = "<group>"; };
|
D1897720266C036471AD9D1B /* FormRowLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormRowLabelStyle.swift; sourceTree = "<group>"; };
|
||||||
D263254AFE5B7993FFBBF324 /* NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NSE.entitlements; sourceTree = "<group>"; };
|
D263254AFE5B7993FFBBF324 /* NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NSE.entitlements; sourceTree = "<group>"; };
|
||||||
D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||||
@@ -1472,10 +1472,8 @@
|
|||||||
F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = "<group>"; };
|
F875D71347DC81EAE7687446 /* NavigationRootCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinatorTests.swift; sourceTree = "<group>"; };
|
||||||
F899D02CF26EA7675EEBE74C /* UserSessionScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionScreenTests.swift; sourceTree = "<group>"; };
|
F899D02CF26EA7675EEBE74C /* UserSessionScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionScreenTests.swift; sourceTree = "<group>"; };
|
||||||
F8CEB4634C0DD7779C4AB504 /* CreateRoomScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreenUITests.swift; sourceTree = "<group>"; };
|
F8CEB4634C0DD7779C4AB504 /* CreateRoomScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreenUITests.swift; sourceTree = "<group>"; };
|
||||||
F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = "<group>"; };
|
|
||||||
F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineView.swift; sourceTree = "<group>"; };
|
F9E785D5137510481733A3E8 /* TextRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||||
F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineView.swift; sourceTree = "<group>"; };
|
F9ED8E731E21055F728E5FED /* TimelineStartRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineView.swift; sourceTree = "<group>"; };
|
||||||
FA7D3C686C214470F4911618 /* RoomTimelineItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemViewModel.swift; sourceTree = "<group>"; };
|
|
||||||
FB0D6CB491777E7FC6B5BA12 /* CreateRoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreen.swift; sourceTree = "<group>"; };
|
FB0D6CB491777E7FC6B5BA12 /* CreateRoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreen.swift; sourceTree = "<group>"; };
|
||||||
FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModel.swift; sourceTree = "<group>"; };
|
FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModel.swift; sourceTree = "<group>"; };
|
||||||
FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = "<group>"; };
|
FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = "<group>"; };
|
||||||
@@ -2590,7 +2588,6 @@
|
|||||||
4A4AD793D50748F8997E5B15 /* TimelineItemMacContextMenu.swift */,
|
4A4AD793D50748F8997E5B15 /* TimelineItemMacContextMenu.swift */,
|
||||||
A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */,
|
A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */,
|
||||||
0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */,
|
0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */,
|
||||||
F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */,
|
|
||||||
874A1842477895F199567BD7 /* TimelineView.swift */,
|
874A1842477895F199567BD7 /* TimelineView.swift */,
|
||||||
1D8572B713A11CFDBF009B2F /* Replies */,
|
1D8572B713A11CFDBF009B2F /* Replies */,
|
||||||
A312471EA62EFB0FD94E60DC /* Style */,
|
A312471EA62EFB0FD94E60DC /* Style */,
|
||||||
@@ -2892,7 +2889,7 @@
|
|||||||
7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */,
|
7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */,
|
||||||
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */,
|
ED1D792EB82506A19A72C8DE /* RoomTimelineItemProtocol.swift */,
|
||||||
90F2F8998E5632668B0AD848 /* RoomTimelineItemView.swift */,
|
90F2F8998E5632668B0AD848 /* RoomTimelineItemView.swift */,
|
||||||
FA7D3C686C214470F4911618 /* RoomTimelineItemViewModel.swift */,
|
D0C2D52E36AD614B3C003EF6 /* RoomTimelineItemViewState.swift */,
|
||||||
75D1D02F7F3AC1122FCFB4F3 /* Items */,
|
75D1D02F7F3AC1122FCFB4F3 /* Items */,
|
||||||
);
|
);
|
||||||
path = TimelineItems;
|
path = TimelineItems;
|
||||||
@@ -3627,7 +3624,7 @@
|
|||||||
path = Timeline;
|
path = Timeline;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
"TEMP_62CE5EF1-8768-4933-9547-E5F60DAC11F4" /* element-x-ios */ = {
|
"TEMP_43147A32-A06C-4EEF-8C45-FDEA43A25C1E" /* element-x-ios */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
41553551C55AD59885840F0E /* secrets.xcconfig */,
|
41553551C55AD59885840F0E /* secrets.xcconfig */,
|
||||||
@@ -4532,7 +4529,7 @@
|
|||||||
878070573C7BF19E735707B4 /* RoomTimelineItemProperties.swift in Sources */,
|
878070573C7BF19E735707B4 /* RoomTimelineItemProperties.swift in Sources */,
|
||||||
1AE4AEA0FA8DEF52671832E0 /* RoomTimelineItemProtocol.swift in Sources */,
|
1AE4AEA0FA8DEF52671832E0 /* RoomTimelineItemProtocol.swift in Sources */,
|
||||||
AD55E245FE686D7DB4C86406 /* RoomTimelineItemView.swift in Sources */,
|
AD55E245FE686D7DB4C86406 /* RoomTimelineItemView.swift in Sources */,
|
||||||
2198B4458AFF69102BBCC370 /* RoomTimelineItemViewModel.swift in Sources */,
|
41CE5E1289C8768FC5B6490C /* RoomTimelineItemViewState.swift in Sources */,
|
||||||
9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */,
|
9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */,
|
||||||
77D7DAA41AAB36800C1F2E2D /* RoomTimelineProviderProtocol.swift in Sources */,
|
77D7DAA41AAB36800C1F2E2D /* RoomTimelineProviderProtocol.swift in Sources */,
|
||||||
B2F8E01ABA1BA30265B4ECBE /* RoundedCornerShape.swift in Sources */,
|
B2F8E01ABA1BA30265B4ECBE /* RoundedCornerShape.swift in Sources */,
|
||||||
@@ -4631,7 +4628,6 @@
|
|||||||
6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */,
|
6FF51EB400DBA0668FC38B97 /* TimelineStartRoomTimelineView.swift in Sources */,
|
||||||
69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */,
|
69BCBB4FB2DC3D61A28D3FD8 /* TimelineStyle.swift in Sources */,
|
||||||
FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */,
|
FFD3E4FF948E06C7585317FC /* TimelineStyler.swift in Sources */,
|
||||||
702694459B649B9D3A3C34F8 /* TimelineTableViewController.swift in Sources */,
|
|
||||||
500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */,
|
500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */,
|
||||||
36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */,
|
36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */,
|
||||||
A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */,
|
A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */,
|
||||||
|
|||||||
@@ -228,8 +228,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "245d527002f29c5a616c5b7131f6a50ac7f41cbf",
|
"revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865",
|
||||||
"version" : "0.8.6"
|
"version" : "0.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ class ScrollViewAdapter: NSObject, UIScrollViewDelegate {
|
|||||||
scrollView?.delegate = self
|
scrollView?.delegate = self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var shouldScrollToTopClosure: ((UIScrollView) -> Bool)?
|
||||||
|
|
||||||
private let didScrollSubject = PassthroughSubject<Void, Never>()
|
private let didScrollSubject = PassthroughSubject<Void, Never>()
|
||||||
var didScroll: AnyPublisher<Void, Never> {
|
var didScroll: AnyPublisher<Void, Never> {
|
||||||
didScrollSubject.eraseToAnyPublisher()
|
didScrollSubject.eraseToAnyPublisher()
|
||||||
@@ -73,6 +75,14 @@ class ScrollViewAdapter: NSObject, UIScrollViewDelegate {
|
|||||||
func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
|
func scrollViewDidScrollToTop(_ scrollView: UIScrollView) {
|
||||||
updateDidScroll(scrollView)
|
updateDidScroll(scrollView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||||
|
guard let shouldScrollToTopClosure else {
|
||||||
|
// Default behaviour
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return shouldScrollToTopClosure(scrollView)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ enum RoomScreenComposerMode: Equatable {
|
|||||||
|
|
||||||
enum RoomScreenViewAction {
|
enum RoomScreenViewAction {
|
||||||
case displayRoomDetails
|
case displayRoomDetails
|
||||||
case paginateBackwards
|
|
||||||
case itemAppeared(itemID: TimelineItemIdentifier)
|
case itemAppeared(itemID: TimelineItemIdentifier)
|
||||||
case itemDisappeared(itemID: TimelineItemIdentifier)
|
case itemDisappeared(itemID: TimelineItemIdentifier)
|
||||||
case itemTapped(itemID: TimelineItemIdentifier)
|
case itemTapped(itemID: TimelineItemIdentifier)
|
||||||
@@ -86,18 +85,14 @@ struct RoomScreenViewState: BindableState {
|
|||||||
var roomId: String
|
var roomId: String
|
||||||
var roomTitle = ""
|
var roomTitle = ""
|
||||||
var roomAvatarURL: URL?
|
var roomAvatarURL: URL?
|
||||||
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>()
|
|
||||||
var members: [String: RoomMemberState] = [:]
|
var members: [String: RoomMemberState] = [:]
|
||||||
var canBackPaginate = true
|
|
||||||
var isBackPaginating = false
|
|
||||||
var showLoading = false
|
var showLoading = false
|
||||||
var timelineStyle: TimelineStyle
|
var timelineStyle: TimelineStyle
|
||||||
var readReceiptsEnabled: Bool
|
var readReceiptsEnabled: Bool
|
||||||
var isEncryptedOneToOneRoom = false
|
var isEncryptedOneToOneRoom = false
|
||||||
|
var timelineViewState = TimelineViewState() // check the doc before changing this
|
||||||
var composerMode: RoomScreenComposerMode = .default
|
var composerMode: RoomScreenComposerMode = .default
|
||||||
let scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
|
||||||
|
|
||||||
var bindings: RoomScreenViewStateBindings
|
var bindings: RoomScreenViewStateBindings
|
||||||
|
|
||||||
/// A closure providing the actions to show when long pressing on an item in the timeline.
|
/// A closure providing the actions to show when long pressing on an item in the timeline.
|
||||||
@@ -106,14 +101,6 @@ struct RoomScreenViewState: BindableState {
|
|||||||
var sendButtonDisabled: Bool {
|
var sendButtonDisabled: Bool {
|
||||||
bindings.composerText.count == 0
|
bindings.composerText.count == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var timelineIDs: [String] {
|
|
||||||
itemsDictionary.keys.elements
|
|
||||||
}
|
|
||||||
|
|
||||||
var itemViewModels: [RoomTimelineItemViewModel] {
|
|
||||||
itemsDictionary.values.elements
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RoomScreenViewStateBindings {
|
struct RoomScreenViewStateBindings {
|
||||||
@@ -184,3 +171,21 @@ struct RoomMemberState {
|
|||||||
let displayName: String?
|
let displayName: String?
|
||||||
let avatarURL: URL?
|
let avatarURL: URL?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Used as the state for the TimelineView, to avoid having the context continuously refresh the list of items on each small change.
|
||||||
|
/// Is also nice to have this as a wrapper for any state that is directly connected to the timeline.
|
||||||
|
struct TimelineViewState {
|
||||||
|
var canBackPaginate = true
|
||||||
|
var isBackPaginating = false
|
||||||
|
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
|
||||||
|
|
||||||
|
var timelineIDs: [String] {
|
||||||
|
itemsDictionary.keys.elements
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemViewStates: [RoomTimelineItemViewState] {
|
||||||
|
itemsDictionary.values.elements
|
||||||
|
}
|
||||||
|
|
||||||
|
var paginateAction: (() -> Void)?
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
|
|
||||||
private var canCurrentUserRedact = false
|
private var canCurrentUserRedact = false
|
||||||
|
|
||||||
|
private var paginateBackwardsTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(timelineController: RoomTimelineControllerProtocol,
|
init(timelineController: RoomTimelineControllerProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
mediaProvider: MediaProviderProtocol,
|
||||||
roomProxy: RoomProxyProtocol,
|
roomProxy: RoomProxyProtocol,
|
||||||
@@ -60,6 +62,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
bindings: .init(composerText: "", composerFocused: false, reactionsCollapsed: [:])),
|
bindings: .init(composerText: "", composerFocused: false, reactionsCollapsed: [:])),
|
||||||
imageProvider: mediaProvider)
|
imageProvider: mediaProvider)
|
||||||
|
|
||||||
|
state.timelineViewState.paginateAction = { [weak self] in
|
||||||
|
self?.paginateBackwards()
|
||||||
|
}
|
||||||
|
|
||||||
setupSubscriptions()
|
setupSubscriptions()
|
||||||
|
|
||||||
state.timelineItemMenuActionProvider = { [weak self] itemId -> TimelineItemMenuActions? in
|
state.timelineItemMenuActionProvider = { [weak self] itemId -> TimelineItemMenuActions? in
|
||||||
@@ -84,8 +90,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
switch viewAction {
|
switch viewAction {
|
||||||
case .displayRoomDetails:
|
case .displayRoomDetails:
|
||||||
callback?(.displayRoomDetails)
|
callback?(.displayRoomDetails)
|
||||||
case .paginateBackwards:
|
|
||||||
Task { await paginateBackwards() }
|
|
||||||
case .itemAppeared(let id):
|
case .itemAppeared(let id):
|
||||||
Task { await timelineController.processItemAppearance(id) }
|
Task { await timelineController.processItemAppearance(id) }
|
||||||
case .itemDisappeared(let id):
|
case .itemDisappeared(let id):
|
||||||
@@ -151,12 +155,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
case .updatedTimelineItems:
|
case .updatedTimelineItems:
|
||||||
self.buildTimelineViews()
|
self.buildTimelineViews()
|
||||||
case .canBackPaginate(let canBackPaginate):
|
case .canBackPaginate(let canBackPaginate):
|
||||||
if self.state.canBackPaginate != canBackPaginate {
|
if self.state.timelineViewState.canBackPaginate != canBackPaginate {
|
||||||
self.state.canBackPaginate = canBackPaginate
|
self.state.timelineViewState.canBackPaginate = canBackPaginate
|
||||||
}
|
}
|
||||||
case .isBackPaginating(let isBackPaginating):
|
case .isBackPaginating(let isBackPaginating):
|
||||||
if self.state.isBackPaginating != isBackPaginating {
|
if self.state.timelineViewState.isBackPaginating != isBackPaginating {
|
||||||
self.state.isBackPaginating = isBackPaginating
|
self.state.timelineViewState.isBackPaginating = isBackPaginating
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,12 +219,23 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func paginateBackwards() async {
|
private func paginateBackwards() {
|
||||||
switch await timelineController.paginateBackwards(requestSize: Constants.backPaginationEventLimit, untilNumberOfItems: Constants.backPaginationPageSize) {
|
guard paginateBackwardsTask == nil else {
|
||||||
case .failure:
|
return
|
||||||
displayError(.toast(L10n.errorFailedLoadingMessages))
|
}
|
||||||
default:
|
|
||||||
break
|
paginateBackwardsTask = Task { [weak self] in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch await timelineController.paginateBackwards(requestSize: Constants.backPaginationEventLimit, untilNumberOfItems: Constants.backPaginationPageSize) {
|
||||||
|
case .failure:
|
||||||
|
displayError(.toast(L10n.errorFailedLoadingMessages))
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
paginateBackwardsTask = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +260,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func buildTimelineViews() {
|
private func buildTimelineViews() {
|
||||||
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>()
|
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
|
||||||
|
|
||||||
let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in
|
let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in
|
||||||
canGroupItem(timelineItem: current, with: next)
|
canGroupItem(timelineItem: current, with: next)
|
||||||
@@ -259,36 +274,26 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
|
|
||||||
if itemGroup.count == 1 {
|
if itemGroup.count == 1 {
|
||||||
if let firstItem = itemGroup.first {
|
if let firstItem = itemGroup.first {
|
||||||
timelineItemsDictionary.updateValue(updateViewModel(item: firstItem, groupStyle: .single),
|
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: firstItem, groupStyle: .single),
|
||||||
forKey: firstItem.id.timelineID)
|
forKey: firstItem.id.timelineID)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (index, item) in itemGroup.enumerated() {
|
for (index, item) in itemGroup.enumerated() {
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .first),
|
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .first),
|
||||||
forKey: item.id.timelineID)
|
forKey: item.id.timelineID)
|
||||||
} else if index == itemGroup.count - 1 {
|
} else if index == itemGroup.count - 1 {
|
||||||
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .last),
|
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .last),
|
||||||
forKey: item.id.timelineID)
|
forKey: item.id.timelineID)
|
||||||
} else {
|
} else {
|
||||||
timelineItemsDictionary.updateValue(updateViewModel(item: item, groupStyle: .middle),
|
timelineItemsDictionary.updateValue(RoomTimelineItemViewState(item: item, groupStyle: .middle),
|
||||||
forKey: item.id.timelineID)
|
forKey: item.id.timelineID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.itemsDictionary = timelineItemsDictionary
|
state.timelineViewState.itemsDictionary = timelineItemsDictionary
|
||||||
}
|
|
||||||
|
|
||||||
private func updateViewModel(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewModel {
|
|
||||||
if let timelineItemViewModel = state.itemsDictionary[item.id.timelineID] {
|
|
||||||
timelineItemViewModel.groupStyle = groupStyle
|
|
||||||
timelineItemViewModel.type = .init(item: item)
|
|
||||||
return timelineItemViewModel
|
|
||||||
} else {
|
|
||||||
return RoomTimelineItemViewModel(item: item, groupStyle: groupStyle)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool {
|
private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool {
|
||||||
@@ -666,7 +671,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
// MARK: - Reactions
|
// MARK: - Reactions
|
||||||
|
|
||||||
private func showEmojiPicker(for itemID: TimelineItemIdentifier) {
|
private func showEmojiPicker(for itemID: TimelineItemIdentifier) {
|
||||||
guard let item = state.itemsDictionary[itemID.timelineID], item.isReactable else { return }
|
guard let item = state.timelineViewState.itemsDictionary[itemID.timelineID], item.isReactable else { return }
|
||||||
callback?(.displayEmojiPicker(itemID: itemID))
|
callback?(.displayEmojiPicker(itemID: itemID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ struct RoomScreen: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
timeline
|
timeline
|
||||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea()) // Kills the toolbar translucency.
|
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
HStack(alignment: .bottom, spacing: attachmentButtonPadding) {
|
HStack(alignment: .bottom, spacing: attachmentButtonPadding) {
|
||||||
RoomAttachmentPicker(context: context)
|
RoomAttachmentPicker(context: context)
|
||||||
@@ -36,6 +36,7 @@ struct RoomScreen: View {
|
|||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
|
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
@@ -80,14 +81,13 @@ struct RoomScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var timeline: some View {
|
private var timeline: some View {
|
||||||
TimelineView()
|
TimelineView(viewState: context.viewState.timelineViewState)
|
||||||
.id(context.viewState.roomId)
|
.id(context.viewState.roomId)
|
||||||
.environmentObject(context)
|
.environmentObject(context)
|
||||||
.environment(\.timelineStyle, context.viewState.timelineStyle)
|
.environment(\.timelineStyle, context.viewState.timelineStyle)
|
||||||
.environment(\.readReceiptsEnabled, context.viewState.readReceiptsEnabled)
|
.environment(\.readReceiptsEnabled, context.viewState.readReceiptsEnabled)
|
||||||
.overlay(alignment: .bottomTrailing) { scrollToBottomButton }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var messageComposer: some View {
|
private var messageComposer: some View {
|
||||||
@@ -108,27 +108,6 @@ struct RoomScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var scrollToBottomButton: some View {
|
|
||||||
Button { context.viewState.scrollToBottomPublisher.send(()) } label: {
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.compound.bodyLG)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundColor(.compound.iconSecondary)
|
|
||||||
.padding(13)
|
|
||||||
.offset(y: 1)
|
|
||||||
.background {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.compound.iconOnSolidPrimary)
|
|
||||||
// Intentionally using system primary colour to get white/black.
|
|
||||||
.shadow(color: .primary.opacity(0.33), radius: 2.0)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.opacity(context.scrollToBottomButtonVisible ? 1.0 : 0.0)
|
|
||||||
.accessibilityHidden(!context.scrollToBottomButtonVisible)
|
|
||||||
.animation(.elementDefault, value: context.scrollToBottomButtonVisible)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var loadingIndicator: some View {
|
private var loadingIndicator: some View {
|
||||||
if context.viewState.showLoading {
|
if context.viewState.showLoading {
|
||||||
|
|||||||
@@ -328,8 +328,8 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
|||||||
static var mockTimeline: some View {
|
static var mockTimeline: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(viewModel.state.itemViewModels) { itemViewModel in
|
ForEach(viewModel.state.timelineViewState.itemViewStates) { viewState in
|
||||||
RoomTimelineItemView(viewModel: itemViewModel)
|
RoomTimelineItemView(viewState: viewState)
|
||||||
.padding(TimelineStyle.bubbles.rowInsets)
|
.padding(TimelineStyle.bubbles.rowInsets)
|
||||||
// Insets added in the table view cells
|
// Insets added in the table view cells
|
||||||
}
|
}
|
||||||
@@ -341,7 +341,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
|||||||
|
|
||||||
static var replies: some View {
|
static var replies: some View {
|
||||||
VStack {
|
VStack {
|
||||||
RoomTimelineItemView(viewModel: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||||
timestamp: "10:42",
|
timestamp: "10:42",
|
||||||
isOutgoing: true,
|
isOutgoing: true,
|
||||||
isEditable: false,
|
isEditable: false,
|
||||||
@@ -350,7 +350,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
|
|||||||
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||||
contentType: .text(.init(body: "Short")))), groupStyle: .single))
|
contentType: .text(.init(body: "Short")))), groupStyle: .single))
|
||||||
|
|
||||||
RoomTimelineItemView(viewModel: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .init(timelineID: ""),
|
||||||
timestamp: "10:42",
|
timestamp: "10:42",
|
||||||
isOutgoing: true,
|
isOutgoing: true,
|
||||||
isEditable: false,
|
isEditable: false,
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider {
|
|||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
|
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
|
||||||
let item = MockRoomTimelineController().timelineItems[index]
|
let item = MockRoomTimelineController().timelineItems[index]
|
||||||
RoomTimelineItemView(viewModel: .init(item: item, groupStyle: .single))
|
RoomTimelineItemView(viewState: .init(item: item, groupStyle: .single))
|
||||||
.padding(TimelineStyle.plain.rowInsets) // Insets added in the table view cells
|
.padding(TimelineStyle.plain.rowInsets) // Insets added in the table view cells
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ struct TimelineItemStyler_Previews: PreviewProvider {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
static let sendingLast: TextRoomTimelineItem = {
|
static let sendingLast: TextRoomTimelineItem = {
|
||||||
let id = viewModel.state.timelineIDs.last ?? UUID().uuidString
|
let id = viewModel.state.timelineViewState.timelineIDs.last ?? UUID().uuidString
|
||||||
var result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
|
var result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
|
||||||
result.properties.deliveryStatus = .sending
|
result.properties.deliveryStatus = .sending
|
||||||
return result
|
return result
|
||||||
@@ -66,7 +66,7 @@ struct TimelineItemStyler_Previews: PreviewProvider {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
static let sentLast: TextRoomTimelineItem = {
|
static let sentLast: TextRoomTimelineItem = {
|
||||||
let id = viewModel.state.timelineIDs.last ?? UUID().uuidString
|
let id = viewModel.state.timelineViewState.timelineIDs.last ?? UUID().uuidString
|
||||||
let result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
|
let result = TextRoomTimelineItem(id: .init(timelineID: id), timestamp: "Now", isOutgoing: true, isEditable: false, sender: .init(id: UUID().uuidString), content: .init(body: "Test"))
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -23,11 +23,15 @@ struct TimelineItemStatusView: View {
|
|||||||
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||||
|
|
||||||
private var isLastOutgoingMessage: Bool {
|
private var isLastOutgoingMessage: Bool {
|
||||||
context.viewState.timelineIDs.last == timelineItem.id.timelineID &&
|
timelineItem.isOutgoing && context.viewState.timelineViewState.timelineIDs.last == timelineItem.id.timelineID
|
||||||
timelineItem.isOutgoing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
mainContent
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var mainContent: some View {
|
||||||
if !timelineItem.properties.orderedReadReceipts.isEmpty, readReceiptsEnabled {
|
if !timelineItem.properties.orderedReadReceipts.isEmpty, readReceiptsEnabled {
|
||||||
readReceipts
|
readReceipts
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+10
-9
@@ -18,34 +18,35 @@ import SwiftUI
|
|||||||
|
|
||||||
struct CollapsibleRoomTimelineView: View {
|
struct CollapsibleRoomTimelineView: View {
|
||||||
private let timelineItem: CollapsibleTimelineItem
|
private let timelineItem: CollapsibleTimelineItem
|
||||||
private let timelineViewModels: [RoomTimelineItemViewModel]
|
private let groupedViewStates: [RoomTimelineItemViewState]
|
||||||
|
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
|
|
||||||
init(timelineItem: CollapsibleTimelineItem) {
|
init(timelineItem: CollapsibleTimelineItem) {
|
||||||
self.timelineItem = timelineItem
|
self.timelineItem = timelineItem
|
||||||
timelineViewModels = timelineItem.items.map { .init(item: $0, groupStyle: .single) }
|
groupedViewStates = timelineItem.items.map { .init(item: $0, groupStyle: .single) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
DisclosureGroup(L10n.roomTimelineStateChanges(timelineItem.items.count), isExpanded: $isExpanded) {
|
DisclosureGroup(L10n.roomTimelineStateChanges(timelineItem.items.count), isExpanded: $isExpanded) {
|
||||||
Group {
|
Group {
|
||||||
ForEach(timelineViewModels) { viewModel in
|
ForEach(groupedViewStates) { viewState in
|
||||||
RoomTimelineItemView(viewModel: viewModel)
|
RoomTimelineItemView(viewState: viewState)
|
||||||
}
|
}
|
||||||
}.transition(.opacity.animation(.elementDefault))
|
}
|
||||||
}
|
}
|
||||||
.disclosureGroupStyle(CollapsibleRoomTimelineItemDisclosureGroupStyle())
|
.disclosureGroupStyle(CollapsibleRoomTimelineItemDisclosureGroupStyle())
|
||||||
.transaction { transaction in
|
|
||||||
transaction.animation = .noAnimation // Fixes weird animations on the disclosure indicator
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CollapsibleRoomTimelineItemDisclosureGroupStyle: DisclosureGroupStyle {
|
private struct CollapsibleRoomTimelineItemDisclosureGroupStyle: DisclosureGroupStyle {
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
VStack(spacing: 0.0) {
|
VStack(spacing: 0.0) {
|
||||||
Button { configuration.isExpanded.toggle() } label: {
|
Button {
|
||||||
|
withElementAnimation {
|
||||||
|
configuration.isExpanded.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
configuration.label
|
configuration.label
|
||||||
Text(Image(systemName: "chevron.forward"))
|
Text(Image(systemName: "chevron.forward"))
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
|
|||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
RoomTimelineItemView(viewModel: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single))
|
RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single))
|
||||||
RoomTimelineItemView(viewModel: .init(type: .text(.init(id: .init(timelineID: ""),
|
RoomTimelineItemView(viewState: .init(type: .text(.init(id: .init(timelineID: ""),
|
||||||
timestamp: "",
|
timestamp: "",
|
||||||
isOutgoing: true,
|
isOutgoing: true,
|
||||||
isEditable: false,
|
isEditable: false,
|
||||||
@@ -52,8 +52,8 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
|
|||||||
|
|
||||||
ReadMarkerRoomTimelineView(timelineItem: item)
|
ReadMarkerRoomTimelineView(timelineItem: item)
|
||||||
|
|
||||||
RoomTimelineItemView(viewModel: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single))
|
RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .init(timelineID: "Separator"), text: "Today")), groupStyle: .single))
|
||||||
RoomTimelineItemView(viewModel: .init(type: .text(.init(id: .init(timelineID: ""),
|
RoomTimelineItemView(viewState: .init(type: .text(.init(id: .init(timelineID: ""),
|
||||||
timestamp: "",
|
timestamp: "",
|
||||||
isOutgoing: false,
|
isOutgoing: false,
|
||||||
isEditable: false,
|
isEditable: false,
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
//
|
|
||||||
// Copyright 2022 New Vector Ltd
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
import OrderedCollections
|
|
||||||
|
|
||||||
/// A table view cell that displays a timeline item in a room. The cell is intended
|
|
||||||
/// to be configured to display a SwiftUI view and not use any UIKit.
|
|
||||||
class TimelineItemCell: UITableViewCell {
|
|
||||||
static let reuseIdentifier = "TimelineItemCell"
|
|
||||||
|
|
||||||
var item: RoomTimelineItemViewModel?
|
|
||||||
|
|
||||||
override func prepareForReuse() {
|
|
||||||
item = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A table view controller that displays the timeline of a room.
|
|
||||||
///
|
|
||||||
/// This class subclasses `UIViewController` as `UITableViewController` adds some
|
|
||||||
/// extra keyboard handling magic that wasn't playing well with SwiftUI (as of iOS 16.1).
|
|
||||||
/// Also this TableViewController uses a **flipped tableview**
|
|
||||||
class TimelineTableViewController: UIViewController {
|
|
||||||
private let coordinator: TimelineView.Coordinator
|
|
||||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
|
||||||
|
|
||||||
var timelineStyle: TimelineStyle
|
|
||||||
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewModel>() {
|
|
||||||
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<TimelineSection, String>?
|
|
||||||
private var cancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
/// 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<Void, Never>()
|
|
||||||
/// Whether or not the view has been shown on screen yet.
|
|
||||||
private var hasAppearedOnce = false
|
|
||||||
/// Whether the scroll and the animations should happen
|
|
||||||
private var shouldAnimate = false
|
|
||||||
|
|
||||||
init(coordinator: TimelineView.Coordinator,
|
|
||||||
timelineStyle: TimelineStyle,
|
|
||||||
scrollToBottomButtonVisible: Binding<Bool>,
|
|
||||||
scrollToBottomPublisher: PassthroughSubject<Void, Never>) {
|
|
||||||
self.coordinator = coordinator
|
|
||||||
self.timelineStyle = timelineStyle
|
|
||||||
_scrollToBottomButtonVisible = scrollToBottomButtonVisible
|
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
|
|
||||||
tableView.register(TimelineItemCell.self, forCellReuseIdentifier: TimelineItemCell.reuseIdentifier)
|
|
||||||
tableView.separatorStyle = .none
|
|
||||||
tableView.allowsSelection = false
|
|
||||||
tableView.keyboardDismissMode = .onDrag
|
|
||||||
tableView.backgroundColor = UIColor(.compound.bgCanvasDefault)
|
|
||||||
tableView.transform = CGAffineTransform(scaleX: 1, y: -1)
|
|
||||||
view.addSubview(tableView)
|
|
||||||
|
|
||||||
// Prevents XCUITest from invoking the diffable dataSource's cellProvider
|
|
||||||
// for each possible cell, causing layout issues
|
|
||||||
tableView.accessibilityElementsHidden = Tests.shouldDisableTimelineAccessibility
|
|
||||||
|
|
||||||
scrollToBottomPublisher
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.scrollToBottom(animated: true)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
paginateBackwardsPublisher
|
|
||||||
.collect(.byTime(DispatchQueue.main, 0.1))
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.paginateBackwardsIfNeeded()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
configureDataSource()
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not available.") }
|
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
|
|
||||||
guard !hasAppearedOnce else { return }
|
|
||||||
tableView.contentOffset.y = -1
|
|
||||||
hasAppearedOnce = true
|
|
||||||
paginateBackwardsPublisher.send()
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
|
||||||
self.shouldAnimate = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillLayoutSubviews() {
|
|
||||||
super.viewWillLayoutSubviews()
|
|
||||||
|
|
||||||
guard tableView.frame.size != view.frame.size else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tableView.frame = CGRect(origin: .zero, size: view.frame.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configures a diffable data source for the timeline's table view.
|
|
||||||
private func configureDataSource() {
|
|
||||||
dataSource = .init(tableView: tableView) { [weak self] tableView, indexPath, id in
|
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: TimelineItemCell.reuseIdentifier, for: indexPath)
|
|
||||||
guard let self, let cell = cell as? TimelineItemCell else { return cell }
|
|
||||||
|
|
||||||
// A local reference to avoid capturing self in the cell configuration.
|
|
||||||
let coordinator = self.coordinator
|
|
||||||
|
|
||||||
let viewModel = timelineItemsDictionary[id]
|
|
||||||
cell.item = viewModel
|
|
||||||
guard let viewModel else {
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
cell.contentConfiguration = UIHostingConfiguration {
|
|
||||||
RoomTimelineItemView(viewModel: viewModel)
|
|
||||||
.id(id)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
|
|
||||||
.onAppear {
|
|
||||||
coordinator.send(viewAction: .itemAppeared(itemID: viewModel.id))
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
coordinator.send(viewAction: .itemDisappeared(itemID: viewModel.id))
|
|
||||||
}
|
|
||||||
.environment(\.openURL, OpenURLAction { url in
|
|
||||||
coordinator.send(viewAction: .linkClicked(url: url))
|
|
||||||
return .systemAction
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.margins(.all, self.timelineStyle.rowInsets)
|
|
||||||
.minSize(height: 1)
|
|
||||||
.background(Color.clear)
|
|
||||||
|
|
||||||
// Flipping the cell can create some issues with cell resizing, so flip the content View
|
|
||||||
cell.contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
|
|
||||||
dataSource?.defaultRowAnimation = .fade
|
|
||||||
tableView.delegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates the table view with the latest items from the ``timelineItems`` array. After
|
|
||||||
/// updating the data, the table will be scrolled to the bottom if it was visible otherwise
|
|
||||||
/// the scroll position will be updated to maintain the position of the last visible item.
|
|
||||||
private func applySnapshot() {
|
|
||||||
guard let dataSource else { return }
|
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, String>()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,69 +14,147 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// A table view wrapper that displays the timeline of a room.
|
import SwiftUIIntrospect
|
||||||
struct TimelineView: UIViewControllerRepresentable {
|
|
||||||
@EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context
|
struct TimelineView: View {
|
||||||
|
let viewState: TimelineViewState
|
||||||
@Environment(\.timelineStyle) private var timelineStyle
|
@Environment(\.timelineStyle) private var timelineStyle
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> TimelineTableViewController {
|
private let bottomID = "RoomTimelineBottomPinIdentifier"
|
||||||
let tableViewController = TimelineTableViewController(coordinator: context.coordinator,
|
private let topID = "RoomTimelineTopPinIdentifier"
|
||||||
timelineStyle: timelineStyle,
|
|
||||||
scrollToBottomButtonVisible: $viewModelContext.scrollToBottomButtonVisible,
|
@State private var scrollViewAdapter = ScrollViewAdapter()
|
||||||
scrollToBottomPublisher: viewModelContext.viewState.scrollToBottomPublisher)
|
@State private var paginateBackwardsPublisher = PassthroughSubject<Void, Never>()
|
||||||
return tableViewController
|
@State private var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
|
||||||
}
|
@State private var scrollToBottomButtonVisible = false
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: TimelineTableViewController, context: Context) {
|
var body: some View {
|
||||||
context.coordinator.update(tableViewController: uiViewController, timelineStyle: timelineStyle)
|
ScrollViewReader { scrollView in
|
||||||
}
|
ScrollView {
|
||||||
|
bottomPin
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator(viewModelContext: viewModelContext)
|
LazyVStack(spacing: 0) {
|
||||||
}
|
ForEach(viewState.itemViewStates.reversed()) { viewState in
|
||||||
|
RoomTimelineItemView(viewState: viewState)
|
||||||
// MARK: - Coordinator
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(timelineStyle.rowInsets)
|
||||||
@MainActor
|
.scaleEffect(x: 1, y: -1)
|
||||||
class Coordinator {
|
}
|
||||||
let context: RoomScreenViewModel.Context
|
}
|
||||||
|
|
||||||
init(viewModelContext: RoomScreenViewModel.Context) {
|
topPin
|
||||||
context = viewModelContext
|
}
|
||||||
|
.introspect(.scrollView, on: .iOS(.v16)) { uiScrollView in
|
||||||
if viewModelContext.viewState.itemViewModels.isEmpty {
|
guard uiScrollView != scrollViewAdapter.scrollView else {
|
||||||
viewModelContext.send(viewAction: .paginateBackwards)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollViewAdapter.scrollView = uiScrollView
|
||||||
|
scrollViewAdapter.shouldScrollToTopClosure = { _ in
|
||||||
|
withElementAnimation {
|
||||||
|
scrollView.scrollTo(topID)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allows the scroll to top to work properly
|
||||||
|
uiScrollView.contentOffset.y -= 1
|
||||||
|
}
|
||||||
|
.scaleEffect(x: 1, y: -1)
|
||||||
|
.onReceive(scrollToBottomPublisher) { _ in
|
||||||
|
withElementAnimation {
|
||||||
|
scrollView.scrollTo(bottomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
}
|
||||||
|
.overlay(scrollToBottomButton, alignment: .bottomTrailing)
|
||||||
|
.animation(.elementDefault, value: viewState.itemViewStates)
|
||||||
|
.onReceive(scrollViewAdapter.didScroll) { _ in
|
||||||
|
guard let scrollView = scrollViewAdapter.scrollView else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let offset = scrollView.contentOffset.y + scrollView.contentInset.top
|
||||||
|
let scrollToBottomButtonVisibleValue = offset > 0
|
||||||
|
if scrollToBottomButtonVisibleValue != scrollToBottomButtonVisible {
|
||||||
|
scrollToBottomButtonVisible = scrollToBottomButtonVisibleValue
|
||||||
|
}
|
||||||
|
paginateBackwardsPublisher.send()
|
||||||
|
|
||||||
|
// Allows the scroll to top to work properly
|
||||||
|
if offset == 0 {
|
||||||
|
scrollView.contentOffset.y -= 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onReceive(paginateBackwardsPublisher.collect(.byTime(DispatchQueue.main, 0.1))) { _ in
|
||||||
/// Updates the specified table view's properties from the current view state.
|
paginateBackwardsIfNeeded()
|
||||||
func update(tableViewController: TimelineTableViewController, timelineStyle: TimelineStyle) {
|
|
||||||
if tableViewController.timelineStyle != timelineStyle {
|
|
||||||
tableViewController.timelineStyle = timelineStyle
|
|
||||||
}
|
|
||||||
if tableViewController.timelineItemsDictionary != context.viewState.itemsDictionary {
|
|
||||||
tableViewController.timelineItemsDictionary = context.viewState.itemsDictionary
|
|
||||||
}
|
|
||||||
if tableViewController.canBackPaginate != context.viewState.canBackPaginate {
|
|
||||||
tableViewController.canBackPaginate = context.viewState.canBackPaginate
|
|
||||||
}
|
|
||||||
if tableViewController.isBackPaginating != context.viewState.isBackPaginating {
|
|
||||||
tableViewController.isBackPaginating = context.viewState.isBackPaginating
|
|
||||||
}
|
|
||||||
if tableViewController.composerMode != context.viewState.composerMode {
|
|
||||||
tableViewController.composerMode = context.viewState.composerMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Doesn't have an equatable conformance :(
|
|
||||||
tableViewController.contextMenuActionProvider = context.viewState.timelineItemMenuActionProvider
|
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
func send(viewAction: RoomScreenViewAction) {
|
paginateBackwardsPublisher.send()
|
||||||
context.send(viewAction: viewAction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Used to mark the top of the scroll view and easily scroll to it
|
||||||
|
private var topPin: some View {
|
||||||
|
Divider()
|
||||||
|
.id(topID)
|
||||||
|
.hidden()
|
||||||
|
.frame(height: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used to mark the bottom of the scroll view and easily scroll to it
|
||||||
|
private var bottomPin: some View {
|
||||||
|
Divider()
|
||||||
|
.id(bottomID)
|
||||||
|
.hidden()
|
||||||
|
.frame(height: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scrollToBottomButton: some View {
|
||||||
|
Button {
|
||||||
|
scrollToBottomPublisher.send()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.compound.bodyLG)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.compound.iconSecondary)
|
||||||
|
.padding(13)
|
||||||
|
.offset(y: 1)
|
||||||
|
.background {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.compound.iconOnSolidPrimary)
|
||||||
|
// Intentionally using system primary colour to get white/black.
|
||||||
|
.shadow(color: .primary.opacity(0.33), radius: 2.0)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.opacity(scrollToBottomButtonVisible ? 1.0 : 0.0)
|
||||||
|
.accessibilityHidden(!scrollToBottomButtonVisible)
|
||||||
|
.animation(.elementDefault, value: scrollToBottomButtonVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func paginateBackwardsIfNeeded() {
|
||||||
|
guard let paginateAction = viewState.paginateAction,
|
||||||
|
let scrollView = scrollViewAdapter.scrollView,
|
||||||
|
viewState.canBackPaginate,
|
||||||
|
!viewState.isBackPaginating else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let visibleHeight = scrollView.visibleSize.height
|
||||||
|
let contentHeight = scrollView.contentSize.height
|
||||||
|
let offset = scrollView.contentOffset.y + scrollView.contentInset.top
|
||||||
|
let threshold = contentHeight - visibleHeight * 2
|
||||||
|
|
||||||
|
guard offset > threshold else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paginateAction()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|||||||
@@ -16,17 +16,27 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RoomTimelineItemView: View {
|
struct RoomTimelineItemView: View {
|
||||||
@ObservedObject var viewModel: RoomTimelineItemViewModel
|
@EnvironmentObject private var context: RoomScreenViewModel.Context
|
||||||
|
let viewState: RoomTimelineItemViewState
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
timelineView
|
timelineView
|
||||||
.environment(\.timelineGroupStyle, viewModel.groupStyle)
|
.environmentObject(context)
|
||||||
.animation(.elementDefault, value: viewModel.type)
|
.environment(\.timelineGroupStyle, viewState.groupStyle)
|
||||||
.animation(.elementDefault, value: viewModel.groupStyle)
|
.onAppear {
|
||||||
|
context.send(viewAction: .itemAppeared(itemID: viewState.identifier))
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
context.send(viewAction: .itemDisappeared(itemID: viewState.identifier))
|
||||||
|
}
|
||||||
|
.environment(\.openURL, OpenURLAction { url in
|
||||||
|
context.send(viewAction: .linkClicked(url: url))
|
||||||
|
return .systemAction
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private var timelineView: some View {
|
@ViewBuilder private var timelineView: some View {
|
||||||
switch viewModel.type {
|
switch viewState.type {
|
||||||
case .text(let item):
|
case .text(let item):
|
||||||
TextRoomTimelineView(timelineItem: item)
|
TextRoomTimelineView(timelineItem: item)
|
||||||
case .separator(let item):
|
case .separator(let item):
|
||||||
@@ -69,6 +79,6 @@ struct RoomTimelineItemView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var timelineGroupStyle: TimelineGroupStyle {
|
var timelineGroupStyle: TimelineGroupStyle {
|
||||||
viewModel.groupStyle
|
viewState.groupStyle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-15
@@ -16,25 +16,18 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class RoomTimelineItemViewModel: Identifiable, Equatable, ObservableObject {
|
struct RoomTimelineItemViewState: Identifiable, Equatable {
|
||||||
static func == (lhs: RoomTimelineItemViewModel, rhs: RoomTimelineItemViewModel) -> Bool {
|
let type: RoomTimelineItemType
|
||||||
lhs.type == rhs.type && lhs.groupStyle == rhs.groupStyle
|
let groupStyle: TimelineGroupStyle
|
||||||
}
|
|
||||||
|
|
||||||
@Published var type: RoomTimelineItemType
|
/// Contains all the identification info of the item, `timelineID`, `eventID` and `transactionID`
|
||||||
@Published var groupStyle: TimelineGroupStyle
|
var identifier: TimelineItemIdentifier {
|
||||||
|
|
||||||
var id: TimelineItemIdentifier {
|
|
||||||
type.id
|
type.id
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) {
|
/// The `timelineID` of the item, used for the timeline view level identification, do not use for any business logic use `identifier` instead
|
||||||
self.init(type: .init(item: item), groupStyle: groupStyle)
|
var id: String {
|
||||||
}
|
identifier.timelineID
|
||||||
|
|
||||||
init(type: RoomTimelineItemType, groupStyle: TimelineGroupStyle) {
|
|
||||||
self.type = type
|
|
||||||
self.groupStyle = groupStyle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var isReactable: Bool {
|
var isReactable: Bool {
|
||||||
@@ -42,6 +35,12 @@ final class RoomTimelineItemViewModel: Identifiable, Equatable, ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension RoomTimelineItemViewState {
|
||||||
|
init(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) {
|
||||||
|
self.init(type: .init(item: item), groupStyle: groupStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum RoomTimelineItemType: Equatable {
|
enum RoomTimelineItemType: Equatable {
|
||||||
case text(TextRoomTimelineItem)
|
case text(TextRoomTimelineItem)
|
||||||
case separator(SeparatorRoomTimelineItem)
|
case separator(SeparatorRoomTimelineItem)
|
||||||
@@ -51,9 +51,9 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
userIndicatorController: userIndicatorControllerMock)
|
userIndicatorController: userIndicatorControllerMock)
|
||||||
|
|
||||||
// Then the messages should be grouped together.
|
// Then the messages should be grouped together.
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .middle, "Nothing should prevent the middle message from being grouped.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the middle message from being grouped.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMessageGroupingMultipleSenders() {
|
func testMessageGroupingMultipleSenders() {
|
||||||
@@ -84,12 +84,12 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
userIndicatorController: userIndicatorControllerMock)
|
userIndicatorController: userIndicatorControllerMock)
|
||||||
|
|
||||||
// Then the messages should be grouped by sender.
|
// Then the messages should be grouped by sender.
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .single, "A message should not be grouped when the sender changes.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "A message should not be grouped when the sender changes.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .single, "A message should not be grouped when the sender changes.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .single, "A message should not be grouped when the sender changes.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[3].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[3].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[4].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[4].groupStyle, .first, "A group should start with a new sender if there are more messages from that sender.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[5].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[5].groupStyle, .last, "A group should be ended when the sender changes in the next message.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMessageGroupingWithLeadingReactions() {
|
func testMessageGroupingWithLeadingReactions() {
|
||||||
@@ -115,9 +115,9 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
userIndicatorController: userIndicatorControllerMock)
|
userIndicatorController: userIndicatorControllerMock)
|
||||||
|
|
||||||
// Then the first message should not be grouped but the other two should.
|
// Then the first message should not be grouped but the other two should.
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .single, "When the first message has reactions it should not be grouped.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .single, "When the first message has reactions it should not be grouped.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .first, "A new group should be made when the preceding message has reactions.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .first, "A new group should be made when the preceding message has reactions.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Nothing should prevent the last message from being grouped.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMessageGroupingWithInnerReactions() {
|
func testMessageGroupingWithInnerReactions() {
|
||||||
@@ -143,9 +143,9 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
userIndicatorController: userIndicatorControllerMock)
|
userIndicatorController: userIndicatorControllerMock)
|
||||||
|
|
||||||
// Then the first and second messages should be grouped and the last one should not.
|
// Then the first and second messages should be grouped and the last one should not.
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .last, "When the message has reactions, the group should end here.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .last, "When the message has reactions, the group should end here.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .single, "The last message should not be grouped when the preceding message has reactions.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .single, "The last message should not be grouped when the preceding message has reactions.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMessageGroupingWithTrailingReactions() {
|
func testMessageGroupingWithTrailingReactions() {
|
||||||
@@ -171,9 +171,9 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
userIndicatorController: userIndicatorControllerMock)
|
userIndicatorController: userIndicatorControllerMock)
|
||||||
|
|
||||||
// Then the messages should be grouped together.
|
// Then the messages should be grouped together.
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[0].groupStyle, .first, "Nothing should prevent the first message from being grouped.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.")
|
||||||
XCTAssertEqual(viewModel.state.itemViewModels[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testGoToUserDetailsSuccessNoDelay() async {
|
func testGoToUserDetailsSuccessNoDelay() async {
|
||||||
|
|||||||
Reference in New Issue
Block a user