From 2cec1858ffeaeb7199e400f6b00ed116a14edf59 Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 10 Jul 2023 16:13:58 +0100 Subject: [PATCH] Add expand/collapse UI for reactions (#1249) * Add expand/collapse UI for reactions - Adds a CollapsibleFlowLayout for controlling the layout - Adds tests for this layout and some mocks for testing layouts generally - Improves the rendering of the reaction buttons which were not pixel perfect - Adds the UI for the expand collapse buttons including the count of hidden items in the collapsed state. * Add comment for reactionsCollapsed binding. * Remove Flow and simplify implementation - Remove SwiftUI-Flow - Add strings by importing from Localyse - Remove count on expand button as requires GeometryReader and can cause loops - Don't use GeometryReader for hiding reactions with opacity(just put them way off screen for now) - Fix unit and UI tests * Address PR comments - use synthesized inits - use rows rather than lines for naming flow layout - other naming improvements - reactions were already rendered in another ui test, removing my test on favour of those and updating the screenshots for those. --- ElementX.xcodeproj/project.pbxproj | 45 ++-- .../xcshareddata/swiftpm/Package.resolved | 18 -- .../Mocks/AggregatedReactionMock.swift | 10 +- .../CollapsibleFlowLayout.swift | 203 ++++++++++++++++++ .../SwiftUI/Layout/ViewFrameReader.swift | 3 +- .../Screens/RoomScreen/RoomScreenModels.swift | 4 + .../RoomScreen/RoomScreenViewModel.swift | 15 +- .../Style/TimelineItemBubbledStylerView.swift | 14 +- .../Style/TimelineItemPlainStylerView.swift | 12 +- .../Supplementary/TimelineReactionsView.swift | 126 +++++++---- .../Fixtures/RoomTimelineItemFixtures.swift | 20 +- .../RoomTimelineItemFactory.swift | 8 +- ElementX/SupportingFiles/target.yml | 1 - UITests/Sources/RoomScreenUITests.swift | 6 - ...9th-generation.roomEncryptedWithAvatar.png | 4 +- ...-iPad-9th-generation.roomPlainNoAvatar.png | 4 +- ...eration.roomSmallTimelineWithReactions.png | 4 +- ...n-GB-iPhone-14.roomEncryptedWithAvatar.png | 4 +- .../en-GB-iPhone-14.roomPlainNoAvatar.png | 4 +- ...hone-14.roomSmallTimelineWithReactions.png | 4 +- ...9th-generation.roomEncryptedWithAvatar.png | 4 +- ...-iPad-9th-generation.roomPlainNoAvatar.png | 4 +- ...eration.roomSmallTimelineWithReactions.png | 4 +- ...eudo-iPhone-14.roomEncryptedWithAvatar.png | 4 +- .../pseudo-iPhone-14.roomPlainNoAvatar.png | 4 +- ...hone-14.roomSmallTimelineWithReactions.png | 4 +- .../CollapsibleFlowLayoutTests.swift | 137 ++++++++++++ .../Sources/LayoutTests/LayoutMocks.swift | 80 +++++++ project.yml | 3 - 29 files changed, 610 insertions(+), 143 deletions(-) create mode 100644 ElementX/Sources/Other/CollapsibleFlowLayout/CollapsibleFlowLayout.swift create mode 100644 UnitTests/Sources/LayoutTests/CollapsibleFlowLayoutTests.swift create mode 100644 UnitTests/Sources/LayoutTests/LayoutMocks.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index b293b6ff7..f028c185a 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -147,7 +147,6 @@ 368C8758FCD079E6AAA18C2C /* NoticeRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */; }; 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */; }; 36AD4DD4C798E22584ED3200 /* Version in Frameworks */ = {isa = PBXBuildFile; productRef = A05AF81DDD14AD58CB0E1B9B /* Version */; }; - 36CD6E11B37396E14F032CB6 /* Flow in Frameworks */ = {isa = PBXBuildFile; productRef = 4D7E6BFC89715FC3CF0349D0 /* Flow */; }; 37D789F24199B32E3FD1AA7B /* FileRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 216F0DDC98F2A2C162D09C28 /* FileRoomTimelineItemContent.swift */; }; 383055C6ABE5BE058CEE1DDB /* WelcomeScreenScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FE5EF0AFFE360C66420AAE /* WelcomeScreenScreenCoordinator.swift */; }; 38546A6010A2CF240EC9AF73 /* BindableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */; }; @@ -504,6 +503,7 @@ B245583C63F8F90357B87FAE /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = A2AE110B053B55E38F8D10C7 /* KZFileWatchers */; }; B27D3190784F85916DA1C394 /* SessionVerificationScreenStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1EE0908B2BF9212436AD3E /* SessionVerificationScreenStateMachine.swift */; }; B2F8E01ABA1BA30265B4ECBE /* RoundedCornerShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 839E2C35DF3F9C7B54C3CE49 /* RoundedCornerShape.swift */; }; + B3490F3DB563A543C73CD663 /* CollapsibleFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD349FC7BB07EFCA64851D2 /* CollapsibleFlowLayout.swift */; }; B3D652AA1654270742072FB3 /* DeveloperOptionsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A6F283BC574FDB96ABBB07 /* DeveloperOptionsScreenViewModel.swift */; }; B3EDDEC1839BB5A3747624BB /* FormButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95A1CCDEE545CB6453B084BF /* FormButtonStyles.swift */; }; B402708F8728DD0DB7C324E2 /* StartChatScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78910787F967CBC6042A101E /* StartChatScreenViewModelProtocol.swift */; }; @@ -512,6 +512,7 @@ B46EBC7B96CCB64FF8E110DC /* MigrationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CD0FAE9EA761DA175D31CC7 /* MigrationScreenModels.swift */; }; B4A0C69370E6008A971463E7 /* BugReportScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C89820BB2B88D4EA28131C /* BugReportScreenViewModelProtocol.swift */; }; B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3DFE5B444F131648066F05 /* StateStoreViewModel.swift */; }; + B5321A1F5B26A0F3EC54909E /* CollapsibleFlowLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */; }; B5479997ECC516C121E6625E /* LocationMarkerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFECCE59967018204876D0A5 /* LocationMarkerView.swift */; }; B5903E48CF43259836BF2DBF /* EncryptedRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C1BCB9E83B09A45387FCA2 /* EncryptedRoomTimelineView.swift */; }; B5E455C9689EA600EDB3E9E0 /* NavigationRootCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */; }; @@ -584,6 +585,7 @@ CB99B0FA38A4AC596F38CC13 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; }; CBA9EDF305036039166E76FF /* StartChatScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */; }; CC0D088F505F33A20DC5590F /* RoomStateEventStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEEAFB646E583655652C3D04 /* RoomStateEventStringBuilderTests.swift */; }; + CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */; }; CCAA0671B46EAFD0BB528E2C /* apple_emojis_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8FC26871038FB0E4AAE22605 /* apple_emojis_data.json */; }; CCBEC2100CAF2EEBE9DB4156 /* TemplateScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA40B98B098B6F0371B750B3 /* TemplateScreenModels.swift */; }; CCC3802A3C019A6FFAAA547A /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E65E613F057697A1A0BC03 /* NotificationViewController.swift */; }; @@ -1225,6 +1227,7 @@ ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = ""; }; AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageCache.swift; sourceTree = ""; }; AC3F82523D6F48B926D6AF68 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayoutTests.swift; sourceTree = ""; }; ACCC1874C122E2BBE648B8F5 /* LegalInformationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenUITests.swift; sourceTree = ""; }; AD378D580A41E42560C60E9C /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; ADB35E2DB4EFE8E6F3959629 /* InviteUsersScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenUITests.swift; sourceTree = ""; }; @@ -1271,12 +1274,14 @@ BB3073CCD77D906B330BC1D6 /* Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests.swift; sourceTree = ""; }; BB33A751BFDA223BDD106EC0 /* OnboardingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingModels.swift; sourceTree = ""; }; BB8BC4C791D0E88CFCF4E5DF /* ServerSelectionScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenCoordinator.swift; sourceTree = ""; }; + BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutMocks.swift; sourceTree = ""; }; BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNNotificationContent.swift; sourceTree = ""; }; BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposerTextField.swift; sourceTree = ""; }; BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemReplyDetails.swift; sourceTree = ""; }; BEA38B9851CFCC4D67F5587D /* EmojiPickerScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerScreenCoordinator.swift; sourceTree = ""; }; BEBA759D1347CFFB3D84ED1F /* UserSessionStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreProtocol.swift; sourceTree = ""; }; BF34A2FD6797535C95AC918D /* PlaceholderScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderScreenCoordinator.swift; sourceTree = ""; }; + BFD349FC7BB07EFCA64851D2 /* CollapsibleFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayout.swift; sourceTree = ""; }; BFDCAC6CAAD65A2C24EA9C4B /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = ""; }; BFEA446F8618DBA79A9239CC /* MessageForwardingScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreen.swift; sourceTree = ""; }; C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXAttributeScope.swift; sourceTree = ""; }; @@ -1525,7 +1530,6 @@ 754602A7B2AAD443C4228ED4 /* Sentry in Frameworks */, B0CB16349B96262AA65A04AF /* URLRouting in Frameworks */, 36AD4DD4C798E22584ED3200 /* Version in Frameworks */, - 36CD6E11B37396E14F032CB6 /* Flow in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2445,6 +2449,7 @@ 851EF6258DF8B7EF129DC3AC /* WelcomeScreenScreenViewModelTests.swift */, 53280D2292E6C9C7821773FD /* UserSession */, 70C5B842301AC281DF374E41 /* Extensions */, + A6AA0A048CAE428A5CA4CBBB /* LayoutTests */, 7583EAC171059A86B767209F /* MediaProvider */, 7DBC911559934065993A5FF4 /* NotificationManager */, 1C62F5382CC9D9F7DCEC344A /* UserDiscoveryService */, @@ -2851,6 +2856,14 @@ path = View; sourceTree = ""; }; + 9F7C2D63C42828D8931D5286 /* CollapsibleFlowLayout */ = { + isa = PBXGroup; + children = ( + BFD349FC7BB07EFCA64851D2 /* CollapsibleFlowLayout.swift */, + ); + path = CollapsibleFlowLayout; + sourceTree = ""; + }; 9FD8D798D879069243A7E7F7 /* View */ = { isa = PBXGroup; children = ( @@ -2923,6 +2936,15 @@ path = WaitlistScreen; sourceTree = ""; }; + A6AA0A048CAE428A5CA4CBBB /* LayoutTests */ = { + isa = PBXGroup; + children = ( + AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */, + BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */, + ); + path = LayoutTests; + sourceTree = ""; + }; A78C2592419CA4C76FBA8FD2 /* Application */ = { isa = PBXGroup; children = ( @@ -3132,6 +3154,7 @@ 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */, 35FA991289149D31F4286747 /* UserPreference.swift */, 7431C962E314ADAE38B6D708 /* Analytics */, + 9F7C2D63C42828D8931D5286 /* CollapsibleFlowLayout */, 349FE0C25B41C7AC9B7C623F /* EffectsScene */, 44BBB96FAA2F0D53C507396B /* Extensions */, 8F9A844EB44B6AD7CA18FD96 /* HTMLParsing */, @@ -3627,7 +3650,6 @@ 7731767AE437BA3BD2CC14A8 /* Sentry */, E9BAB8A793FE3B54CDD47102 /* URLRouting */, A05AF81DDD14AD58CB0E1B9B /* Version */, - 4D7E6BFC89715FC3CF0349D0 /* Flow */, ); productName = ElementX; productReference = 4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */; @@ -3741,7 +3763,6 @@ 9754C4B03F6255F67FC15E52 /* XCRemoteSwiftPackageReference "compound-ios" */, C13F55E4518415CB4C278E73 /* XCRemoteSwiftPackageReference "DTCoreText" */, D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */, - 65398401562A467FD2FDCF20 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */, 701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */, 9A472EE0218FE7DCF5283429 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 395DE6AE429B7ACC7C7FE31D /* XCRemoteSwiftPackageReference "KZFileWatchers" */, @@ -3980,6 +4001,7 @@ 0F9E38A75337D0146652ACAB /* BackgroundTaskTests.swift in Sources */, 7F61F9ACD5EC9E845EF3EFBF /* BugReportServiceTests.swift in Sources */, C7CFDB4929DDD9A3B5BA085D /* BugReportViewModelTests.swift in Sources */, + B5321A1F5B26A0F3EC54909E /* CollapsibleFlowLayoutTests.swift in Sources */, D3FD96913D2B1AAA3149DAC7 /* CreateRoomViewModelTests.swift in Sources */, CD0088B763CD970CF1CBF8CB /* DateTests.swift in Sources */, 864C69CF951BF36D25BE0C03 /* DeveloperOptionsScreenViewModelTests.swift in Sources */, @@ -3994,6 +4016,7 @@ A216C83ADCF32BA5EF8A6FBC /* InviteUsersViewModelTests.swift in Sources */, 266C4DF893F2947DCCEF327B /* InvitesScreenViewModelTests.swift in Sources */, EEC40663922856C65D1E0DF5 /* KeychainControllerTests.swift in Sources */, + CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */, 8AC256AF0EC54658321C9241 /* LegalInformationScreenViewModelTests.swift in Sources */, 0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */, 664F77F02A57617A00FB9B24 /* XCTestCase.swift in Sources */, @@ -4130,6 +4153,7 @@ 520EEDAFBC778AB0B41F2F53 /* ClientMock.swift in Sources */, 1950A80CD198BED283DFC2CE /* ClientProxy.swift in Sources */, 24BDDD09A90B8BFE3793F3AA /* ClientProxyProtocol.swift in Sources */, + B3490F3DB563A543C73CD663 /* CollapsibleFlowLayout.swift in Sources */, 9FAF6DA7E8E85C9699757764 /* CollapsibleRoomTimelineView.swift in Sources */, 0DC815CA24E1BD7F408F37D3 /* CollapsibleTimelineItem.swift in Sources */, 663E198678778F7426A9B27D /* Collection.swift in Sources */, @@ -5178,14 +5202,6 @@ minimumVersion = 4.2.0; }; }; - 65398401562A467FD2FDCF20 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/tevelee/SwiftUI-Flow"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 1.2.0; - }; - }; 6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ReactKit/SwiftState"; @@ -5384,11 +5400,6 @@ package = 96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */; productName = PostHog; }; - 4D7E6BFC89715FC3CF0349D0 /* Flow */ = { - isa = XCSwiftPackageProductDependency; - package = 65398401562A467FD2FDCF20 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */; - productName = Flow; - }; 50009897F60FAE7D63EF5E5B /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; package = D283517192CAC3E2E6920765 /* XCRemoteSwiftPackageReference "Kingfisher" */; diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 493fd82d2..a45c37c8a 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -142,15 +142,6 @@ "version" : "1.0.0" } }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", - "state" : { - "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", - "version" : "1.2.2" - } - }, { "identity" : "swift-case-paths", "kind" : "remoteSourceControl", @@ -214,15 +205,6 @@ "version" : "6.0.1" } }, - { - "identity" : "swiftui-flow", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tevelee/SwiftUI-Flow", - "state" : { - "revision" : "d592b610a92869b74ede8ee4c0435a29219be9d8", - "version" : "1.2.0" - } - }, { "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", diff --git a/ElementX/Sources/Mocks/AggregatedReactionMock.swift b/ElementX/Sources/Mocks/AggregatedReactionMock.swift index 5694aa4c9..bf8a56880 100644 --- a/ElementX/Sources/Mocks/AggregatedReactionMock.swift +++ b/ElementX/Sources/Mocks/AggregatedReactionMock.swift @@ -50,7 +50,15 @@ extension AggregatedReaction { AggregatedReaction(accountOwnerID: alice, key: "🚀", senders: [alice] + mockIds(3)), AggregatedReaction(accountOwnerID: alice, key: "😇", senders: mockIds(2)), AggregatedReaction(accountOwnerID: alice, key: "🤭", senders: [alice] + mockIds(8)), - AggregatedReaction(accountOwnerID: alice, key: "🫤", senders: mockIds(10)) + AggregatedReaction(accountOwnerID: alice, key: "🫤", senders: mockIds(10)), + AggregatedReaction(accountOwnerID: alice, key: "🐶", senders: mockIds(1)), + AggregatedReaction(accountOwnerID: alice, key: "🐱", senders: mockIds(1)), + AggregatedReaction(accountOwnerID: alice, key: "🐭", senders: mockIds(1)), + AggregatedReaction(accountOwnerID: alice, key: "🐹", senders: mockIds(1)), + AggregatedReaction(accountOwnerID: alice, key: "🐰", senders: mockIds(1)), + AggregatedReaction(accountOwnerID: alice, key: "🦊", senders: mockIds(1)), + AggregatedReaction(accountOwnerID: alice, key: "🐻", senders: mockIds(1)), + AggregatedReaction(accountOwnerID: alice, key: "🐼", senders: mockIds(1)) ] } } diff --git a/ElementX/Sources/Other/CollapsibleFlowLayout/CollapsibleFlowLayout.swift b/ElementX/Sources/Other/CollapsibleFlowLayout/CollapsibleFlowLayout.swift new file mode 100644 index 000000000..c09bee33e --- /dev/null +++ b/ElementX/Sources/Other/CollapsibleFlowLayout/CollapsibleFlowLayout.swift @@ -0,0 +1,203 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import SwiftUI + +/// A flow layout that will show a collapse/expand button when the layout wraps over a defined number of rows. +/// With n subviews passed to the layout, n-1 first views represent the main views to be laid out. +/// The nth subview is the collapse/expand button which is only shown when the layout overflows `rowsBeforeCollapsible` number of rows. +/// When the button is shown it is tagged on the end of the collapsed or expanded layout. +struct CollapsibleFlowLayout: Layout { + static let pointOffscreen = CGPoint(x: -10000, y: -10000) + /// The horizontal spacing between items + var itemSpacing: CGFloat = 0 + /// The vertical spacing between rows + var rowSpacing: CGFloat = 0 + /// Whether the layout should display in expanded or collapsed state + var collapsed = true + /// The number of rows before the collapse/expand button is shown + var rowsBeforeCollapsible: Int? + + func sizeThatFits(proposal: ProposedViewSize, subviews: some FlowLayoutSubviews, cache: inout ()) -> CGSize { + let collapseButton = subviews[subviews.count - 1] + var subviewsWithoutCollapseButton = subviews + subviewsWithoutCollapseButton.removeLast() + // Calculate the layout of the rows without the button + let rowsNoButton = calculateRows(proposal: proposal, subviews: Array(subviewsWithoutCollapseButton)) + + // If we have extended beyond the defined number of rows we are showing the expand/collapse ui + if let rowsBeforeCollapsible, rowsNoButton.count > rowsBeforeCollapsible { + if collapsed { + // Truncate to `rowsBeforeCollapsible` number of rows and replace the item at the end of the last row with the button + let collapsedRows = Array(rowsNoButton.prefix(rowsBeforeCollapsible)) + let (collapsedRowsWithButton, _) = replaceTrailingItemsWithButton(rowWidth: proposal.width ?? 0, rows: collapsedRows, button: collapseButton) + let size = sizeThatFits(proposal: proposal, rows: collapsedRowsWithButton) + return size + } else { + // Show all subviews with the button at the end + let rowsWithButton = calculateRows(proposal: proposal, subviews: Array(subviews)) + let size = sizeThatFits(proposal: proposal, rows: rowsWithButton) + return size + } + } else { + // Otherwise we are just calculating the size of all items without the button + return sizeThatFits(proposal: proposal, rows: rowsNoButton) + } + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: some FlowLayoutSubviews, cache: inout ()) { + let collapseButton = subviews[subviews.count - 1] + var subviewsWithoutCollapseButton = subviews + subviewsWithoutCollapseButton.removeLast() + // Calculate the layout of the rows without the button + let rowsNoButton = calculateRows(proposal: ProposedViewSize(bounds.size), subviews: Array(subviewsWithoutCollapseButton)) + // If we have extended beyond the defined number of rows we are showing the expand/collapse ui + if let rowsBeforeCollapsible, rowsNoButton.count > rowsBeforeCollapsible { + if collapsed { + // Truncate to `rowsBeforeCollapsible` number of rows and replace the item at the end of the last row with the button + let collapsedRows = Array(rowsNoButton.prefix(rowsBeforeCollapsible)) + let (collapsedRowsWithButton, subviewsToHide) = replaceTrailingItemsWithButton(rowWidth: bounds.width, rows: collapsedRows, button: collapseButton) + let remainingSubviews = subviewsToHide + Array(rowsNoButton.suffix(rowsNoButton.count - rowsBeforeCollapsible)).joined() + placeSubviews(in: bounds, rows: collapsedRowsWithButton) + // "Remove" (place with a proposed zero frame) any additional subviews + remainingSubviews.forEach { subview in + subview.place(at: Self.pointOffscreen, anchor: .leading, proposal: .zero) + } + + } else { + // Show all subviews with the button at the end + let rowsWithButton = calculateRows(proposal: ProposedViewSize(bounds.size), subviews: Array(subviews)) + placeSubviews(in: bounds, rows: rowsWithButton) + } + } else { + // Otherwise we are just calculating the size of all items without the button + placeSubviews(in: bounds, rows: rowsNoButton) + // "Remove"(place with a proposed zero frame) the button + collapseButton.place(at: Self.pointOffscreen, anchor: .leading, proposal: .zero) + } + } + + /// Given a proposed size and a flat list of subviews, calculates and returns a structure representing + /// how the subviews should wrap on to multiple rows given the size's width. + /// - Parameters: + /// - proposal: The proposed size + /// - subviews: The subviews + /// - Returns: A 2d array, the first dimension representing the rows, the second being the items per row. + private func calculateRows(proposal: ProposedViewSize, subviews: [FlowLayoutSubview]) -> [[FlowLayoutSubview]] { + var rows = [[FlowLayoutSubview]]() + var currentRow = [FlowLayoutSubview]() + + var rowX: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + let horizontalSpacing = currentRow.isEmpty ? 0 : itemSpacing + // If the current view does not fine on this row bump to the next + if rowX + size.width > proposal.width ?? 0 { + rows.append(currentRow) + currentRow = [LayoutSubview]() + rowX = 0 + } + rowX += horizontalSpacing + size.width + currentRow.append(subview) + } + // If there are items in the current row remember to append it to the returned value + if currentRow.count > 0 { + rows.append(currentRow) + } + return rows + } + + /// Given a list of rows calculate the size needed to display them + /// - Parameters: + /// - proposal: The proposed size + /// - rows: The list of rows + /// - Returns: The size render the rows + private func sizeThatFits(proposal: ProposedViewSize, rows: [[FlowLayoutSubview]]) -> CGSize { + rows.enumerated().reduce(CGSize.zero) { partialResult, rowItem in + let (rowIndex, row) = rowItem + let rowSize = row.enumerated().reduce(CGSize.zero) { partialResult, subviewItem in + let (subviewIndex, subview) = subviewItem + let size = subview.sizeThatFits(.unspecified) + let horizontalSpacing = subviewIndex == 0 ? 0 : itemSpacing + return CGSize(width: partialResult.width + size.width + horizontalSpacing, height: max(partialResult.height, size.height)) + } + let verticalSpacing = rowIndex == 0 ? 0 : rowSpacing + return CGSize(width: max(partialResult.width, rowSize.width), height: partialResult.height + rowSize.height + verticalSpacing) + } + } + + /// Used to render the collapsed state, this takes the rows inputted and adds the button to the last row, + /// removing only as many trailing subviews as needed to make space for it. It also returns the items removed. + /// - Parameters: + /// - rowWidth: The width of the parent + /// - rows: The input list of rows + /// - button: The button to replace the trailing items + /// - Returns: The new rows structure with button replaced and the subviews remove from the input to make space for the button + private func replaceTrailingItemsWithButton(rowWidth: CGFloat, rows: [[FlowLayoutSubview]], button: FlowLayoutSubview) -> ([[FlowLayoutSubview]], [FlowLayoutSubview]) { + var rows = rows + let lastRow = rows[rows.count - 1] + let buttonSize = button.sizeThatFits(.unspecified) + var rowX: CGFloat = 0 + for (i, subview) in lastRow.enumerated() { + let size = subview.sizeThatFits(.unspecified) + let horizontalSpacing = i == 0 ? 0 : itemSpacing + rowX += size.width + horizontalSpacing + if rowX > (rowWidth - (buttonSize.width + horizontalSpacing)) { + let lastRowWithButton = Array(lastRow.prefix(i)) + [button] + let subviewsToHide = Array(lastRow.suffix(lastRow.count - i)) + rows[rows.count - 1] = lastRowWithButton + return (rows, subviewsToHide) + } + } + let lastRowWithButton = Array(lastRow) + [button] + rows[rows.count - 1] = lastRowWithButton + return (rows, []) + } + + /// Given a list of rows place them in the layout. + /// - Parameters: + /// - bounds: The bounds of the parent + /// - rows: The input row structure. + private func placeSubviews(in bounds: CGRect, rows: [[FlowLayoutSubview]]) { + var rowY: CGFloat = bounds.minY + var rowHeight: CGFloat = 0 + for (i, row) in rows.enumerated() { + var rowX: CGFloat = bounds.minX + let verticalSpacing = i == 0 ? 0 : rowSpacing + for (j, subview) in row.enumerated() { + let size = subview.sizeThatFits(.unspecified) + let horizontalSpacing = j == 0 ? 0 : itemSpacing + let point = CGPoint(x: rowX + horizontalSpacing, y: rowY + verticalSpacing + (size.height / 2)) + subview.place(at: point, anchor: .leading, proposal: ProposedViewSize(size)) + rowHeight = max(rowHeight, size.height) + rowX += size.width + horizontalSpacing + } + rowY += rowHeight + verticalSpacing + } + } +} + +/// A protocol representing subviews so that we can inject mocks in unit tests. +protocol FlowLayoutSubviews: RandomAccessCollection where Element: FlowLayoutSubview, Index == Int, SubSequence == Self { } + +extension LayoutSubviews: FlowLayoutSubviews { } + +/// A protocol representing a subview so that we can inject mocks in unit tests. +protocol FlowLayoutSubview { + func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize + func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) +} + +extension LayoutSubview: FlowLayoutSubview { } diff --git a/ElementX/Sources/Other/SwiftUI/Layout/ViewFrameReader.swift b/ElementX/Sources/Other/SwiftUI/Layout/ViewFrameReader.swift index 9a9d08ea2..fdb198c7b 100644 --- a/ElementX/Sources/Other/SwiftUI/Layout/ViewFrameReader.swift +++ b/ElementX/Sources/Other/SwiftUI/Layout/ViewFrameReader.swift @@ -28,12 +28,13 @@ import SwiftUI /// ``` struct ViewFrameReader: View { @Binding var frame: CGRect + let coordinateSpace: CoordinateSpace = .local var body: some View { GeometryReader { geometry in Color.clear .preference(key: FramePreferenceKey.self, - value: geometry.frame(in: .local)) + value: geometry.frame(in: coordinateSpace)) } .onPreferenceChange(FramePreferenceKey.self) { newValue in guard frame != newValue else { return } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index b135d581d..82ccaa166 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -127,6 +127,10 @@ struct RoomScreenViewStateBindings { } } + /// The state of wether reactions listed on the timeline are expanded/collapsed. + /// Key is itemID, value is the collapsed state. + var reactionsCollapsed: [String: Bool] + /// A media item that will be previewed with QuickLook. var mediaPreviewItem: MediaPreviewItem? diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 51c612ecd..c9ba1f618 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -52,7 +52,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol timelineStyle: appSettings.timelineStyle, readReceiptsEnabled: appSettings.readReceiptsEnabled, isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom, - bindings: .init(composerText: "", composerFocused: false)), + bindings: .init(composerText: "", composerFocused: false, reactionsCollapsed: [:])), imageProvider: mediaProvider) setupSubscriptions() @@ -656,6 +656,19 @@ private extension RoomProxyProtocol { } } +extension RoomScreenViewModel.Context { + /// A function to make it easier to bind to reactions expand/collapsed state + /// - Parameter itemID: The id of the timeline item the reacted to + /// - Returns: Wether the reactions should show in the collapsed state, true by default. + func reactionsCollapsedBinding(for itemID: String) -> Binding { + Binding(get: { + self.reactionsCollapsed[itemID] ?? true + }, set: { + self.reactionsCollapsed[itemID] = $0 + }) + } +} + // MARK: - Mocks extension RoomScreenViewModel { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 6ca666e0d..d3116993b 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -109,14 +109,12 @@ struct TimelineItemBubbledStylerView: View { .accessibilityElement(children: .combine) if !timelineItem.properties.reactions.isEmpty { - TimelineReactionsView(reactions: timelineItem.properties.reactions) { key in - context.send(viewAction: .toggleReaction(key: key, eventID: timelineItem.id)) - } showReactionSummary: { key in - context.send(viewAction: .reactionSummary(itemID: timelineItem.id, key: key)) - } - .environment(\.layoutDirection, reactionsLayoutDirection) - // Workaround to stop the message long press stealing the touch from the reaction buttons - .onTapGesture { } + TimelineReactionsView(itemID: timelineItem.id, + reactions: timelineItem.properties.reactions, + collapsed: context.reactionsCollapsedBinding(for: timelineItem.id)) + .environment(\.layoutDirection, reactionsLayoutDirection) + // Workaround to stop the message long press stealing the touch from the reaction buttons + .onTapGesture { } } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index 12a40ade5..bb3267522 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -117,13 +117,11 @@ struct TimelineItemPlainStylerView: View { } if !timelineItem.properties.reactions.isEmpty { - TimelineReactionsView(reactions: timelineItem.properties.reactions) { key in - context.send(viewAction: .toggleReaction(key: key, eventID: timelineItem.id)) - } showReactionSummary: { key in - context.send(viewAction: .reactionSummary(itemID: timelineItem.id, key: key)) - } - // Workaround to stop the message long press stealing the touch from the reaction buttons - .onTapGesture { } + TimelineReactionsView(itemID: timelineItem.id, + reactions: timelineItem.properties.reactions, + collapsed: context.reactionsCollapsedBinding(for: timelineItem.id)) + // Workaround to stop the message long press stealing the touch from the reaction buttons + .onTapGesture { } } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift index 8a6e4503e..1f171df3e 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift @@ -14,34 +14,90 @@ // limitations under the License. // -import Flow import SwiftUI struct TimelineReactionsView: View { - @Environment(\.layoutDirection) var layoutDirection: LayoutDirection + /// We use a coordinate space for measuring the reactions within their container. + /// For some reason when using .local the origin of reactions always shown as (0, 0) + private static let flowCoordinateSpace = "flowCoordinateSpace" + private static let horizontalSpacing: CGFloat = 4 + private static let verticalSpacing: CGFloat = 4 + @EnvironmentObject private var context: RoomScreenViewModel.Context + @Environment(\.layoutDirection) private var layoutDirection: LayoutDirection + + let itemID: String let reactions: [AggregatedReaction] - let toggleReaction: (String) -> Void - let showReactionSummary: (String) -> Void - + @Binding var collapsed: Bool + var body: some View { - HFlow(itemSpacing: 4, rowSpacing: 4) { + CollapsibleFlowLayout(itemSpacing: 4, rowSpacing: 4, collapsed: collapsed, rowsBeforeCollapsible: 2) { ForEach(reactions, id: \.self) { reaction in - TimelineReactionButton(reaction: reaction, - toggleReaction: toggleReaction, - showReactionSummary: showReactionSummary) + TimelineReactionButton(itemID: itemID, reaction: reaction) { key in + context.send(viewAction: .toggleReaction(key: key, eventID: itemID)) + } showReactionSummary: { key in + context.send(viewAction: .reactionSummary(itemID: itemID, key: key)) + } + } + Button { + collapsed.toggle() + } label: { + TimelineCollapseButtonLabel(collapsed: collapsed) } } - .environment(\.layoutDirection, layoutDirection) + .coordinateSpace(name: Self.flowCoordinateSpace) + } +} + +/// The pill shape for the label that surrounds both the reaction and collapse buttons. +struct TimelineReactionButtonLabel: View { + var isHighlighted = false + @ViewBuilder var content: () -> Content + + var body: some View { + HStack(spacing: 4) { + content() + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background(backgroundShape.inset(by: 1).fill(overlayBackgroundColor)) + .overlay(backgroundShape.inset(by: 2.0).strokeBorder(overlayBorderColor)) + .overlay(backgroundShape.strokeBorder(Color.compound.bgCanvasDefault, lineWidth: 2)) + .accessibilityElement(children: .combine) + } + + var backgroundShape: some InsettableShape { + RoundedRectangle(cornerRadius: 14, style: .continuous) + } + + var overlayBackgroundColor: Color { + isHighlighted ? Color.compound.bgSubtlePrimary : .compound.bgSubtleSecondary + } + + var overlayBorderColor: Color { + isHighlighted ? Color.compound.borderInteractivePrimary : .clear + } +} + +struct TimelineCollapseButtonLabel: View { + var collapsed: Bool + + var body: some View { + TimelineReactionButtonLabel { + Text(collapsed ? L10n.screenRoomReactionsShowMore : L10n.screenRoomReactionsShowLess) + .layoutPriority(1) + .drawingGroup() + .font(.compound.bodyMD) + .foregroundColor(.compound.textPrimary) + } } } struct TimelineReactionButton: View { + let itemID: String let reaction: AggregatedReaction let toggleReaction: (String) -> Void let showReactionSummary: (String) -> Void - @State private var didLongPress = false - var body: some View { label .onTapGesture { @@ -53,7 +109,7 @@ struct TimelineReactionButton: View { } var label: some View { - HStack(spacing: 4) { + TimelineReactionButtonLabel(isHighlighted: reaction.isHighlighted) { Text(reaction.key) .font(.compound.bodyMD) if reaction.count > 1 { @@ -62,41 +118,33 @@ struct TimelineReactionButton: View { .foregroundColor(textColor) } } - .padding(.vertical, 6) - .padding(.horizontal, 8) - .background(backgroundShape.inset(by: 1).fill(overlayBackgroundColor)) - .overlay(backgroundShape.inset(by: 2.0).strokeBorder(overlayBorderColor)) - .overlay(backgroundShape.strokeBorder(Color.compound.bgCanvasDefault, lineWidth: 2)) - .accessibilityElement(children: .combine) - } - - var backgroundShape: some InsettableShape { - RoundedRectangle(cornerRadius: 12, style: .continuous) } var textColor: Color { reaction.isHighlighted ? Color.compound.textPrimary : .compound.textSecondary } - - var overlayBackgroundColor: Color { - reaction.isHighlighted ? Color.compound.bgSubtlePrimary : .compound.bgSubtleSecondary - } - - var overlayBorderColor: Color { - reaction.isHighlighted ? Color.compound.borderInteractivePrimary : .clear +} + +struct TimelineReactionViewPreviewsContainer: View { + @State private var collapseState1 = false + @State private var collapseState2 = true + + var body: some View { + VStack { + TimelineReactionsView(itemID: "1", reactions: Array(AggregatedReaction.mockReactions.prefix(3)), collapsed: .constant(true)) + Divider() + TimelineReactionsView(itemID: "2", reactions: AggregatedReaction.mockReactions, collapsed: $collapseState1) + Divider() + TimelineReactionsView(itemID: "3", reactions: AggregatedReaction.mockReactions, collapsed: $collapseState2) + .environment(\.layoutDirection, .rightToLeft) + } + .background(Color.red) + .frame(maxWidth: 250, alignment: .leading) } } struct TimelineReactionView_Previews: PreviewProvider { static var previews: some View { - VStack { - TimelineReactionButton(reaction: AggregatedReaction.mockThumbsUpHighlighted) { _ in } showReactionSummary: { _ in } - TimelineReactionButton(reaction: AggregatedReaction.mockClap) { _ in } showReactionSummary: { _ in } - TimelineReactionButton(reaction: AggregatedReaction.mockParty) { _ in } showReactionSummary: { _ in } - TimelineReactionsView(reactions: AggregatedReaction.mockReactions) { _ in } showReactionSummary: { _ in } - .environment(\.layoutDirection, .leftToRight) - TimelineReactionsView(reactions: AggregatedReaction.mockReactions) { _ in } showReactionSummary: { _ in } - .environment(\.layoutDirection, .rightToLeft) - } + TimelineReactionViewPreviewsContainer() } } diff --git a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift index 41fa80287..f9b521b04 100644 --- a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift +++ b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift @@ -66,21 +66,11 @@ enum RoomTimelineItemFixtures { isEditable: true, sender: .init(id: "", displayName: "Bob"), content: .init(body: "New home office set up!"), - properties: RoomTimelineItemProperties(reactions: [ - AggregatedReaction(accountOwnerID: "me", key: "🙏", senders: ["helena"]), - AggregatedReaction(accountOwnerID: "me", key: "🙌", senders: ["me", "helena", "jacob", "bob", "alice", "charlie", "dan", "erin"]), - AggregatedReaction(accountOwnerID: "me", key: "👌", senders: ["helena"]), - AggregatedReaction(accountOwnerID: "me", key: "🥳", senders: ["bob"]), - AggregatedReaction(accountOwnerID: "me", key: "🍾", senders: ["charlie"]), - AggregatedReaction(accountOwnerID: "me", key: "🎈", senders: ["helena"]), - AggregatedReaction(accountOwnerID: "me", key: "👏", senders: ["helena"]), - AggregatedReaction(accountOwnerID: "me", key: "🎉", senders: ["helena"]) - - ], - orderedReadReceipts: [ReadReceipt(userID: "alice", formattedTimestamp: nil), - ReadReceipt(userID: "bob", formattedTimestamp: nil), - ReadReceipt(userID: "charlie", formattedTimestamp: nil), - ReadReceipt(userID: "dan", formattedTimestamp: nil)])), + properties: RoomTimelineItemProperties(reactions: AggregatedReaction.mockReactions, + orderedReadReceipts: [ReadReceipt(userID: "alice", formattedTimestamp: nil), + ReadReceipt(userID: "bob", formattedTimestamp: nil), + ReadReceipt(userID: "charlie", formattedTimestamp: nil), + ReadReceipt(userID: "dan", formattedTimestamp: nil)])), TextRoomTimelineItem(id: UUID().uuidString, timestamp: "5 PM", isOutgoing: false, diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 1a2b04933..7f7a3f2ad 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -323,8 +323,12 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] { reactions.map { reaction in AggregatedReaction(accountOwnerID: userID, key: reaction.key, senders: reaction.senders) - }.sorted { a, b in - a.count > b.count + } + .sorted { a, b in + // Sort by count and then by key for a consistence experience. + // Otherwise emojis can switch around. We can replace + // with timestamp as a secondary sort when it is available. + (a.count, a.key) > (b.count, b.key) } } diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index bfe64c1f1..8ffa832d8 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -156,7 +156,6 @@ targets: - package: Sentry - package: URLRouting - package: Version - - package: Flow sources: - path: ../Sources diff --git a/UITests/Sources/RoomScreenUITests.swift b/UITests/Sources/RoomScreenUITests.swift index 501f6cc1a..5039989ca 100644 --- a/UITests/Sources/RoomScreenUITests.swift +++ b/UITests/Sources/RoomScreenUITests.swift @@ -161,12 +161,6 @@ class RoomScreenUITests: XCTestCase { try await app.assertScreenshot(.roomSmallTimelineWithReadReceipts) } - func testTimelineReactions() async throws { - let app = Application.launch(.roomSmallTimelineWithReactions) - - try await app.assertScreenshot(.roomSmallTimelineWithReactions) - } - // MARK: - Helper Methods private func performOperation(_ operation: UITestsSignal, using client: UITestsSignalling.Client) async throws { diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEncryptedWithAvatar.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEncryptedWithAvatar.png index c6db0cd92..a65290f73 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEncryptedWithAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomEncryptedWithAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:88d2c242919c115dc9cf1fdedc2e8fce72b8b744430d98174c7c7d87525b117d -size 250731 +oid sha256:2536457833f1e02d6526f3cce18bfd89629f715cb28266bbc4c2c4117ea65caa +size 292394 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomPlainNoAvatar.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomPlainNoAvatar.png index d42e67e31..4cfa03141 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomPlainNoAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomPlainNoAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7d2280697193c1ce77a7c1fbcae22e0dc745f8a1fa1b29e809ca0685b1a3daba -size 250495 +oid sha256:4f8ff0d944bce8228a6c656d0a4e694be4262237c956545eb02bf4ccefd99524 +size 292274 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomSmallTimelineWithReactions.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomSmallTimelineWithReactions.png index 12f182c5b..5eb0b019d 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomSmallTimelineWithReactions.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomSmallTimelineWithReactions.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3b129972c4051695fe66991ce4d73f5fab08974598eb45c484affbb1b5899784 -size 249350 +oid sha256:d1dddcb5b5b2c22c08494e9f1d009a3d96f25049acd1ce39ce6c40681fb59bee +size 290746 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEncryptedWithAvatar.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEncryptedWithAvatar.png index 910bb060d..c5263fd73 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEncryptedWithAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomEncryptedWithAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2adbb2c8a1131cbead5a3dd54ab03edda7f3b7ad3563978a944816b1cb60ac3d -size 362015 +oid sha256:53bb76fe68c4191683679d2640447f1197ca332c9f6d54baaece8892da4d6e65 +size 374657 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomPlainNoAvatar.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomPlainNoAvatar.png index eb95b0a2b..6a70bf695 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomPlainNoAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomPlainNoAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7342a00c360a31e37efce90162884b1337e5996c82d1d8d5b5b4c6ee99652c77 -size 361539 +oid sha256:295992c13e2831693a810b7034c1ab41cb81f969de802a238e691599f4718950 +size 374348 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomSmallTimelineWithReactions.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomSmallTimelineWithReactions.png index 58277737f..4ef51239d 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomSmallTimelineWithReactions.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomSmallTimelineWithReactions.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a11b4cf742ea5e6b2a6074d0cdfa7691f7da8b2bbe1a4a7926a240171f76312f -size 359297 +oid sha256:02088ebf095f90a47b5690d08877e19bb3f9ff34685b3401b6d575a51d87d4dd +size 372218 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEncryptedWithAvatar.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEncryptedWithAvatar.png index 582555473..068c5a1d6 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEncryptedWithAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomEncryptedWithAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e2be6670a34a788dc1e61d16ed27475c4994c9a1c56b4ffd80a6222faf901c5 -size 251812 +oid sha256:c15292dae68fb8d0680fe6dcffba981801016df4e5601a9cb0bcca5748bc7fd9 +size 293483 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomPlainNoAvatar.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomPlainNoAvatar.png index d7f340718..4ccc9bbe0 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomPlainNoAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomPlainNoAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a6c85164fbf45e58fdaa664e6438347931459b7cc5406e3fc4234dcf764cafea -size 251570 +oid sha256:40cf545f40203e7e77231aa44cf43756f00f9a0860c1260b7db13d4a69b6399b +size 293363 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomSmallTimelineWithReactions.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomSmallTimelineWithReactions.png index 9205effdd..c893187df 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomSmallTimelineWithReactions.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.roomSmallTimelineWithReactions.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f36517e412982bbc83f4b7d1f460fd60667b0d2a1d776e39ebc7da9baa7cd93f -size 250438 +oid sha256:cb4e61ee05553aaa73b68656bac3f80538c8bba55d32c3de133a5c1fe8472d39 +size 291849 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEncryptedWithAvatar.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEncryptedWithAvatar.png index 1c900e355..bcdf475b2 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEncryptedWithAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomEncryptedWithAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:efc0147b8245abce5d881b9605882b115002c54f9d7d165cb5e6431fea18c938 -size 362606 +oid sha256:cd9b2eca15f4756abb3865f7488de286ce8e210e61c0e15ac2aa3f1899246964 +size 367914 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomPlainNoAvatar.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomPlainNoAvatar.png index 9bf3dca4d..25dc04ce2 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomPlainNoAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomPlainNoAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2925f0c4a1210948c9b863cb861026041d60afb24b593f15cdf9d82e9628c841 -size 362130 +oid sha256:277ffaa4c038e5e41e0092190c045ce886a757ff6fc478feec7c0ee2c8c5282b +size 367605 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomSmallTimelineWithReactions.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomSmallTimelineWithReactions.png index 97474d1cf..7faa43bd2 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomSmallTimelineWithReactions.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomSmallTimelineWithReactions.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dbf860ed8cb7cee2d0e4b45e9817aafb9c414e1e846b9d3d9e87b6098894ba2d -size 359888 +oid sha256:6b4f4f40e06cf45694cf98764839abb285ea474588387985a6caffb15aa604d0 +size 365475 diff --git a/UnitTests/Sources/LayoutTests/CollapsibleFlowLayoutTests.swift b/UnitTests/Sources/LayoutTests/CollapsibleFlowLayoutTests.swift new file mode 100644 index 000000000..421c5e716 --- /dev/null +++ b/UnitTests/Sources/LayoutTests/CollapsibleFlowLayoutTests.swift @@ -0,0 +1,137 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2 (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 +// +// 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. +// + +@testable import ElementX +import SwiftUI +import XCTest + +final class CollapsibleFlowLayoutTests: XCTestCase { + func testFlowLayoutWithExpandAndCollapse() { + let containerSize = CGSize(width: 250, height: 400) + var flowLayout = CollapsibleFlowLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2) + + var placedViews: [CGRect] = [] + let placedViewsCallback = { rect in + placedViews.append(rect) + } + let subviews: [LayoutSubviewMock] = [ + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback), + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback), + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback), + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback), + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback), + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback), + // The expand/collapse button + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback) + ] + let subviewsMock = LayoutSubviewsMock(subviews: subviews) + var a: () = () + var size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) + + // Collapsed target layout has 2 rows of 2 items, so just 1 spacing between items hence 205, 105 + XCTAssertEqual(size, CGSize(width: 205, height: 105)) + flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) + + // 3 items are hidden in the collapsed state (put in the centre with zero size) + var targetPlacements: [CGRect] = [ + CGRect(x: 0, y: 25, width: 100, height: 50), + CGRect(x: 105, y: 25, width: 100, height: 50), + CGRect(x: 0, y: 80, width: 100, height: 50), + CGRect(x: 105, y: 80, width: 100, height: 50), + CGRect(x: -10000, y: -10000, width: 0, height: 0), + CGRect(x: -10000, y: -10000, width: 0, height: 0), + CGRect(x: -10000, y: -10000, width: 0, height: 0) + ] + XCTAssertEqual(placedViews, targetPlacements) + + flowLayout.collapsed = false + placedViews = [] + + size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) + + // Expanded target layout has 4 rows and no more than 2 items per row + XCTAssertEqual(size, CGSize(width: 205, height: 215)) + + flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) + + targetPlacements = [ + CGRect(x: 0, y: 25, width: 100, height: 50), + CGRect(x: 105, y: 25, width: 100, height: 50), + CGRect(x: 0, y: 80, width: 100, height: 50), + CGRect(x: 105, y: 80, width: 100, height: 50), + CGRect(x: 0, y: 135, width: 100, height: 50), + CGRect(x: 105, y: 135, width: 100, height: 50), + CGRect(x: 0, y: 190, width: 100, height: 50) + ] + XCTAssertEqual(placedViews, targetPlacements) + } + + func testFlowLayoutWithExpandButtonIsHidden() { + let containerSize = CGSize(width: 250, height: 400) + let flowLayout = CollapsibleFlowLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2) + + var placedViews: [CGRect] = [] + let placedViewsCallback = { rect in + placedViews.append(rect) + } + let subviews: [LayoutSubviewMock] = [ + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback), + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback), + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback), + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback) + ] + let subviewsMock = LayoutSubviewsMock(subviews: subviews) + var a: () = () + let size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) + + XCTAssertEqual(size, CGSize(width: 205, height: 105)) + flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) + + let targetPlacements: [CGRect] = [ + CGRect(x: 0, y: 25, width: 100, height: 50), + CGRect(x: 105, y: 25, width: 100, height: 50), + CGRect(x: 0, y: 80, width: 100, height: 50), + // Button is hidden + CGRect(x: -10000, y: -10000, width: 0, height: 0) + ] + XCTAssertEqual(placedViews, targetPlacements) + } + + func testFlowLayoutEmptyState() { + let containerSize = CGSize(width: 250, height: 400) + let flowLayout = CollapsibleFlowLayout(itemSpacing: 5, rowSpacing: 5, rowsBeforeCollapsible: 2) + + var placedViews: [CGRect] = [] + let placedViewsCallback = { rect in + placedViews.append(rect) + } + let subviews: [LayoutSubviewMock] = [ + // No subviews to layout just the expand/collapse button + LayoutSubviewMock(size: CGSize(width: 100, height: 50), placedPositionCallback: placedViewsCallback) + ] + let subviewsMock = LayoutSubviewsMock(subviews: subviews) + var a: () = () + let size = flowLayout.sizeThatFits(proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) + + XCTAssertEqual(size, CGSize(width: 0, height: 0)) + flowLayout.placeSubviews(in: CGRect(origin: .zero, size: size), proposal: ProposedViewSize(containerSize), subviews: subviewsMock, cache: &a) + + let targetPlacements: [CGRect] = [ + CGRect(x: -10000, y: -10000, width: 0, height: 0) + ] + XCTAssertEqual(placedViews, targetPlacements) + } +} diff --git a/UnitTests/Sources/LayoutTests/LayoutMocks.swift b/UnitTests/Sources/LayoutTests/LayoutMocks.swift new file mode 100644 index 000000000..508743f92 --- /dev/null +++ b/UnitTests/Sources/LayoutTests/LayoutMocks.swift @@ -0,0 +1,80 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@testable import ElementX +import Foundation +import SwiftUI + +/// A mock of the SwiftUI `LayoutSubviews` struct +struct LayoutSubviewsMock: Equatable, RandomAccessCollection { + var subviews: [LayoutSubviewMock] + + /// A type that contains a subsequence of proxy values. + typealias SubSequence = LayoutSubviewsMock + + /// A type that contains a proxy value. + typealias Element = LayoutSubviewMock + + /// A type that you can use to index proxy values. + typealias Index = Int + + /// The index of the first subview. + var startIndex: Int { + subviews.startIndex + } + + /// An index that's one higher than the last subview. + var endIndex: Int { + subviews.endIndex + } + + /// Gets the subview proxy at a specified index. + subscript(index: Int) -> LayoutSubviewsMock.Element { + subviews[index] + } + + /// Gets the subview proxies in the specified range. + subscript(bounds: Range) -> LayoutSubviewsMock { + LayoutSubviewsMock(subviews: Array(subviews[bounds])) + } + + /// Gets the subview proxies with the specified indicies. + subscript(indices: S) -> LayoutSubviewsMock where S: Sequence, S.Element == Int { + LayoutSubviewsMock(subviews: Array(indices.map { subviews[$0] })) + } +} + +/// A mock of the SwiftUI `LayoutSubview` struct +struct LayoutSubviewMock: FlowLayoutSubview, Equatable { + var size: CGSize + + var placedPositionCallback: (CGRect) -> Void + + func sizeThatFits(_ proposal: ProposedViewSize) -> CGSize { + size + } + + func place(at position: CGPoint, anchor: UnitPoint, proposal: ProposedViewSize) { + let rect = CGRect(origin: position, size: CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0)) + placedPositionCallback(rect) + } + + static func == (lhs: LayoutSubviewMock, rhs: LayoutSubviewMock) -> Bool { + lhs.size == rhs.size + } +} + +extension LayoutSubviewsMock: FlowLayoutSubviews { } diff --git a/project.yml b/project.yml index 4a3f29e7f..a510a6c20 100644 --- a/project.yml +++ b/project.yml @@ -103,6 +103,3 @@ packages: Version: url: https://github.com/mxcl/Version minorVersion: 2.0.0 - Flow: - url: https://github.com/tevelee/SwiftUI-Flow - minorVersion: 1.2.0