RoomScreenViewModel is now TimelineViewModel (#3157)

Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
Mauro
2024-08-13 13:36:40 +02:00
committed by GitHub
parent 4519275559
commit 1ad361a6e8
88 changed files with 2266 additions and 1973 deletions
@@ -0,0 +1,153 @@
//
// 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 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) { _ in
scrollView.scrollTo(selectedReactionKey)
}
}
}
.padding(.top, 24)
.padding(.bottom, 12)
}
private var sendersList: some View {
TabView(selection: $selectedReactionKey) {
ForEach(reactions) { reaction in
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ForEach(reaction.senders) { sender in
ReactionSummarySenderView(sender: sender, member: members[sender.id], imageProvider: imageProvider)
.padding(.horizontal, 16)
}
}
.frame(maxWidth: .infinity)
}
.tag(reaction.key)
}
}
}
}
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 imageProvider: ImageProviderProtocol?
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),
imageProvider: imageProvider)
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: [:],
imageProvider: MockMediaProvider(),
selectedReactionKey: AggregatedReaction.mockReactions[0].key)
}
}
@@ -0,0 +1,60 @@
//
// Copyright 2022 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 Compound
import SwiftUI
struct TimelineDeliveryStatusView: View {
enum Status {
case sending
case sent
}
let deliveryStatus: Status
private var icon: CompoundIcon {
switch deliveryStatus {
case .sending:
return CompoundIcon(\.circle, size: .xSmall, relativeTo: .compound.bodyMD)
case .sent:
return CompoundIcon(\.checkCircle, size: .xSmall, relativeTo: .compound.bodyMD)
}
}
var body: some View {
icon
.foregroundColor(.compound.iconSecondary)
.accessibilityLabel(accessibilityLabel)
}
private var accessibilityLabel: String {
switch deliveryStatus {
case .sending:
return L10n.commonSending
case .sent:
return L10n.commonSent
}
}
}
struct TimelineDeliveryStatusView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 8) {
TimelineDeliveryStatusView(deliveryStatus: .sending)
TimelineDeliveryStatusView(deliveryStatus: .sent)
}
}
}
@@ -0,0 +1,62 @@
//
// 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 Compound
import SwiftUI
struct TimelineItemStatusView: View {
let timelineItem: EventBasedTimelineItemProtocol
let adjustedDeliveryStatus: TimelineItemDeliveryStatus?
@EnvironmentObject private var context: TimelineViewModel.Context
private var isLastOutgoingMessage: Bool {
timelineItem.isOutgoing && context.viewState.timelineViewState.timelineIDs.last == timelineItem.id.timelineID
}
var body: some View {
mainContent
}
@ViewBuilder
private var mainContent: some View {
if context.viewState.showReadReceipts, !timelineItem.properties.orderedReadReceipts.isEmpty {
readReceipts
} else {
deliveryStatusBadge
}
}
@ViewBuilder
var deliveryStatusBadge: some View {
switch adjustedDeliveryStatus {
case .sending:
TimelineDeliveryStatusView(deliveryStatus: .sending)
case .sent, .none:
if isLastOutgoingMessage {
// We only display the sent icon for the latest outgoing message
TimelineDeliveryStatusView(deliveryStatus: .sent)
}
case .sendingFailed:
// Bubbles handle the case internally
EmptyView()
}
}
var readReceipts: some View {
TimelineReadReceiptsView(timelineItem: timelineItem)
.environmentObject(context)
}
}
@@ -0,0 +1,234 @@
//
// Copyright 2022 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 Compound
import SwiftUI
@MainActor
struct TimelineReactionsView: View {
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
@Environment(\.layoutDirection) private var layoutDirection: LayoutDirection
let context: TimelineViewModel.Context
let itemID: TimelineItemIdentifier
let reactions: [AggregatedReaction]
let isLayoutRTL: Bool
private var collapsed: Binding<Bool>
init(context: TimelineViewModel.Context,
itemID: TimelineItemIdentifier,
reactions: [AggregatedReaction],
isLayoutRTL: Bool = false) {
self.context = context
self.itemID = itemID
self.reactions = reactions
self.isLayoutRTL = isLayoutRTL
collapsed = Binding(get: {
context.reactionsCollapsed[itemID] ?? true
}, set: {
context.reactionsCollapsed[itemID] = $0
})
}
var reactionsLayoutDirection: LayoutDirection {
guard isLayoutRTL else { return layoutDirection }
return layoutDirection == .leftToRight ? .rightToLeft : .leftToRight
}
var body: some View {
layout {
ForEach(reactions) { reaction in
TimelineReactionButton(reaction: reaction) { key in
feedbackGenerator.impactOccurred()
context.send(viewAction: .toggleReaction(key: key, itemID: itemID))
} showReactionSummary: { key in
context.send(viewAction: .displayReactionSummary(itemID: itemID, key: key))
}
.reactionLayoutItem(.reaction)
.environment(\.layoutDirection, layoutDirection)
}
if isCollapsible {
Button {
collapsed.wrappedValue.toggle()
} label: {
TimelineCollapseButtonLabel(collapsed: collapsed.wrappedValue)
.transaction { $0.animation = nil }
}
.reactionLayoutItem(.expandCollapse)
.environment(\.layoutDirection, layoutDirection)
}
Button {
context.send(viewAction: .displayEmojiPicker(itemID: itemID))
} label: {
TimelineReactionAddMoreButtonLabel()
}
.reactionLayoutItem(.addMore)
}
.environment(\.layoutDirection, reactionsLayoutDirection)
.animation(.easeInOut(duration: 0.1).disabledDuringTests(), value: reactions)
.padding(.leading, 4)
}
// MARK: - Private
private var isCollapsible: Bool {
reactions.count > 5
}
private var layout: AnyLayout {
if isCollapsible {
return AnyLayout(CollapsibleReactionLayout(itemSpacing: 4,
rowSpacing: 4,
collapsed: collapsed.wrappedValue,
rowsBeforeCollapsible: 2))
}
return AnyLayout(HStackLayout(spacing: 4.0))
}
}
/// The pill shape for the label that surrounds both the reaction and collapse buttons.
struct TimelineReactionButtonLabel<Content: View>: View {
var isHighlighted = false
@ViewBuilder var content: () -> Content
var body: some View {
content()
.background(backgroundShape.inset(by: 1).fill(overlayBackgroundColor))
.overlay(backgroundShape.inset(by: 2.0).strokeBorder(overlayBorderColor))
.overlay(backgroundShape.strokeBorder(Color.compound.bgCanvasDefault, lineWidth: 2))
.accessibilityElement(children: .combine)
}
var backgroundShape: some InsettableShape {
RoundedRectangle(cornerRadius: 14, style: .continuous)
}
var overlayBackgroundColor: Color {
isHighlighted ? Color.compound.bgSubtlePrimary : .compound.bgSubtleSecondary
}
var overlayBorderColor: Color {
isHighlighted ? Color.compound.borderInteractivePrimary : .clear
}
}
struct TimelineCollapseButtonLabel: View {
var collapsed: Bool
@ScaledMetric(relativeTo: .subheadline) private var lineHeight = 20
var body: some View {
TimelineReactionButtonLabel {
Text(collapsed ? L10n.screenRoomTimelineReactionsShowMore : L10n.screenRoomTimelineReactionsShowLess)
.frame(height: lineHeight, alignment: .center)
.padding(.vertical, 6)
.padding(.horizontal, 12)
.font(.compound.bodyMD)
.foregroundColor(.compound.textPrimary)
}
}
}
struct TimelineReactionButton: View {
let reaction: AggregatedReaction
let toggleReaction: (String) -> Void
let showReactionSummary: (String) -> Void
@ScaledMetric(relativeTo: .subheadline) private var lineHeight = 20
var body: some View {
label
.onTapGesture {
toggleReaction(reaction.key)
}
.longPressWithFeedback {
showReactionSummary(reaction.key)
}
.accessibilityHint(L10n.commonReaction)
.accessibilityAddTraits(reaction.isHighlighted ? .isSelected : [])
}
var label: some View {
TimelineReactionButtonLabel(isHighlighted: reaction.isHighlighted) {
HStack(spacing: 4) {
// Designs have bodyMD for the key but practically this makes
// emojis too big. bodySM gives a more appropriate size when compared
// to the count text and the lineHeight/padding in the designs.
Text(reaction.displayKey)
.font(.compound.bodySM)
if reaction.count > 1 {
Text(String(reaction.count))
.font(.compound.bodyMD)
.foregroundColor(textColor)
}
}
.frame(height: lineHeight, alignment: .center)
.padding(.vertical, 6)
.padding(.horizontal, 12)
}
}
var textColor: Color {
reaction.isHighlighted ? Color.compound.textPrimary : .compound.textSecondary
}
}
struct TimelineReactionAddMoreButtonLabel: View {
var body: some View {
TimelineReactionButtonLabel {
CompoundIcon(\.reactionAdd, size: .xSmall, relativeTo: .compound.bodySM)
.padding(.vertical, 8)
.padding(.horizontal, 12)
.foregroundColor(.compound.iconSecondary)
.accessibilityLabel(L10n.actionReact)
}
}
}
struct TimelineReactionViewPreviewsContainer: View {
var body: some View {
VStack(spacing: 8) {
TimelineReactionsView(context: TimelineViewModel.mock.context,
itemID: .init(timelineID: "1"),
reactions: [AggregatedReaction.mockReactionWithLongText,
AggregatedReaction.mockReactionWithLongTextRTL])
Divider()
TimelineReactionsView(context: TimelineViewModel.mock.context,
itemID: .init(timelineID: "2"),
reactions: Array(AggregatedReaction.mockReactions.prefix(3)))
Divider()
TimelineReactionsView(context: TimelineViewModel.mock.context,
itemID: .init(timelineID: "3"),
reactions: AggregatedReaction.mockReactions)
Divider()
TimelineReactionsView(context: TimelineViewModel.mock.context,
itemID: .init(timelineID: "4"),
reactions: AggregatedReaction.mockReactions,
isLayoutRTL: true)
}
.background(Color.red)
.frame(maxWidth: 250, alignment: .leading)
}
}
struct TimelineReactionView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
TimelineReactionViewPreviewsContainer()
}
}
@@ -0,0 +1,137 @@
//
// 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 TimelineReadReceiptsView: View {
let displayNumber = 3
let timelineItem: EventBasedTimelineItemProtocol
@EnvironmentObject private var context: TimelineViewModel.Context
var body: some View {
HStack(spacing: 2) {
HStack(spacing: -4) {
let receiptsToDisplay = timelineItem.properties.orderedReadReceipts.prefix(displayNumber)
ForEach(0..<receiptsToDisplay.count, id: \.self) { index in
let receipt = receiptsToDisplay[index]
LoadableAvatarImage(url: context.viewState.members[receipt.userID]?.avatarURL,
name: context.viewState.members[receipt.userID]?.displayName,
contentID: receipt.userID,
avatarSize: .user(on: .readReceipt),
imageProvider: context.imageProvider)
.overlay {
RoundedRectangle(cornerRadius: .infinity)
.stroke(Color.compound.bgCanvasDefault, lineWidth: 1)
}
.zIndex(Double(displayNumber - index))
}
}
if timelineItem.properties.orderedReadReceipts.count > displayNumber {
Text("+\(remaining)")
.font(.compound.bodySM)
.foregroundColor(.compound.textPrimary)
}
}
.onTapGesture {
context.send(viewAction: .displayReadReceipts(itemID: timelineItem.id))
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
.accessibilityHint(L10n.a11yReadReceiptsTapToShowAll)
}
private var remaining: Int {
timelineItem.properties.orderedReadReceipts.count - displayNumber
}
private var accessibilityLabel: String {
if timelineItem.properties.orderedReadReceipts.count == 1 {
return L10n.a11yReadReceiptsSingle(displayName(at: 0))
} else if timelineItem.properties.orderedReadReceipts.count <= displayNumber {
let limit = timelineItem.properties.orderedReadReceipts.count - 1
let list = (0..<limit).map { displayName(at: $0) }.formatted(.list(type: .and, width: .narrow))
let last = displayName(at: limit)
return L10n.a11yReadReceiptsMultiple(list, last)
} else if timelineItem.properties.orderedReadReceipts.count > displayNumber {
let list = (0..<displayNumber).map { displayName(at: $0) }.formatted(.list(type: .and, width: .narrow))
// Plurals with string arguments aren't generated correctly so we need to use this
// https://github.com/SwiftGen/SwiftGen/issues/1089
return L10n.tr("Localizable", "a11y_read_receipts_multiple_with_others", list, remaining)
}
return ""
}
private func displayName(at index: Int) -> String {
let userID = timelineItem.properties.orderedReadReceipts[index].userID
return context.viewState.members[userID]?.displayName ?? userID
}
}
struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
static let members: [RoomMemberProxyMock] = [
.mockAlice,
.mockBob,
.mockCharlie,
.mockDan,
.mockMe
]
static let viewModel = TimelineViewModel(roomProxy: RoomProxyMock(.init(name: "Test", members: members)),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static let singleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now")]
static let doubleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),
ReadReceipt(userID: RoomMemberProxyMock.mockBob.userID, formattedTimestamp: "Before")]
static let tripleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),
ReadReceipt(userID: RoomMemberProxyMock.mockBob.userID, formattedTimestamp: "Before"),
ReadReceipt(userID: RoomMemberProxyMock.mockCharlie.userID, formattedTimestamp: "Way before")]
static let quadrupleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),
ReadReceipt(userID: RoomMemberProxyMock.mockBob.userID, formattedTimestamp: "Before"),
ReadReceipt(userID: RoomMemberProxyMock.mockCharlie.userID, formattedTimestamp: "Way before"),
ReadReceipt(userID: RoomMemberProxyMock.mockDan.userID, formattedTimestamp: "Way, way before")]
static func mockTimelineItem(with receipts: [ReadReceipt]) -> TextRoomTimelineItem {
TextRoomTimelineItem(id: .random,
timestamp: "Now",
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: UUID().uuidString), content: .init(body: "Test"),
properties: .init(orderedReadReceipts: receipts))
}
static var previews: some View {
VStack(spacing: 8) {
TimelineReadReceiptsView(timelineItem: mockTimelineItem(with: singleReceipt))
.environmentObject(viewModel.context)
TimelineReadReceiptsView(timelineItem: mockTimelineItem(with: doubleReceipt))
.environmentObject(viewModel.context)
TimelineReadReceiptsView(timelineItem: mockTimelineItem(with: tripleReceipt))
.environmentObject(viewModel.context)
TimelineReadReceiptsView(timelineItem: mockTimelineItem(with: quadrupleReceipt))
.environmentObject(viewModel.context)
}
}
}