mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
RoomScreenViewModel is now TimelineViewModel (#3157)
Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user