Files
element-x-ios/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift
T
Mauro Romito e67a4c388f Read the number of selected messages to VoiceOver
The count was only on the forward button at the bottom, so VoiceOver
users had to go and find it after every tap. While selecting, each
selectable message now starts its label with "N messages selected".
The label changes on every toggle, so VoiceOver reads the new count
followed by the message, as in other messaging apps.
2026-10-01 21:39:47 +02:00

947 lines
58 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-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 Compound
import SwiftUI
struct TimelineItemBubbledStylerView<Content: View>: View {
@EnvironmentObject private var context: TimelineViewModel.Context
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
@Environment(\.focussedEventID) private var focussedEventID
let timelineItem: EventBasedTimelineItemProtocol
let adjustedDeliveryStatus: TimelineItemDeliveryStatus?
@ViewBuilder let content: () -> Content
/// Whether the item's media failed content scanning, in which case the whole bubble adopts
/// the critical styling. Reported by the item's `ContentScanningView` through the preference key.
@State private var contentScanningFailure: ContentScanningFailure?
private var hasContentScanningFailure: Bool {
contentScanningFailure != nil
}
private var isDM: Bool {
context.viewState.isDM
}
private var isFocussed: Bool {
focussedEventID != nil && timelineItem.id.eventID == focussedEventID
}
private var isPinned: Bool {
guard context.viewState.timelineKind != .pinned,
let eventID = timelineItem.id.eventID else {
return false
}
return context.viewState.pinnedEventIDs.contains(eventID)
}
private var isMessageSelectionActive: Bool {
context.viewState.messageSelection.isActive
}
private var isSelected: Bool {
context.viewState.messageSelection.contains(timelineItem.id.eventID)
}
private var accessibilitySelection: TimelineItemAccessibilityMessageSelection {
guard isMessageSelectionActive else { return .none }
return .selecting(isSelected: isSelected, selectedCount: context.viewState.messageSelection.count)
}
/// The base padding applied to bubbles on either side.
///
/// **Note:** This is on top of the insets applied to the cells by the table view.
private let bubbleHorizontalPadding: CGFloat = 8
/// Additional padding applied to outgoing bubbles when the avatar is shown
private var bubbleAvatarPadding: CGFloat {
guard !timelineItem.isOutgoing, !isDM else { return 0 }
return 8
}
var body: some View {
HStack(spacing: 10) {
if isMessageSelectionActive {
selectionIndicator
}
bubble
}
.padding(EdgeInsets(top: 1, leading: 8, bottom: 1, trailing: 8))
.highlightedTimelineItem(isFocussed)
.overlay { selectionOverlay }
.animation(.elementDefault.disabledDuringTests(), value: isMessageSelectionActive)
.onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 }
}
private var selectionIndicator: some View {
// The ZStack keeps the transition on the indicator's appearance rather than its checked state.
ZStack {
ListRowAccessory.multiSelection(isSelected)
.opacity(timelineItem.isForwardable ? 1 : 0) // Keeps the bubbles aligned.
}
.padding(.leading, 8)
.transition(.move(edge: .leading).combined(with: .opacity))
.accessibilityHidden(true) // The bubble itself carries the selected state.
}
/// Captures every touch while selecting so the content's own gestures can't fire.
@ViewBuilder
private var selectionOverlay: some View {
if isMessageSelectionActive {
Color.clear
.contentShape(Rectangle())
.onTapGesture { toggleSelection() }
.onLongPressGesture { toggleSelection() }
.accessibilityHidden(true)
}
}
private var bubble: some View {
ZStack(alignment: .trailingFirstTextBaseline) {
VStack(alignment: alignment, spacing: -8) {
if !timelineItem.isOutgoing, !isDM {
header
.zIndex(1)
}
VStack(alignment: alignment, spacing: 0) {
HStack(spacing: 0) {
if timelineItem.isOutgoing {
Spacer()
}
messageBubbleWithReactions
}
.padding(timelineItem.isOutgoing ? .leading : .trailing, 48) // Additional padding to differentiate alignment.
HStack(spacing: 0) {
if !timelineItem.isOutgoing {
Spacer()
}
TimelineItemStatusView(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus)
.environmentObject(context)
.padding(.top, 8)
.padding(.bottom, 3)
}
}
.padding(.horizontal, bubbleHorizontalPadding)
.padding(.leading, bubbleAvatarPadding)
}
}
}
@ViewBuilder
private var header: some View {
if shouldShowSenderDetails {
HStack(alignment: .top, spacing: 4) {
TimelineSenderAvatarView(timelineItem: timelineItem)
HStack(alignment: .center, spacing: 4) {
Text(timelineItem.sender.displayName ?? timelineItem.sender.id)
.font(.compound.bodyMDSemibold)
.foregroundStyle(.compound.decorativeColor(for: timelineItem.sender.id).text)
if let statusEmoji = timelineItem.sender.status.displayed?.emoji {
Text(String(statusEmoji))
.font(.compound.bodyMDSemibold)
.foregroundStyle(.compound.textPrimary)
}
if timelineItem.sender.displayName != nil, timelineItem.sender.isDisplayNameAmbiguous {
Text(timelineItem.sender.id)
.font(.compound.bodyXS)
.foregroundStyle(.compound.textSecondary)
}
}
.lineLimit(1)
.scaledPadding(.top, 3)
}
// sender info are read inside the `TimelineAccessibilityModifier`
.accessibilityHidden(true)
.onTapGesture {
context.send(viewAction: .tappedOnSenderDetails(sender: timelineItem.sender))
}
.padding(.top, 8)
}
}
private var messageBubbleWithReactions: some View {
// Figma overlaps reactions by 3
VStack(alignment: alignment, spacing: -3) {
messageBubbleWithActions
.timelineItemAccessibility(timelineItem, selection: accessibilitySelection) {
if isMessageSelectionActive {
toggleSelection()
} else {
context.send(viewAction: .displayTimelineItemMenu(itemID: timelineItem.id))
}
}
// Do not display reactions in the pinned events timeline
if context.viewState.timelineKind != .pinned,
!timelineItem.properties.reactions.isEmpty {
TimelineReactionsView(context: context,
itemID: timelineItem.id,
reactions: timelineItem.properties.reactions,
isLayoutRTL: timelineItem.isOutgoing)
// Workaround to stop the message long press stealing the touch from the reaction buttons
.onTapGesture { }
.accessibilityHidden(isMessageSelectionActive)
}
if context.viewState.areThreadsEnabled,
!context.viewState.timelineKind.isThread,
let threadSummary = timelineItem.properties.threadSummary {
TimelineThreadSummaryView(threadSummary: threadSummary) {
context.send(viewAction: .displayThread(itemID: timelineItem.id))
}
.padding(5)
.accessibilityHidden(isMessageSelectionActive)
}
}
}
var messageBubbleWithActions: some View {
messageBubble
.onTapGesture {
// We need a tap gesture before the long press gesture below, otherwise something
// on iOS 17 hijacks the long press and you can't bring up the context menu. This
// is no longer an issue on iOS 18. Note: it's fine for this to be empty, we handle
// specific taps within the timeline views themselves.
}
// The UIKit recogniser would still receive touches through the selection overlay.
.longPressWithFeedback(isEnabled: !isMessageSelectionActive) {
context.send(viewAction: .displayTimelineItemMenu(itemID: timelineItem.id))
}
.swipeRightAction {
CompoundIcon(\.reply)
.foregroundColor(.compound.iconPrimary)
.accessibilityHidden(true)
} shouldStartAction: {
!isMessageSelectionActive && timelineItem.canBeRepliedTo
} action: {
context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id,
action: .reply(isThread: timelineItem.properties.isThreaded)))
}
.contextMenu {
if !isMessageSelectionActive {
macContextMenu
}
}
.pinnedIndicator(isPinned: isPinned, isOutgoing: timelineItem.isOutgoing)
.padding(.top, messageBubbleTopPadding)
}
@ViewBuilder
private var macContextMenu: some View {
let provider = TimelineItemMenuActionProvider(timelineItem: timelineItem,
canCurrentUserSendMessage: context.viewState.canCurrentUserSendMessage,
canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf,
canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers,
canCurrentUserPin: context.viewState.canCurrentUserPin,
pinnedEventIDs: context.viewState.pinnedEventIDs,
isViewSourceEnabled: context.viewState.isViewSourceEnabled,
areThreadsEnabled: context.viewState.areThreadsEnabled,
timelineKind: context.viewState.timelineKind,
emojiProvider: context.viewState.emojiProvider)
TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in
context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action))
}
}
var messageBubble: some View {
contentWithReply
.timelineItemSendInfo(timelineItem: timelineItem,
adjustedDeliveryStatus: adjustedDeliveryStatus,
hasContentScanningFailure: hasContentScanningFailure,
context: context)
.bubbleBackground(isOutgoing: timelineItem.isOutgoing,
insets: timelineItem.bubbleInsets(hasContentScanningFailure: hasContentScanningFailure),
color: hasContentScanningFailure ? .compound.bgCriticalSubtle : timelineItem.bubbleBackgroundColor,
borderColor: hasContentScanningFailure ? .compound.borderCriticalSubtle : nil)
}
var contentWithReply: some View {
TimelineBubbleLayout(spacing: 8) {
if !context.viewState.timelineKind.isThread, timelineItem.properties.isThreaded {
ThreadDecorator()
.padding(.leading, 4)
.timelineBubbleLayoutSize(.natural)
}
if let replyDetails = timelineItem.properties.replyDetails {
// The rendered reply bubble with a greedy width. The custom layout prevents
// the infinite width from increasing the overall width of the view.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails, maxWidth: .infinity)
.timelineBubbleLayoutSize(.bubbleWidth(mode: .rendering))
.onTapGesture {
if context.viewState.timelineKind != .pinned {
context.send(viewAction: .focusOnEventID(replyDetails.eventID))
}
}
// Add a fixed width reply bubble that is used for layout calculations but won't be rendered.
TimelineReplyView(placement: .timeline, timelineItemReplyDetails: replyDetails)
.timelineBubbleLayoutSize(.bubbleWidth(mode: .layout))
.hidden()
}
content()
.timelineBubbleLayoutSize(.natural)
.cornerRadius(timelineItem.contentCornerRadius)
}
}
private func toggleSelection() {
guard timelineItem.isForwardable else { return }
context.send(viewAction: .toggleMessageSelection(itemID: timelineItem.id))
}
private var messageBubbleTopPadding: CGFloat {
guard timelineItem.isOutgoing || isDM else { return 0 }
return timelineGroupStyle == .single || timelineGroupStyle == .first ? 8 : 0
}
private var alignment: HorizontalAlignment {
timelineItem.isOutgoing ? .trailing : .leading
}
private var shouldShowSenderDetails: Bool {
timelineGroupStyle.shouldShowSenderDetails
}
}
private extension EventBasedTimelineItemProtocol {
var bubbleBackgroundColor: Color? {
let defaultColor: Color = isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming
switch self {
case is ImageRoomTimelineItem, is VideoRoomTimelineItem:
// In case a reply detail or a thread decorator is present we render the color and the padding
return properties.replyDetails != nil || properties.isThreaded || hasMediaCaption ? defaultColor : nil
case is StickerRoomTimelineItem:
return nil
default:
return defaultColor
}
}
/// The insets for the full bubble content.
/// Padding affecting just the "send info" should be added inside `TimelineItemSendInfoView`
func bubbleInsets(hasContentScanningFailure: Bool) -> EdgeInsets {
let defaultInsets: EdgeInsets = .init(around: 8)
// The content scanner failure placeholder is always rendered inset within the critical bubble.
if hasContentScanningFailure {
return defaultInsets
}
switch self {
case is StickerRoomTimelineItem:
return .zero
case is PollRoomTimelineItem:
return .init(top: 12, leading: 12, bottom: 4, trailing: 12)
// In case a reply detail or a thread decorator is present we render the color and the padding
case is ImageRoomTimelineItem, is VideoRoomTimelineItem:
return properties.replyDetails != nil || properties.isThreaded || hasMediaCaption ? defaultInsets : .zero
case let locationTimelineItem as LocationRoomTimelineItem:
return locationTimelineItem.content.geoURI == nil ||
properties.replyDetails != nil ||
properties.isThreaded ? defaultInsets : .zero
case is LiveLocationRoomTimelineItem:
return properties.replyDetails != nil ||
properties.isThreaded ? defaultInsets : .zero
default:
return defaultInsets
}
}
var contentCornerRadius: CGFloat {
switch self {
case is ImageRoomTimelineItem, is VideoRoomTimelineItem, is LocationRoomTimelineItem, is LiveLocationRoomTimelineItem:
return properties.replyDetails != nil || properties.isThreaded ? 8 : .zero
default:
return .zero
}
}
}
private extension EdgeInsets {
init(around: CGFloat) {
self.init(top: around, leading: around, bottom: around, trailing: around)
}
static var zero: Self = .init(around: 0)
}
private struct PinnedIndicatorViewModifier: ViewModifier {
let isPinned: Bool
let isOutgoing: Bool
func body(content: Content) -> some View {
if isPinned {
HStack(alignment: .top, spacing: 8) {
if isOutgoing {
pinnedIndicator
}
content
.layoutPriority(1)
if !isOutgoing {
pinnedIndicator
}
}
} else {
content
}
}
private var pinnedIndicator: some View {
CompoundIcon(\.pinSolid, size: .xSmall, relativeTo: .compound.bodyMD)
.foregroundStyle(Color.compound.iconTertiary)
.accessibilityLabel(L10n.commonPinned)
}
}
private extension View {
func pinnedIndicator(isPinned: Bool, isOutgoing: Bool) -> some View {
modifier(PinnedIndicatorViewModifier(isPinned: isPinned, isOutgoing: isOutgoing))
}
}
private extension TimelineItemKeyForwarder {
static var test: TimelineItemKeyForwarder {
TimelineItemKeyForwarder(id: "@alice:matrix.org", displayName: "alice")
}
}
// MARK: - Previews
struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview {
static let viewModel: TimelineViewModel = {
let userSettings = UserSettings.volatile()
userSettings.threadsEnabled = true
let userSession = UserSessionMock(.init(userSettings: userSettings))
let roomProxy = JoinedRoomProxyMock(.init())
return TimelineViewModel(roomProxy: roomProxy,
focussedEventID: nil,
timelineController: TimelineControllerMock(.init()),
userSession: userSession,
mediaPlayerProvider: MediaPlayerProviderMock(),
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock(.init()),
analyticsService: AnalyticsServiceMock(.init()),
emojiProvider: EmojiProvider(userSettings: userSettings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
}()
static let viewModelWithPins: TimelineViewModel = {
let userSettings = UserSettings.volatile()
userSettings.threadsEnabled = true
let userSession = UserSessionMock(.init(userSettings: userSettings))
let roomProxy = JoinedRoomProxyMock(.init(name: "Preview Room", pinnedEventIDs: ["pinned"]))
return TimelineViewModel(roomProxy: roomProxy,
focussedEventID: nil,
timelineController: TimelineControllerMock(.init()),
userSession: userSession,
mediaPlayerProvider: MediaPlayerProviderMock(),
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock(.init()),
analyticsService: AnalyticsServiceMock(.init()),
emojiProvider: EmojiProvider(userSettings: userSettings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
}()
static let unsafeViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: false)))
static let selectingViewModel: TimelineViewModel = {
let userSettings = UserSettings.volatile()
userSettings.messageMultiSelectEnabled = true
let userSession = UserSessionMock(.init(userSettings: userSettings))
let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init()),
focussedEventID: nil,
timelineController: TimelineControllerMock(.init()),
userSession: userSession,
mediaPlayerProvider: MediaPlayerProviderMock(),
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock(.init()),
analyticsService: AnalyticsServiceMock(.init()),
emojiProvider: EmojiProvider(userSettings: userSettings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
viewModel.state.messageSelection.isActive = true
viewModel.state.messageSelection.selectedEventIDs = ["selected"]
return viewModel
}()
static var previews: some View {
mockTimeline
.previewDisplayName("Mock Timeline")
.previewLayout(.fixed(width: 390, height: 900))
.padding(.bottom, 20)
mockTimeline
.environment(\.layoutDirection, .rightToLeft)
.previewDisplayName("Mock Timeline RTL")
.previewLayout(.fixed(width: 390, height: 900))
.padding(.bottom, 20)
replies
.previewDisplayName("Replies")
threadDecorator
.previewDisplayName("Thread decorator")
.previewLayout(.fixed(width: 390, height: 1700))
.padding(.bottom, 20)
threadSummary
.previewDisplayName("Thread summary")
.previewLayout(.fixed(width: 390, height: 1700))
.padding(.bottom, 20)
encryptionAuthenticity
.previewDisplayName("Encryption Indicators")
encryptionForwarder
.previewLayout(.sizeThatFits)
.previewDisplayName("Encryption Forwarder Info")
pinned
.previewDisplayName("Pinned messages")
.previewLayout(.fixed(width: 390, height: 1150))
.padding(.bottom, 20)
selection
.previewDisplayName("Selection")
}
static var selection: some View {
VStack(spacing: 0) {
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .event(uniqueID: .init("1"), eventOrTransactionID: .eventID("selected")),
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
content: .init(body: "A selected message.")),
groupStyle: .single))
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .event(uniqueID: .init("2"), eventOrTransactionID: .eventID("unselected")),
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org"),
content: .init(body: "A message that isn't selected.")),
groupStyle: .single))
RoomTimelineItemView(viewState: .init(item: RedactedRoomTimelineItem(id: .event(uniqueID: .init("3"), eventOrTransactionID: .eventID("redacted")),
body: "Message removed",
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: false,
sender: .init(id: "@alice:matrix.org", displayName: "Alice")),
groupStyle: .single))
}
.environmentObject(selectingViewModel.context)
.environment(\.timelineContext, selectingViewModel.context)
}
static var mockTimeline: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(viewModel.state.timelineState.itemViewStates) { viewState in
RoomTimelineItemView(viewState: viewState)
}
}
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
}
static var replies: some View {
VStack(spacing: 0) {
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
properties: .init(replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short")))))),
groupStyle: .single))
let properties = RoomTimelineItemProperties(replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "A long message that should be on more than 2 lines and so will be clipped by the layout.")))))
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "Short message"),
properties: properties),
groupStyle: .single))
RoomTimelineItemView(viewState: .init(item: FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(filename: "unsafe.pdf",
caption: "Replying with an unsafe file.",
formattedCaption: nil,
source: try? MediaSourceProxy(url: .mockMXCFile, mimeType: nil),
fileSize: 3 * 1024 * 1024,
thumbnailSource: nil,
contentType: nil),
properties: .init(replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short")))))),
groupStyle: .single))
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
// A safe message replying to an unsafe image: only the reply preview shows the failure.
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "Replying to an unsafe image."),
properties: .init(replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.image(.init(filename: "amazing.jpeg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail)))))),
groupStyle: .single))
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
// An unsafe file replying to an unsafe image: the whole bubble and the reply preview show the failure.
RoomTimelineItemView(viewState: .init(item: FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(filename: "unsafe.pdf",
caption: "Replying with an unsafe file to an unsafe image.",
formattedCaption: nil,
source: try? MediaSourceProxy(url: .mockMXCFile, mimeType: nil),
fileSize: 3 * 1024 * 1024,
thumbnailSource: nil,
contentType: nil),
properties: .init(replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.image(.init(filename: "amazing.jpeg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail)))))),
groupStyle: .single))
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
}
static var threadDecorator: some View {
ScrollView {
MockTimelineContent(isThreaded: true)
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
}
static var threadSummary: some View {
ScrollView {
let threadSummary = TimelineItemThreadSummary.loaded(senderID: "@alice:matrix.org",
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
latestEventContent: .message(.text(.init(body: "This is a very long, multi-lined, threaded message"))),
numberOfReplies: 42)
MockTimelineContent(threadSummary: threadSummary)
}
.environmentObject(viewModelWithPins.context)
.environment(\.timelineContext, viewModel.context)
}
static var pinned: some View {
ScrollView {
MockTimelineContent(isPinned: true)
}
.environmentObject(viewModelWithPins.context)
.environment(\.timelineContext, viewModel.context)
}
static var encryptionAuthenticity: some View {
VStack(spacing: 0) {
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
properties: RoomTimelineItemProperties(encryptionAuthenticity: .unsignedDevice(color: .red))),
groupStyle: .single))
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
properties: RoomTimelineItemProperties(isEdited: true,
encryptionAuthenticity: .unsignedDevice(color: .red))),
groupStyle: .single))
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "Short message"),
properties: RoomTimelineItemProperties(encryptionAuthenticity: .unknownDevice(color: .red))),
groupStyle: .first))
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "Message goes Here"),
properties: RoomTimelineItemProperties(encryptionAuthenticity: .notGuaranteed(color: .gray))),
groupStyle: .last))
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(filename: "other.png",
imageInfo: .mockImage,
thumbnailInfo: nil),
properties: RoomTimelineItemProperties(encryptionAuthenticity: .notGuaranteed(color: .gray))))
VoiceMessageRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: ""),
content: .init(filename: "audio.ogg",
duration: 100,
waveform: EstimatedWaveform.mockWaveform,
source: nil,
fileSize: nil,
contentType: nil),
properties: RoomTimelineItemProperties(isThreaded: true,
encryptionAuthenticity: .notGuaranteed(color: .gray))),
playerState: AudioPlayerState(id: .timelineItemIdentifier(.randomEvent),
title: L10n.commonVoiceMessage,
duration: 10,
waveform: EstimatedWaveform.mockWaveform))
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
}
static var encryptionForwarder: some View {
VStack(spacing: 0) {
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
properties: RoomTimelineItemProperties(isEdited: true, encryptionForwarder: .test)),
groupStyle: .single))
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
properties: RoomTimelineItemProperties(encryptionForwarder: .test)),
groupStyle: .single))
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "Short message"),
properties: RoomTimelineItemProperties(encryptionForwarder: .test)),
groupStyle: .first))
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "Message goes Here"),
properties: RoomTimelineItemProperties(encryptionForwarder: .test)),
groupStyle: .last))
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(filename: "other.png",
imageInfo: .mockImage,
thumbnailInfo: nil),
properties: RoomTimelineItemProperties(encryptionForwarder: .test)))
VoiceMessageRoomTimelineView(timelineItem: .init(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: ""),
content: .init(filename: "audio.ogg",
duration: 100,
waveform: EstimatedWaveform.mockWaveform,
source: nil,
fileSize: nil,
contentType: nil),
properties: RoomTimelineItemProperties(isThreaded: true,
encryptionForwarder: .test)),
playerState: AudioPlayerState(id: .timelineItemIdentifier(.randomEvent),
title: L10n.commonVoiceMessage,
duration: 10,
waveform: EstimatedWaveform.mockWaveform))
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
}
}
private struct MockTimelineContent: View {
var isThreaded = false
var isPinned = false
var threadSummary: TimelineItemThreadSummary?
var body: some View {
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: makeItemIdentifier(),
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "whoever"),
content: .init(body: "A long message that should be on multiple lines."),
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)),
groupStyle: .single))
AudioRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: ""),
content: .init(filename: "audio.ogg",
duration: 100,
waveform: EstimatedWaveform.mockWaveform,
source: nil,
fileSize: nil,
contentType: nil),
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)))
FileRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: ""),
content: .init(filename: "file.txt",
caption: "File",
source: nil,
fileSize: nil,
thumbnailSource: nil,
contentType: nil),
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)))
ImageRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
timestamp: .mock,
isOutgoing: true,
isEditable: true,
canBeRepliedTo: true,
sender: .init(id: ""),
content: .init(filename: "image.jpg",
imageInfo: .mockImage,
thumbnailInfo: nil),
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)))
LocationRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "Bob"),
content: .init(body: "Fallback geo uri description",
geoURI: .init(latitude: 41.902782,
longitude: 12.496366)),
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)))
VoiceMessageRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
timestamp: .mock,
isOutgoing: true,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: ""),
content: .init(filename: "audio.ogg",
duration: 100,
waveform: EstimatedWaveform.mockWaveform,
source: nil,
fileSize: nil,
contentType: nil),
properties: .init(replyDetails: replyDetails,
isThreaded: isThreaded,
threadSummary: threadSummary)),
playerState: AudioPlayerState(id: .timelineItemIdentifier(.randomEvent),
title: L10n.commonVoiceMessage,
duration: 10,
waveform: EstimatedWaveform.mockWaveform))
}
func makeItemIdentifier() -> TimelineItemIdentifier {
isPinned ? .event(uniqueID: .init(""), eventOrTransactionID: .eventID("pinned")) : .randomEvent
}
var replyDetails: TimelineItemReplyDetails? {
isThreaded ? .loaded(sender: .init(id: "", displayName: "Alice"),
eventID: "123",
eventContent: .message(.text(.init(body: "Short")))) : nil
}
}