Reaction summary and flow layout for reactions (#1158)

* Add reaction summary view

* Add avatars and display names and flowlayout for reactions

- Add avatars to summary view
- Add display names to summary view
- Add flow layout to reaction buttons as per designs(long press on  scolling buttons is difficult to achieve so prioritised this).

* Add and use SwiftUI-Flow for reaction layout.

* Correct Swift-Flow inclusion via xcodegen

* Remove own FlowLayout implementation and Fix unit tests

* Improve naming and correct spacing on summary view as per design.

* Fix safe area insets on summary view bottom sheet

* Scroll to selected reaction in the case that it is offscreen when the sheet is displayed.

* Add more reactions to UITests to ensure the new flow layout is displayed(wraps on to second line).

* Fix snapshot image.

* Address review comments.

* Use onLongPressGesture for reactions also to fix double actions bug and for consistency.

* Respect RTL languages.

* Fix unit test.
This commit is contained in:
David Langley
2023-06-29 10:53:44 +01:00
committed by GitHub
parent 3f6bfbcc14
commit 98cec5672c
20 changed files with 359 additions and 53 deletions
+25
View File
@@ -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 = "<group>"; };
142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryDetails.swift; sourceTree = "<group>"; };
1454CF3AABD242F55C8A2615 /* InviteUsersScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenModels.swift; sourceTree = "<group>"; };
153726EDCE1ACBB3D466A916 /* ReactionsSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsSummaryView.swift; sourceTree = "<group>"; };
15A657D96779D1DEB8EF1327 /* CreateRoomViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomViewModel.swift; sourceTree = "<group>"; };
16037EE9E9A52AF37B7818E3 /* AnalyticsSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenUITests.swift; sourceTree = "<group>"; };
16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenModels.swift; sourceTree = "<group>"; };
@@ -1029,6 +1033,7 @@
69219A908D7C22E6EE6689AE /* UserNotificationCenterSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationCenterSpy.swift; sourceTree = "<group>"; };
693E16574C6F7F9FA1015A8C /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = "<group>"; };
69B63F817FE305548DB4B512 /* RoomMembersListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListViewModelTests.swift; sourceTree = "<group>"; };
69CB8242D69B7E4D0B32E18D /* AggregatedReactionMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregatedReactionMock.swift; sourceTree = "<group>"; };
69D42EE0102D2857933625DD /* CreateRoomViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomViewModelTests.swift; sourceTree = "<group>"; };
6A4C9547BBFEEF30AA11329B /* TimelineItemStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemStatusView.swift; sourceTree = "<group>"; };
6A580295A56B55A856CC4084 /* InfoPlistReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoPlistReader.swift; sourceTree = "<group>"; };
@@ -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" */;
@@ -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",
@@ -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))
]
}
}
@@ -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)
@@ -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 {
@@ -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) {
@@ -20,6 +20,7 @@ import SwiftUI
struct TimelineItemBubbledStylerView<Content: View>: 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<Content: View>: 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<Content: View>: 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 { }
}
@@ -117,9 +117,10 @@ struct TimelineItemPlainStylerView<Content: View>: 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 { }
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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),
@@ -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 })
}
}
@@ -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
}
}
+1
View File
@@ -153,6 +153,7 @@ targets:
- package: Sentry
- package: URLRouting
- package: Version
- package: Flow
sources:
- path: ../Sources
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6b77e85ff9c5f5b1d888c97af89a511f788aa9e74eb0d609659c7d81e2b89b94
size 231654
oid sha256:2876d98496585f32beb44ee4e625b4ff1f790dead3db0061e15e72eecef006f6
size 250594
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2c6127923272006fe70a164fca243c1683d32d0227c8066f6794957567724a41
size 328078
oid sha256:166a3ee681e30d0f0da944324e66ec945dfdc5c6ee480383c124fe4b003bb1e5
size 337908
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bfc0399661f579abcba48d2d003e710e1f54b235186d26d5fb7f6d3ffcbe235a
size 232688
oid sha256:e0d851f39979376d0f7e33376a04ad803c818f4d1fb491db1b456cc1d6bd5e7b
size 251684
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d91a63988ebcb6be677fa4030f1cc9c0bfad8ede08f1568a2ca8c82bab4f443
size 347244
oid sha256:b2b1b811e13e996ee452e66515936370e80d2d98fab865247721621e78c01b8e
size 338503
@@ -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))
}
}
+3
View File
@@ -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