diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 708f6e490..0fcd73b94 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -146,6 +146,7 @@ 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 */; }; 38546A6010A2CF240EC9AF73 /* BindableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */; }; 38896D54D6D675534E606195 /* RoomTimelineControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FCC416A3BFE73DF7B3E6BF /* RoomTimelineControllerFactory.swift */; }; @@ -213,6 +214,7 @@ 520EEDAFBC778AB0B41F2F53 /* ClientMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE6170EFE6A161B0A68AB61 /* ClientMock.swift */; }; 5375902175B2FEA2949D7D74 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDDDDD9FE1A699D23A5E096 /* LoginScreen.swift */; }; 53A55748D5F587C9061F98BF /* ServerConfigurationScreenViewStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */; }; + 53C1E7F6A7D6409D89F36ED7 /* AggregatedReactionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */; }; 53DEF39F0C4DE02E3FC56D91 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 800631D7250B7F93195035F1 /* KeychainAccess */; }; 53F1196F9C69512306A2693F /* TextRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28C19F54A0C4FC9AB7ABD583 /* TextRoomTimelineItemContent.swift */; }; 5455147CAC63F71E48F7D699 /* NSELogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3D455BC2423D911A62ACFB2 /* NSELogger.swift */; }; @@ -383,6 +385,7 @@ 8EF63DDDC1B54F122070B04D /* ReadMarkerRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6311F21F911E23BE4DF51B4 /* ReadMarkerRoomTimelineView.swift */; }; 8F2FAA98457750D9D664136F /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 4278261E147DB2DE5CFB7FC5 /* PostHog */; }; 90733645AE76FB33DAD28C2B /* URLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE40D4A5DD857AC16EED945A /* URLSession.swift */; }; + 9095B9E40DB5CF8BA26CE0D8 /* ReactionsSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 153726EDCE1ACBB3D466A916 /* ReactionsSummaryView.swift */; }; 90DF83A6A347F7EE7EDE89EE /* AttributedStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF25E364AE85090A70AE4644 /* AttributedStringBuilderTests.swift */; }; 90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D52BAA5BADB06E5E8C295D /* Assets.swift */; }; 91ABC91758A6E4A5FAA2E9C4 /* ReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314F1C79850BE46E8ABEAFCB /* ReadReceipt.swift */; }; @@ -818,6 +821,7 @@ 1423AB065857FA546444DB15 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryDetails.swift; sourceTree = ""; }; 1454CF3AABD242F55C8A2615 /* InviteUsersScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenModels.swift; sourceTree = ""; }; + 153726EDCE1ACBB3D466A916 /* ReactionsSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsSummaryView.swift; sourceTree = ""; }; 15A657D96779D1DEB8EF1327 /* CreateRoomViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomViewModel.swift; sourceTree = ""; }; 16037EE9E9A52AF37B7818E3 /* AnalyticsSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenUITests.swift; sourceTree = ""; }; 16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenModels.swift; sourceTree = ""; }; @@ -1029,6 +1033,7 @@ 69219A908D7C22E6EE6689AE /* UserNotificationCenterSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationCenterSpy.swift; sourceTree = ""; }; 693E16574C6F7F9FA1015A8C /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = ""; }; 69B63F817FE305548DB4B512 /* RoomMembersListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListViewModelTests.swift; sourceTree = ""; }; + 69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregatedReactionMock.swift; sourceTree = ""; }; 69D42EE0102D2857933625DD /* CreateRoomViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomViewModelTests.swift; sourceTree = ""; }; 6A4C9547BBFEEF30AA11329B /* TimelineItemStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemStatusView.swift; sourceTree = ""; }; 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoPlistReader.swift; sourceTree = ""; }; @@ -1482,6 +1487,7 @@ 754602A7B2AAD443C4228ED4 /* Sentry in Frameworks */, B0CB16349B96262AA65A04AF /* URLRouting in Frameworks */, 36AD4DD4C798E22584ED3200 /* Version in Frameworks */, + 36CD6E11B37396E14F032CB6 /* Flow in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1766,6 +1772,7 @@ 31CE4DA53232AA534057F912 /* Mocks */ = { isa = PBXGroup; children = ( + 69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */, 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */, 1ABDE6F66532CBEB0E016F94 /* RoomProxyMock.swift */, 248649EBA5BC33DB93698734 /* SessionVerificationControllerProxyMock.swift */, @@ -3134,6 +3141,7 @@ CCD48459CA34A1928EC7A26A /* Supplementary */ = { isa = PBXGroup; children = ( + 153726EDCE1ACBB3D466A916 /* ReactionsSummaryView.swift */, D5AC06FC11B6638F7BF1670E /* TimelineDeliveryStatusView.swift */, 6A4C9547BBFEEF30AA11329B /* TimelineItemStatusView.swift */, 351E89CE2ED9B73C5CC47955 /* TimelineReactionsView.swift */, @@ -3530,6 +3538,7 @@ 7731767AE437BA3BD2CC14A8 /* Sentry */, E9BAB8A793FE3B54CDD47102 /* URLRouting */, A05AF81DDD14AD58CB0E1B9B /* Version */, + 4D7E6BFC89715FC3CF0349D0 /* Flow */, ); productName = ElementX; productReference = 4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */; @@ -3643,6 +3652,7 @@ 9754C4B03F6255F67FC15E52 /* XCRemoteSwiftPackageReference "compound-ios" */, C13F55E4518415CB4C278E73 /* XCRemoteSwiftPackageReference "DTCoreText" */, D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */, + 65398401562A467FD2FDCF20 /* XCRemoteSwiftPackageReference "SwiftUI-Flow" */, 701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */, 9A472EE0218FE7DCF5283429 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 395DE6AE429B7ACC7C7FE31D /* XCRemoteSwiftPackageReference "KZFileWatchers" */, @@ -3964,6 +3974,7 @@ files = ( 41F553349AF44567184822D8 /* APNSPayload.swift in Sources */, 70394ECD2DCC70741538620D /* AccessibilityIdentifiers.swift in Sources */, + 53C1E7F6A7D6409D89F36ED7 /* AggregatedReactionMock.swift in Sources */, 4219391CD2351E410554B3E8 /* AggregratedReaction.swift in Sources */, 64D05250CEDE8B604119F6E6 /* Alert.swift in Sources */, 39929D29B265C3F6606047DE /* AlignedScrollView.swift in Sources */, @@ -4225,6 +4236,7 @@ 1BA04D05EBC6646958B1BE60 /* PlaceholderScreenCoordinator.swift in Sources */, DF504B10A4918F971A57BEF2 /* PostHogAnalyticsClient.swift in Sources */, 2835FD52F3F618D07F799B3D /* Publisher.swift in Sources */, + 9095B9E40DB5CF8BA26CE0D8 /* ReactionsSummaryView.swift in Sources */, 743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */, 8EF63DDDC1B54F122070B04D /* ReadMarkerRoomTimelineView.swift in Sources */, 91ABC91758A6E4A5FAA2E9C4 /* ReadReceipt.swift in Sources */, @@ -5058,6 +5070,14 @@ 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"; @@ -5256,6 +5276,11 @@ 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 28de96963..ce478870e 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -142,6 +142,15 @@ "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", @@ -205,6 +214,15 @@ "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 new file mode 100644 index 000000000..5694aa4c9 --- /dev/null +++ b/ElementX/Sources/Mocks/AggregatedReactionMock.swift @@ -0,0 +1,56 @@ +// +// 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 Foundation + +extension AggregatedReaction { + private static func mockIds(_ count: Int) -> [String] { + Array(1...count).map { + "@user\($0):matrix.org" + } + } + + private static var alice: String { + RoomMemberProxyMock.mockAlice.userID + } + + static var mockThumbsUpHighlighted: AggregatedReaction { + AggregatedReaction(accountOwnerID: alice, key: "๐Ÿ‘", senders: [alice] + mockIds(4)) + } + + static var mockClap: AggregatedReaction { + AggregatedReaction(accountOwnerID: alice, key: "๐Ÿ‘", senders: mockIds(1)) + } + + static var mockParty: AggregatedReaction { + AggregatedReaction(accountOwnerID: alice, key: "๐ŸŽ‰", senders: mockIds(20)) + } + + static var mockReactions: [AggregatedReaction] { + [ + AggregatedReaction(accountOwnerID: alice, key: "๐Ÿ˜…", senders: [alice]), + AggregatedReaction(accountOwnerID: alice, key: "๐Ÿคทโ€โ™‚๏ธ", senders: mockIds(1)), + AggregatedReaction(accountOwnerID: alice, key: "๐ŸŽจ", senders: [alice] + mockIds(5)), + AggregatedReaction(accountOwnerID: alice, key: "๐ŸŽ‰", senders: mockIds(8)), + AggregatedReaction(accountOwnerID: alice, key: "๐Ÿคฏ", senders: [alice] + mockIds(14)), + AggregatedReaction(accountOwnerID: alice, key: "๐Ÿซฃ", senders: mockIds(1)), + 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)) + ] + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 53d659664..d04de1308 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -73,6 +73,8 @@ enum RoomScreenViewAction { case handlePasteOrDrop(provider: NSItemProvider) case tappedOnUser(userID: String) + + case reactionSummary(itemID: String, key: String) case retrySend(transactionID: String?) case cancelSend(transactionID: String?) @@ -126,6 +128,8 @@ struct RoomScreenViewStateBindings { var actionMenuInfo: TimelineItemActionMenuInfo? var sendFailedConfirmationDialogInfo: SendFailedConfirmationDialogInfo? + + var reactionSummaryInfo: ReactionSummaryInfo? } struct TimelineItemActionMenuInfo: Identifiable, Equatable { @@ -146,6 +150,15 @@ struct SendFailedConfirmationDialogInfo: ConfirmationDialogProtocol { let transactionID: String? } +struct ReactionSummaryInfo: Identifiable { + let reactions: [AggregatedReaction] + let selectedKey: String + + var id: String { + selectedKey + } +} + enum RoomScreenErrorType: Hashable { /// A specific error message shown in an alert. case alert(String) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 40781e51e..129cbe532 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -118,6 +118,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol case .displayEmojiPicker(let itemID): guard let item = state.items.first(where: { $0.id == itemID }), item.isReactable else { return } callback?(.displayEmojiPicker(itemID: itemID)) + case .reactionSummary(let itemId, let key): + showReactionSummary(for: itemId, selectedKey: key) case .retrySend(let transactionID): Task { await handleRetrySend(transactionID: transactionID) } case .cancelSend(let transactionID): @@ -619,6 +621,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } } } + + // MARK: - Reaction summary + + private func showReactionSummary(for itemID: String, selectedKey: String) { + guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemID }), + let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else { + return + } + + state.bindings.reactionSummaryInfo = .init(reactions: eventTimelineItem.properties.reactions, selectedKey: selectedKey) + } } private extension RoomProxyProtocol { diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index b265ff54b..b98dea8a0 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -18,7 +18,6 @@ import SwiftUI struct RoomScreen: View { @ObservedObject var context: RoomScreenViewModel.Context - @State private var showReactionsMenuForItemId = "" @State private var dragOver = false private let attachmentButtonPadding = 10.0 @@ -50,6 +49,10 @@ struct RoomScreen: View { .environmentObject(context) } } + .sheet(item: $context.reactionSummaryInfo) { + ReactionsSummaryView(reactions: $0.reactions, members: context.viewState.members, imageProvider: context.imageProvider, selectedReactionKey: $0.selectedKey) + .edgesIgnoringSafeArea([.bottom]) + } .interactiveQuickLook(item: $context.mediaPreviewItem) .track(screen: .room) .task(id: context.viewState.roomId) { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index b6b6d4cb5..b87e56dd8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -20,6 +20,7 @@ import SwiftUI struct TimelineItemBubbledStylerView: View { @EnvironmentObject private var context: RoomScreenViewModel.Context @Environment(\.timelineGroupStyle) private var timelineGroupStyle + @Environment(\.layoutDirection) var layoutDirection: LayoutDirection let timelineItem: EventBasedTimelineItemProtocol @ViewBuilder let content: () -> Content @@ -31,7 +32,12 @@ struct TimelineItemBubbledStylerView: View { private var isTextItem: Bool { timelineItem is TextBasedRoomTimelineItem } private var isEncryptedOneToOneRoom: Bool { context.viewState.isEncryptedOneToOneRoom } - + + var reactionsLayoutDirection: LayoutDirection { + guard timelineItem.isOutgoing else { return layoutDirection } + return layoutDirection == .leftToRight ? .rightToLeft : .leftToRight + } + var body: some View { ZStack(alignment: .trailingFirstTextBaseline) { VStack(alignment: alignment, spacing: -12) { @@ -92,10 +98,12 @@ struct TimelineItemBubbledStylerView: View { .accessibilityElement(children: .combine) if !timelineItem.properties.reactions.isEmpty { - TimelineReactionsView(reactions: timelineItem.properties.reactions, - alignment: alignment) { key in + 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 { } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index 5190bfe7d..103460682 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -117,9 +117,10 @@ struct TimelineItemPlainStylerView: View { } if !timelineItem.properties.reactions.isEmpty { - TimelineReactionsView(reactions: timelineItem.properties.reactions, - alignment: .leading) { key in + 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 { } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/ReactionsSummaryView.swift b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/ReactionsSummaryView.swift new file mode 100644 index 000000000..7790193ce --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/ReactionsSummaryView.swift @@ -0,0 +1,144 @@ +// +// 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 + +struct ReactionsSummaryView: View { + let reactions: [AggregatedReaction] + let members: [String: RoomMemberState] + let imageProvider: ImageProviderProtocol? + + @State var selectedReactionKey: String + + var selectedReactionIndex: Int { + reactions.firstIndex(where: { $0.key == selectedReactionKey }) ?? 0 + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + reactionButtons + sendersList + } + .presentationDetents([.medium]) + .presentationBackground(Color.compound.bgCanvasDefault) + .presentationDragIndicator(.visible) + } + + private var reactionButtons: some View { + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { scrollView in + HStack { + ForEach(reactions, id: \.self) { reaction in + ReactionSummaryButton(reaction: reaction, highlighted: selectedReactionKey == reaction.key) { key in + withAnimation(.easeInOut) { + selectedReactionKey = key + } + } + .id(reaction.key) + } + } + .padding(.leading, 20) + .onAppear { + scrollView.scrollTo(selectedReactionKey, anchor: .leading) + } + } + } + .padding(.top, 24) + .padding(.bottom, 12) + } + + private var sendersList: some View { + TabView(selection: $selectedReactionKey) { + ForEach(reactions, id: \.self) { reaction in + ScrollView { + VStack(alignment: .leading) { + ForEach(reaction.senders, id: \.self) { sender in + ReactionSummarySenderView(sender: sender, member: members[sender], imageProvider: imageProvider) + .padding(.horizontal, 16) + } + } + .frame(maxWidth: .infinity) + } + .tag(reaction.key) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } +} + +private struct ReactionSummaryButton: View { + let reaction: AggregatedReaction + let highlighted: Bool + let action: (String) -> Void + + var body: some View { + Button { action(reaction.key) } label: { label } + } + + var label: some View { + HStack(spacing: 4) { + Text(reaction.key) + .font(.compound.headingSM) + if reaction.count > 1 { + Text(String(reaction.count)) + .font(.compound.headingSM) + .foregroundColor(highlighted ? Color.compound.textOnSolidPrimary : Color.compound.textSecondary) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background(highlighted ? Color.compound.bgActionPrimaryRest : .clear, in: Capsule()) + .accessibilityElement(children: .combine) + } +} + +private struct ReactionSummarySenderView: View { + var sender: String + var member: RoomMemberState? + let imageProvider: ImageProviderProtocol? + + var displayName: String { + member?.displayName ?? sender + } + + var body: some View { + HStack { + LoadableAvatarImage(url: member?.avatarURL, + name: displayName, + contentID: sender, + avatarSize: .user(on: .timeline), + imageProvider: imageProvider) + VStack(alignment: .leading) { + Text(displayName) + .font(.compound.bodyMDSemibold) + Text(sender) + .font(.compound.bodySM) + .foregroundColor(.compound.textSecondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + } +} + +struct ReactionsSummaryView_Previews: PreviewProvider { + static var previews: some View { + ReactionsSummaryView(reactions: AggregatedReaction.mockReactions, + members: [:], + imageProvider: MockMediaProvider(), + selectedReactionKey: AggregatedReaction.mockReactions[0].key) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift index 98a834e9b..46ab52382 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReactionsView.swift @@ -14,30 +14,41 @@ // limitations under the License. // +import Flow import SwiftUI struct TimelineReactionsView: View { + @Environment(\.layoutDirection) var layoutDirection: LayoutDirection let reactions: [AggregatedReaction] - let alignment: HorizontalAlignment - let action: (String) -> Void + let toggleReaction: (String) -> Void + let showReactionSummary: (String) -> Void var body: some View { - AlignedScrollView(alignment: alignment, showsIndicators: false) { - HStack { - ForEach(reactions, id: \.self) { reaction in - TimelineReactionButton(reaction: reaction, action: action) - } + HFlow(itemSpacing: 4, rowSpacing: 4) { + ForEach(reactions, id: \.self) { reaction in + TimelineReactionButton(reaction: reaction, + toggleReaction: toggleReaction, + showReactionSummary: showReactionSummary) } } + .environment(\.layoutDirection, layoutDirection) } } struct TimelineReactionButton: View { let reaction: AggregatedReaction - let action: (String) -> Void + let toggleReaction: (String) -> Void + let showReactionSummary: (String) -> Void + + @State private var didLongPress = false var body: some View { - Button { action(reaction.key) } label: { label } + label.onTapGesture { + toggleReaction(reaction.key) + } + .longPressWithFeedback { + showReactionSummary(reaction.key) + } } var label: some View { @@ -79,22 +90,13 @@ struct TimelineReactionButton: View { struct TimelineReactionView_Previews: PreviewProvider { static var previews: some View { VStack { - TimelineReactionButton(reaction: AggregatedReaction(key: "๐Ÿ‘", count: 5, isHighlighted: true)) { _ in } - TimelineReactionButton(reaction: AggregatedReaction(key: "๐Ÿ‘", count: 1, isHighlighted: false)) { _ in } - TimelineReactionButton(reaction: AggregatedReaction(key: "๐ŸŽ‰", count: 20, isHighlighted: false)) { _ in } - - TimelineReactionsView(reactions: [ - AggregatedReaction(key: "๐Ÿ˜…", count: 1, isHighlighted: true), - AggregatedReaction(key: "๐Ÿคทโ€โ™‚๏ธ", count: 1, isHighlighted: false), - AggregatedReaction(key: "๐ŸŽจ", count: 6, isHighlighted: true), - AggregatedReaction(key: "๐ŸŽ‰", count: 8, isHighlighted: false), - AggregatedReaction(key: "๐Ÿคฏ", count: 15, isHighlighted: true), - AggregatedReaction(key: "๐Ÿซฃ", count: 1, isHighlighted: false), - AggregatedReaction(key: "๐Ÿš€", count: 3, isHighlighted: true), - AggregatedReaction(key: "๐Ÿ˜‡", count: 2, isHighlighted: false), - AggregatedReaction(key: "๐Ÿคญ", count: 9, isHighlighted: true), - AggregatedReaction(key: "๐Ÿซค", count: 10, isHighlighted: false) - ], alignment: .leading) { _ in } + 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) } } } diff --git a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift index dca83ebc6..41fa80287 100644 --- a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift +++ b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift @@ -34,7 +34,7 @@ enum RoomTimelineItemFixtures { sender: .init(id: "", displayName: "Helena"), content: .init(body: "Letโ€™s get lunch soon! New salad place opened up ๐Ÿฅ—. When are yโ€™all free? ๐Ÿค—"), properties: RoomTimelineItemProperties(reactions: [ - AggregatedReaction(key: "๐Ÿ™Œ", count: 1, isHighlighted: true) + AggregatedReaction(accountOwnerID: "me", key: "๐Ÿ™Œ", senders: ["me"]) ])), TextRoomTimelineItem(id: UUID().uuidString, timestamp: "10:11 AM", @@ -43,8 +43,8 @@ enum RoomTimelineItemFixtures { sender: .init(id: "", displayName: "Helena"), content: .init(body: "I can be around on Wednesday. How about some ๐ŸŒฎ instead? Like https://www.tortilla.co.uk/"), properties: RoomTimelineItemProperties(reactions: [ - AggregatedReaction(key: "๐Ÿ™", count: 1, isHighlighted: false), - AggregatedReaction(key: "๐Ÿ™Œ", count: 2, isHighlighted: true) + AggregatedReaction(accountOwnerID: "me", key: "๐Ÿ™", senders: ["helena"]), + AggregatedReaction(accountOwnerID: "me", key: "๐Ÿ™Œ", senders: ["me", "helena", "jacob"]) ])), SeparatorRoomTimelineItem(id: "Today", text: "Today"), TextRoomTimelineItem(id: UUID().uuidString, @@ -67,8 +67,15 @@ enum RoomTimelineItemFixtures { sender: .init(id: "", displayName: "Bob"), content: .init(body: "New home office set up!"), properties: RoomTimelineItemProperties(reactions: [ - AggregatedReaction(key: "๐Ÿ™", count: 1, isHighlighted: false), - AggregatedReaction(key: "๐Ÿ˜", count: 3, isHighlighted: false) + 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), diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/AggregratedReaction.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/AggregratedReaction.swift index b9767221e..108ac9aa3 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/AggregratedReaction.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/AggregratedReaction.swift @@ -18,10 +18,22 @@ import Foundation /// Represents all reactions of the same type for a single event. struct AggregatedReaction: Hashable { + /// The id of the account owner + let accountOwnerID: String /// The reaction that was sent. let key: String - /// The number of times this reactions was sent. - let count: Int - /// Whether to highlight the reaction, indicating that the current user sent this reaction. - let isHighlighted: Bool + /// The user ids of those who sent the reactions + let senders: [String] +} + +extension AggregatedReaction { + /// The number of times this reactions was sent. + var count: Int { + senders.count + } + + /// Whether to highlight the reaction, indicating that the current user sent this reaction. + var isHighlighted: Bool { + senders.contains(where: { $0 == accountOwnerID }) + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 4bb97eee9..cc9c958f7 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -322,8 +322,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] { reactions.map { reaction in - let isHighlighted = reaction.senders.contains(where: { $0 == userID }) - return AggregatedReaction(key: reaction.key, count: Int(reaction.count), isHighlighted: isHighlighted) + AggregatedReaction(accountOwnerID: userID, key: reaction.key, senders: reaction.senders) + }.sorted { a, b in + a.count > b.count } } diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index 26a4a7a48..8eb5453de 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -153,6 +153,7 @@ targets: - package: Sentry - package: URLRouting - package: Version + - package: Flow sources: - path: ../Sources 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 dcb0a806e..c6be5da90 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:6b77e85ff9c5f5b1d888c97af89a511f788aa9e74eb0d609659c7d81e2b89b94 -size 231654 +oid sha256:2876d98496585f32beb44ee4e625b4ff1f790dead3db0061e15e72eecef006f6 +size 250594 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 9c06d6854..c5ba1745a 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:2c6127923272006fe70a164fca243c1683d32d0227c8066f6794957567724a41 -size 328078 +oid sha256:166a3ee681e30d0f0da944324e66ec945dfdc5c6ee480383c124fe4b003bb1e5 +size 337908 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 31e171ad0..4aea890ae 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:bfc0399661f579abcba48d2d003e710e1f54b235186d26d5fb7f6d3ffcbe235a -size 232688 +oid sha256:e0d851f39979376d0f7e33376a04ad803c818f4d1fb491db1b456cc1d6bd5e7b +size 251684 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomSmallTimelineWithReactions.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.roomSmallTimelineWithReactions.png index 1e07f453d..4e10564a2 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:6d91a63988ebcb6be677fa4030f1cc9c0bfad8ede08f1568a2ca8c82bab4f443 -size 347244 +oid sha256:b2b1b811e13e996ee452e66515936370e80d2d98fab865247721621e78c01b8e +size 338503 diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index 168f4bb67..55732df08 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -348,14 +348,13 @@ class RoomScreenViewModelTests: XCTestCase { private extension TextRoomTimelineItem { init(text: String, sender: String, addReactions: Bool = false) { + let reactions = addReactions ? [AggregatedReaction(accountOwnerID: "bob", key: "๐Ÿฆ„", senders: [sender])] : [] self.init(id: UUID().uuidString, timestamp: "10:47 am", isOutgoing: sender == "bob", isEditable: sender == "bob", sender: .init(id: "@\(sender):server.com", displayName: sender), content: .init(body: text), - properties: RoomTimelineItemProperties(reactions: addReactions ? [ - AggregatedReaction(key: "๐Ÿฆ„", count: 1, isHighlighted: false) - ] : [])) + properties: RoomTimelineItemProperties(reactions: reactions)) } } diff --git a/project.yml b/project.yml index 81b42b074..8ef7926db 100644 --- a/project.yml +++ b/project.yml @@ -103,3 +103,6 @@ packages: Version: url: https://github.com/mxcl/Version minorVersion: 2.0.0 + Flow: + url: https://github.com/tevelee/SwiftUI-Flow + minorVersion: 1.2.0