mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
+27
-25
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,7 @@ targets:
|
||||
- package: Sentry
|
||||
- package: URLRouting
|
||||
- package: Version
|
||||
- package: Flow
|
||||
|
||||
sources:
|
||||
- path: ../Sources
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6b77e85ff9c5f5b1d888c97af89a511f788aa9e74eb0d609659c7d81e2b89b94
|
||||
size 231654
|
||||
oid sha256:2876d98496585f32beb44ee4e625b4ff1f790dead3db0061e15e72eecef006f6
|
||||
size 250594
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2c6127923272006fe70a164fca243c1683d32d0227c8066f6794957567724a41
|
||||
size 328078
|
||||
oid sha256:166a3ee681e30d0f0da944324e66ec945dfdc5c6ee480383c124fe4b003bb1e5
|
||||
size 337908
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bfc0399661f579abcba48d2d003e710e1f54b235186d26d5fb7f6d3ffcbe235a
|
||||
size 232688
|
||||
oid sha256:e0d851f39979376d0f7e33376a04ad803c818f4d1fb491db1b456cc1d6bd5e7b
|
||||
size 251684
|
||||
|
||||
+2
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user