mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
fc75f90ef5
* Remove the dynamic member lookup from UserSettings in favour of nested access. * Remove forwarding methods from UserSettings. * Update the Enterprise submodule.
947 lines
58 KiB
Swift
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.mock()
|
|
userSettings.app.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.mock()
|
|
userSettings.app.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.mock()
|
|
userSettings.app.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
|
|
}
|
|
}
|