// // 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 Foundation import OrderedCollections import SwiftUI struct TextRoomTimelineView: View, TextBasedRoomTimelineViewProtocol { static let maxLinkPreviewsToRender = 2 @Environment(\.timelineContext) private var context let timelineItem: TextRoomTimelineItem @State private var linkMetadata: OrderedDictionary init(timelineItem: TextRoomTimelineItem, linkMetadata: OrderedDictionary = [:]) { self.timelineItem = timelineItem self.linkMetadata = linkMetadata } var body: some View { TimelineStyler(timelineItem: timelineItem) { VStack(alignment: .leading, spacing: 8) { if let attributedString = timelineItem.content.formattedBody { FormattedBodyText(attributedString: attributedString, trailingReservedSize: timelineItem.trailingReservedSize, boostFontSize: timelineItem.shouldBoost) } else { FormattedBodyText(text: timelineItem.body, trailingReservedSize: timelineItem.trailingReservedSize, boostFontSize: timelineItem.shouldBoost) } if context?.viewState.linkPreviewsEnabled ?? false, !linkMetadata.keys.isEmpty { VStack(spacing: 8) { ForEach(linkMetadata.keys, id: \.absoluteString) { url in let metadata = linkMetadata[url]?.metadata ?? context?.viewState.linkMetadataProvider?.metadataItems[url]?.metadata LinkPreviewView(url: url, metadata: metadata) } } .padding(.bottom, 16) } } } .task { await fetchLinkPreviews() } } private func fetchLinkPreviews() async { guard context?.viewState.linkPreviewsEnabled ?? false else { return } // Fetch the previews concurrently. The work stays on the main actor (the provider and // its LPLinkMetadata are main actor bound) but each network fetch suspends, so they still // overlap rather than running one after another. await withTaskGroup { taskGroup in for url in timelineItem.links.prefix(Self.maxLinkPreviewsToRender) { taskGroup.addTask { await fetchLinkPreview(for: url) } } } } @MainActor private func fetchLinkPreview(for url: URL) async { if case let .success(metadata) = await context?.viewState.linkMetadataProvider?.fetchMetadataFor(url: url) { linkMetadata[url] = metadata } } } struct TextRoomTimelineView_Previews: PreviewProvider, TestablePreview { static let viewModel = TimelineViewModel.mock static var previews: some View { body.environmentObject(viewModel.context) .previewDisplayName("Bubble") .previewLayout(.sizeThatFits) body .environmentObject(viewModel.context) .environment(\.layoutDirection, .rightToLeft) .previewDisplayName("Bubble RTL") .previewLayout(.sizeThatFits) } static var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20.0) { TextRoomTimelineView(timelineItem: itemWith(text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.", timestamp: .mock, isOutgoing: false, senderId: "Bob")) TextRoomTimelineView(timelineItem: itemWith(text: "Check out this cool website: https://www.apple.com and also https://github.com for some great projects!", timestamp: .mock, isOutgoing: true, senderId: "Anne")) TextRoomTimelineView(timelineItem: itemWith(text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.", timestamp: .mock, isOutgoing: false, senderId: "Bob")) TextRoomTimelineView(timelineItem: itemWith(text: "Some other text", timestamp: .mock, isOutgoing: true, senderId: "Anne")) TextRoomTimelineView(timelineItem: itemWith(text: "טקסט אחר", timestamp: .mock, isOutgoing: true, senderId: "Anne")) TextRoomTimelineView(timelineItem: itemWith(html: "
  1. First item
  2. Second item
  3. Third item
", timestamp: .mock, isOutgoing: true, senderId: "Anne")) TextRoomTimelineView(timelineItem: itemWith(html: "
  1. פריט ראשון
  2. הפריט השני
  3. פריט שלישי
", timestamp: .mock, isOutgoing: true, senderId: "Anne")) // HTML with links for testing TextRoomTimelineView(timelineItem: itemWith(html: "Check out Apple's website and GitHub!", timestamp: .mock, isOutgoing: false, senderId: "Bob")) } } } private static func itemWith(text: String, timestamp: Date, isOutgoing: Bool, senderId: String) -> TextRoomTimelineItem { TextRoomTimelineItem(id: .randomEvent, timestamp: timestamp, isOutgoing: isOutgoing, isEditable: isOutgoing, canBeRepliedTo: true, sender: .init(id: senderId), content: .init(body: text)) } private static func itemWith(html: String, timestamp: Date, isOutgoing: Bool, senderId: String) -> TextRoomTimelineItem { let builder = AttributedStringBuilder(cacheKey: "preview", mentionBuilder: MentionBuilder()) let attributedString = builder.fromHTML(html) return TextRoomTimelineItem(id: .randomEvent, timestamp: timestamp, isOutgoing: isOutgoing, isEditable: isOutgoing, canBeRepliedTo: true, sender: .init(id: senderId), content: .init(body: "", formattedBody: attributedString)) } }