Implement link previews for text messages using Apple's LinkPreview framework (#4520)

This commit is contained in:
Stefan Ceriu
2025-09-19 15:48:09 +03:00
committed by GitHub
parent 94db3adf3a
commit bfaca7cff9
48 changed files with 347 additions and 31 deletions
@@ -52,6 +52,7 @@ struct ReadReceiptsSummaryView_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
return mock
}()
@@ -341,6 +341,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
}()
@@ -87,6 +87,7 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
static let singleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now")]
@@ -97,6 +97,7 @@ struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
static var previews: some View {
@@ -8,19 +8,62 @@
import Foundation
import SwiftUI
import OrderedCollections
struct TextRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
static let maxLinkPreviewsToRender = 2
@Environment(\.timelineContext) private var context
let timelineItem: TextRoomTimelineItem
@State private var linkMetadata: OrderedDictionary<URL, LinkMetadataProviderItem>
init(timelineItem: TextRoomTimelineItem, linkMetadata: OrderedDictionary<URL, LinkMetadataProviderItem> = [:]) {
self.timelineItem = timelineItem
self.linkMetadata = linkMetadata
}
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let attributedString = timelineItem.content.formattedBody {
FormattedBodyText(attributedString: attributedString,
additionalWhitespacesCount: timelineItem.additionalWhitespaces(),
boostFontSize: timelineItem.shouldBoost)
} else {
FormattedBodyText(text: timelineItem.body,
additionalWhitespacesCount: timelineItem.additionalWhitespaces(),
boostFontSize: timelineItem.shouldBoost)
VStack(alignment: .leading, spacing: 8) {
if let attributedString = timelineItem.content.formattedBody {
FormattedBodyText(attributedString: attributedString,
additionalWhitespacesCount: timelineItem.additionalWhitespaces(),
boostFontSize: timelineItem.shouldBoost)
} else {
FormattedBodyText(text: timelineItem.body,
additionalWhitespacesCount: timelineItem.additionalWhitespaces(),
boostFontSize: timelineItem.shouldBoost)
}
if context?.viewState.linkPreviewsEnabled ?? false {
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
}
await withTaskGroup { taskGroup in
for url in timelineItem.links.prefix(Self.maxLinkPreviewsToRender) {
taskGroup.addTask {
if case let .success(metadata) = await context?.viewState.linkMetadataProvider?.fetchMetadataFor(url: url) {
await MainActor.run {
linkMetadata[url] = metadata
}
}
}
}
}
}
@@ -32,10 +75,12 @@ struct TextRoomTimelineView_Previews: PreviewProvider, TestablePreview {
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 {
@@ -46,7 +91,7 @@ struct TextRoomTimelineView_Previews: PreviewProvider, TestablePreview {
isOutgoing: false,
senderId: "Bob"))
TextRoomTimelineView(timelineItem: itemWith(text: "Some other text",
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"))
@@ -75,6 +120,12 @@ struct TextRoomTimelineView_Previews: PreviewProvider, TestablePreview {
timestamp: .mock,
isOutgoing: true,
senderId: "Anne"))
// HTML with links for testing
TextRoomTimelineView(timelineItem: itemWith(html: "Check out <a href=\"https://www.apple.com\">Apple's website</a> and <a href=\"https://github.com\">GitHub</a>!",
timestamp: .mock,
isOutgoing: false,
senderId: "Bob"))
}
}
}
@@ -144,6 +144,7 @@ struct TimelineView_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
static var previews: some View {