mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Introduce a TimelineItemThreadSummary object (#4032)
* Introduce a `TimelineItemThreadSummary` object to hold details about threads starting from that particular item This patch introduces a thread summary object that will be available on main timeline messages that are the root for a given thread. It currently provides the latest message content and sender for that thread but it will grow to provide info on the number of replies, unreads etc. It also add a new UI component called `TimelineThreadSummaryView` that makes use of this data and is in turn used by the bubbled styler to render it in the timeline. The rest of the PR is about refactoring on the `RoomTimelineItemFactory` so that replies and thread summaries use similar paths and builders. * Add a feature flag for threads * Address PR comments * Converge on single implementation for message previews
This commit is contained in:
@@ -131,12 +131,12 @@ struct TimelineReplyView: View {
|
||||
.cornerRadius(icon?.cornerRadii ?? 0.0, corners: .allCorners)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(sender.displayName ?? sender.id)
|
||||
Text(sender.disambiguatedDisplayName ?? sender.id)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.accessibilityLabel(L10n.commonInReplyTo(sender.displayName ?? sender.id))
|
||||
.accessibilityLabel(L10n.commonInReplyTo(sender.disambiguatedDisplayName ?? sender.id))
|
||||
|
||||
Text(messagePreview)
|
||||
Text(context.viewState.buildMessagePreview(formattedBody: formattedBody, plainBody: plainBody))
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.tint(.compound.textLinkExternal)
|
||||
@@ -175,56 +175,6 @@ struct TimelineReplyView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The string shown as the message preview.
|
||||
///
|
||||
/// This converts the formatted body to a plain string to remove formatting
|
||||
/// and render with a consistent font size. This conversion is done to avoid
|
||||
/// showing markdown characters in the preview for messages with formatting.
|
||||
private var messagePreview: String {
|
||||
guard let formattedBody,
|
||||
let attributedString = try? NSMutableAttributedString(formattedBody, including: \.elementX) else {
|
||||
return plainBody
|
||||
}
|
||||
|
||||
let range = NSRange(location: 0, length: attributedString.length)
|
||||
attributedString.enumerateAttributes(in: range) { attributes, range, _ in
|
||||
if let userID = attributes[.MatrixUserID] as? String {
|
||||
if let displayName = context.viewState.members[userID]?.displayName {
|
||||
attributedString.replaceCharacters(in: range, with: "@\(displayName)")
|
||||
} else {
|
||||
attributedString.replaceCharacters(in: range, with: userID)
|
||||
}
|
||||
}
|
||||
|
||||
if attributes[.MatrixAllUsersMention] as? Bool == true {
|
||||
attributedString.replaceCharacters(in: range, with: PillUtilities.atRoom)
|
||||
}
|
||||
|
||||
if let roomAlias = attributes[.MatrixRoomAlias] as? String {
|
||||
let roomName = context.viewState.roomNameForAliasResolver?(roomAlias)
|
||||
attributedString.replaceCharacters(in: range, with: PillUtilities.roomPillDisplayText(roomName: roomName, rawRoomText: roomAlias))
|
||||
}
|
||||
|
||||
if let roomID = attributes[.MatrixRoomID] as? String {
|
||||
let roomName = context.viewState.roomNameForIDResolver?(roomID)
|
||||
attributedString.replaceCharacters(in: range, with: PillUtilities.roomPillDisplayText(roomName: roomName, rawRoomText: roomID))
|
||||
}
|
||||
|
||||
if let eventOnRoomID = attributes[.MatrixEventOnRoomID] as? EventOnRoomIDAttribute.Value {
|
||||
let roomID = eventOnRoomID.roomID
|
||||
let roomName = context.viewState.roomNameForIDResolver?(roomID)
|
||||
attributedString.replaceCharacters(in: range, with: PillUtilities.eventPillDisplayText(roomName: roomName, rawRoomText: roomID))
|
||||
}
|
||||
|
||||
if let eventOnRoomAlias = attributes[.MatrixEventOnRoomAlias] as? EventOnRoomAliasAttribute.Value {
|
||||
let roomAlias = eventOnRoomAlias.alias
|
||||
let roomName = context.viewState.roomNameForAliasResolver?(roomAlias)
|
||||
attributedString.replaceCharacters(in: range, with: PillUtilities.eventPillDisplayText(roomName: roomName, rawRoomText: eventOnRoomAlias.alias))
|
||||
}
|
||||
}
|
||||
return attributedString.string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
// Workaround to stop the message long press stealing the touch from the reaction buttons
|
||||
.onTapGesture { }
|
||||
}
|
||||
|
||||
if let threadSummary = timelineItem.properties.threadSummary {
|
||||
TimelineThreadSummaryView(threadSummary: threadSummary) {
|
||||
context.send(viewAction: .displayThread(itemID: timelineItem.id))
|
||||
}
|
||||
.padding(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,10 +347,14 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
|
||||
.padding(.bottom, 20)
|
||||
replies
|
||||
.previewDisplayName("Replies")
|
||||
threads
|
||||
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")
|
||||
pinned
|
||||
@@ -395,13 +406,25 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
|
||||
.environment(\.timelineContext, viewModel.context)
|
||||
}
|
||||
|
||||
static var threads: some View {
|
||||
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 threaded message"))))
|
||||
|
||||
MockTimelineContent(threadSummary: threadSummary)
|
||||
}
|
||||
.environmentObject(viewModelWithPins.context)
|
||||
.environment(\.timelineContext, viewModel.context)
|
||||
}
|
||||
|
||||
static var pinned: some View {
|
||||
ScrollView {
|
||||
@@ -493,6 +516,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
|
||||
private struct MockTimelineContent: View {
|
||||
var isThreaded = false
|
||||
var isPinned = false
|
||||
var threadSummary: TimelineItemThreadSummary?
|
||||
|
||||
var body: some View {
|
||||
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: makeItemIdentifier(),
|
||||
@@ -502,7 +526,9 @@ private struct MockTimelineContent: View {
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "whoever"),
|
||||
content: .init(body: "A long message that should be on multiple lines."),
|
||||
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)),
|
||||
properties: .init(replyDetails: replyDetails,
|
||||
isThreaded: isThreaded,
|
||||
threadSummary: threadSummary)),
|
||||
groupStyle: .single))
|
||||
|
||||
AudioRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
|
||||
@@ -517,7 +543,9 @@ private struct MockTimelineContent: View {
|
||||
source: nil,
|
||||
fileSize: nil,
|
||||
contentType: nil),
|
||||
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)))
|
||||
properties: .init(replyDetails: replyDetails,
|
||||
isThreaded: isThreaded,
|
||||
threadSummary: threadSummary)))
|
||||
|
||||
FileRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
|
||||
timestamp: .mock,
|
||||
@@ -531,7 +559,9 @@ private struct MockTimelineContent: View {
|
||||
fileSize: nil,
|
||||
thumbnailSource: nil,
|
||||
contentType: nil),
|
||||
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)))
|
||||
properties: .init(replyDetails: replyDetails,
|
||||
isThreaded: isThreaded,
|
||||
threadSummary: threadSummary)))
|
||||
|
||||
ImageRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
|
||||
timestamp: .mock,
|
||||
@@ -542,7 +572,9 @@ private struct MockTimelineContent: View {
|
||||
content: .init(filename: "image.jpg",
|
||||
imageInfo: .mockImage,
|
||||
thumbnailInfo: nil),
|
||||
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)))
|
||||
properties: .init(replyDetails: replyDetails,
|
||||
isThreaded: isThreaded,
|
||||
threadSummary: threadSummary)))
|
||||
|
||||
LocationRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
|
||||
timestamp: .mock,
|
||||
@@ -554,7 +586,9 @@ private struct MockTimelineContent: View {
|
||||
geoURI: .init(latitude: 41.902782,
|
||||
longitude: 12.496366),
|
||||
description: "Location description description description description description description description description"),
|
||||
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)))
|
||||
properties: .init(replyDetails: replyDetails,
|
||||
isThreaded: isThreaded,
|
||||
threadSummary: threadSummary)))
|
||||
|
||||
LocationRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
|
||||
timestamp: .mock,
|
||||
@@ -564,7 +598,9 @@ private struct MockTimelineContent: View {
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(body: "Fallback geo uri description",
|
||||
geoURI: .init(latitude: 41.902782, longitude: 12.496366), description: nil),
|
||||
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)))
|
||||
properties: .init(replyDetails: replyDetails,
|
||||
isThreaded: isThreaded,
|
||||
threadSummary: threadSummary)))
|
||||
|
||||
VoiceMessageRoomTimelineView(timelineItem: .init(id: makeItemIdentifier(),
|
||||
timestamp: .mock,
|
||||
@@ -578,7 +614,9 @@ private struct MockTimelineContent: View {
|
||||
source: nil,
|
||||
fileSize: nil,
|
||||
contentType: nil),
|
||||
properties: .init(replyDetails: replyDetails, isThreaded: isThreaded)),
|
||||
properties: .init(replyDetails: replyDetails,
|
||||
isThreaded: isThreaded,
|
||||
threadSummary: threadSummary)),
|
||||
playerState: AudioPlayerState(id: .timelineItemIdentifier(.randomEvent),
|
||||
title: L10n.commonVoiceMessage,
|
||||
duration: 10,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//
|
||||
// Copyright 2023, 2024 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 TimelineThreadSummaryView: View {
|
||||
let threadSummary: TimelineItemThreadSummary
|
||||
var onTap: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
onTap?()
|
||||
} label: {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch threadSummary {
|
||||
case .loaded(let senderID, let sender, let latestEventContent):
|
||||
switch latestEventContent {
|
||||
case .message(let content):
|
||||
switch content {
|
||||
case .audio(let content):
|
||||
ThreadView(senderID: senderID,
|
||||
sender: sender,
|
||||
plainBody: content.caption ?? content.filename,
|
||||
formattedBody: content.formattedCaption)
|
||||
case .emote(let content):
|
||||
ThreadView(senderID: senderID,
|
||||
sender: sender,
|
||||
plainBody: content.body,
|
||||
formattedBody: content.formattedBody)
|
||||
case .file(let content):
|
||||
ThreadView(senderID: senderID,
|
||||
sender: sender,
|
||||
plainBody: content.caption ?? content.filename,
|
||||
formattedBody: content.formattedCaption)
|
||||
case .image(let content):
|
||||
ThreadView(senderID: senderID,
|
||||
sender: sender,
|
||||
plainBody: content.caption ?? content.filename,
|
||||
formattedBody: content.formattedCaption)
|
||||
case .notice(let content):
|
||||
ThreadView(senderID: senderID,
|
||||
sender: sender,
|
||||
plainBody: content.body,
|
||||
formattedBody: content.formattedBody)
|
||||
case .text(let content):
|
||||
ThreadView(senderID: senderID,
|
||||
sender: sender,
|
||||
plainBody: content.body,
|
||||
formattedBody: content.formattedBody)
|
||||
case .video(let content):
|
||||
ThreadView(senderID: senderID,
|
||||
sender: sender,
|
||||
plainBody: content.caption ?? content.filename,
|
||||
formattedBody: content.formattedCaption)
|
||||
case .voice:
|
||||
ThreadView(senderID: senderID,
|
||||
sender: sender,
|
||||
plainBody: L10n.commonVoiceMessage,
|
||||
formattedBody: nil)
|
||||
case .location:
|
||||
ThreadView(senderID: senderID,
|
||||
sender: sender,
|
||||
plainBody: L10n.commonSharedLocation,
|
||||
formattedBody: nil)
|
||||
}
|
||||
case .poll(let question):
|
||||
ThreadView(senderID: senderID,
|
||||
sender: sender,
|
||||
plainBody: question,
|
||||
formattedBody: nil)
|
||||
case .redacted:
|
||||
ThreadView(senderID: senderID,
|
||||
sender: sender,
|
||||
plainBody: L10n.commonMessageRemoved,
|
||||
formattedBody: nil)
|
||||
}
|
||||
default:
|
||||
LoadingThreadView()
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoadingThreadView: View {
|
||||
var body: some View {
|
||||
ThreadView(senderID: "@alice:matrix.org", sender: nil, plainBody: "Hello world", formattedBody: nil)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ThreadView: View {
|
||||
@EnvironmentObject private var context: TimelineViewModel.Context
|
||||
|
||||
let senderID: String
|
||||
let sender: TimelineItemSender?
|
||||
let plainBody: String
|
||||
let formattedBody: AttributedString?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
CompoundIcon(\.threads, size: .xSmall, relativeTo: .compound.bodyXS)
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
|
||||
LoadableAvatarImage(url: sender?.avatarURL,
|
||||
name: sender?.displayName,
|
||||
contentID: sender?.id,
|
||||
avatarSize: .user(on: .threadSummary),
|
||||
mediaProvider: context.mediaProvider)
|
||||
|
||||
Text(sender?.disambiguatedDisplayName ?? senderID)
|
||||
.font(.compound.bodyXSSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.accessibilityLabel(L10n.commonInReplyTo(sender?.disambiguatedDisplayName ?? senderID))
|
||||
|
||||
Text(context.viewState.buildMessagePreview(formattedBody: formattedBody, plainBody: plainBody))
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.tint(.compound.textLinkExternal)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.vertical, 4.0)
|
||||
.padding(.horizontal, 8.0)
|
||||
.background(Color.compound.bgSubtlePrimary)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineThreadSummaryView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
|
||||
static let attributedStringWithMention = {
|
||||
var attributedString = AttributedString("To be replaced")
|
||||
attributedString.userID = "@alice:matrix.org"
|
||||
return attributedString
|
||||
}()
|
||||
|
||||
static let attributedStringWithAtRoomMention = {
|
||||
var attributedString = AttributedString("to be replaced")
|
||||
attributedString.allUsersMention = true
|
||||
return attributedString
|
||||
}()
|
||||
|
||||
static let attributedStringWithRoomAliasMention = {
|
||||
var attributedString = AttributedString("to be replaced")
|
||||
attributedString.roomAlias = "#room:matrix.org"
|
||||
return attributedString
|
||||
}()
|
||||
|
||||
static let attributedStringWithRoomIDMention = {
|
||||
var attributedString = AttributedString("to be replaced")
|
||||
attributedString.roomID = "!room:matrix.org"
|
||||
return attributedString
|
||||
}()
|
||||
|
||||
static let attributedStringWithEventOnRoomIDMention = {
|
||||
var attributedString = AttributedString("to be replaced")
|
||||
attributedString.eventOnRoomID = .init(roomID: "!room:matrix.org", eventID: "$event")
|
||||
return attributedString
|
||||
}()
|
||||
|
||||
static let attributedStringWithEventOnRoomAliasMention = {
|
||||
var attributedString = AttributedString("to be replaced")
|
||||
attributedString.eventOnRoomAlias = .init(alias: "#room:matrix.org", eventID: "$event")
|
||||
return attributedString
|
||||
}()
|
||||
|
||||
static var previewItems: [TimelineThreadSummaryView] {
|
||||
[
|
||||
TimelineThreadSummaryView(threadSummary: .notLoaded),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .loading),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .error(message: "Error")),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.text(.init(body: "This is a threaded message"))))),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.notice(.init(body: "Hello world"))))),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.audio(.init(filename: "audio.m4a",
|
||||
caption: "Some audio",
|
||||
duration: 0,
|
||||
waveform: nil,
|
||||
source: nil,
|
||||
fileSize: nil,
|
||||
contentType: nil))))),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.file(.init(filename: "file.txt",
|
||||
caption: "Some file",
|
||||
source: nil,
|
||||
fileSize: nil,
|
||||
thumbnailSource: nil,
|
||||
contentType: nil))))),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.image(.init(filename: "image.jpg",
|
||||
caption: "Some image",
|
||||
imageInfo: .mockImage,
|
||||
thumbnailInfo: .mockThumbnail))))),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.video(.init(filename: "video.mp4",
|
||||
caption: "Some video",
|
||||
videoInfo: .mockVideo,
|
||||
thumbnailInfo: .mockVideoThumbnail))))),
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.location(.init(body: ""))))),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.voice(.init(filename: "voice-message.ogg",
|
||||
caption: "Some voice message",
|
||||
duration: 0,
|
||||
waveform: nil,
|
||||
source: nil,
|
||||
fileSize: nil,
|
||||
contentType: nil))))),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .poll(question: "Do you like polls?"))),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .redacted)),
|
||||
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithMention))))),
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithAtRoomMention))))),
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithRoomAliasMention))))),
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithRoomIDMention))))),
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithEventOnRoomIDMention))))),
|
||||
TimelineThreadSummaryView(threadSummary: .loaded(senderID: "@alice:matrix.org",
|
||||
sender: .init(id: "@alice:matrix.org", displayName: "Alice"),
|
||||
latestEventContent: .message(.notice(.init(body: "", formattedBody: attributedStringWithEventOnRoomAliasMention)))))
|
||||
]
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
ForEach(0..<previewItems.count, id: \.self) { index in
|
||||
previewItems[index]
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.environmentObject(viewModel.context)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user