// // Copyright 2025 Element Creations Ltd. // Copyright 2023-2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // import SwiftUI struct ReactionsSummaryView: View { let reactions: [AggregatedReaction] let members: [String: RoomMemberState] let mediaProvider: MediaProviderProtocol? @State var selectedReactionKey: String private var selectedReaction: AggregatedReaction? { reactions.first { $0.key == selectedReactionKey } } 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(spacing: 8) { ForEach(reactions) { reaction in ReactionSummaryButton(reaction: reaction, highlighted: selectedReactionKey == reaction.key) { key in selectedReactionKey = key } } } .padding(.horizontal, 20) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { scrollView.scrollTo(selectedReactionKey) } } .onChange(of: selectedReactionKey) { scrollView.scrollTo(selectedReactionKey) } } } .padding(.top, 24) .padding(.bottom, 12) } @ViewBuilder private var sendersList: some View { if let selectedReaction { ScrollView { VStack(alignment: .leading, spacing: 8) { ForEach(selectedReaction.senders) { sender in ReactionSummarySenderView(sender: sender, member: members[sender.id], mediaProvider: mediaProvider) .padding(.horizontal, 16) } } .frame(maxWidth: .infinity) } } } } 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.displayKey) .font(.compound.headingSM) .foregroundColor(textColor) if reaction.count > 1 { Text(String(reaction.count)) .font(.compound.headingSM) .foregroundColor(textColor) } } .padding(.vertical, 8) .padding(.horizontal, 12) .background(highlighted ? Color.compound.bgActionPrimaryRest : .clear, in: Capsule()) .accessibilityElement(children: .combine) } var textColor: Color { highlighted ? Color.compound.textOnSolidPrimary : Color.compound.textSecondary } } private struct ReactionSummarySenderView: View { var sender: ReactionSender var member: RoomMemberState? let mediaProvider: MediaProviderProtocol? var displayName: String { member?.displayName ?? sender.id } var body: some View { HStack(spacing: 8) { LoadableAvatarImage(url: member?.avatarURL, name: displayName, contentID: sender.id, avatarSize: .user(on: .timeline), mediaProvider: mediaProvider) VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { Text(displayName) .font(.compound.bodyMDSemibold) .frame(maxWidth: .infinity, alignment: .leading) Text(sender.timestamp.formattedMinimal()) .font(.compound.bodyXS) .foregroundColor(.compound.textSecondary) } Text(sender.id) .font(.compound.bodySM) .foregroundColor(.compound.textSecondary) } } .padding(.vertical, 8) } } struct ReactionsSummaryView_Previews: PreviewProvider, TestablePreview { static var previews: some View { ReactionsSummaryView(reactions: AggregatedReaction.mockReactions, members: [:], mediaProvider: MediaProviderMock(.init()), selectedReactionKey: AggregatedReaction.mockReactions[0].key) } }