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
@@ -689,6 +689,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
elementCallService: elementCallService,
timelineControllerFactory: TimelineControllerFactory(),
emojiProvider: EmojiProvider(appSettings: appSettings),
linkMetadataProvider: LinkMetadataProvider(),
appMediator: appMediator,
appSettings: appSettings,
appHooks: appHooks,
@@ -25,6 +25,7 @@ struct CommonFlowParameters {
let elementCallService: ElementCallServiceProtocol
let timelineControllerFactory: TimelineControllerFactoryProtocol
let emojiProvider: EmojiProviderProtocol
let linkMetadataProvider: LinkMetadataProviderProtocol
let appMediator: AppMediatorProtocol
let appSettings: AppSettings
let appHooks: AppHooks
@@ -62,6 +62,7 @@ final class AppSettings {
case spacesEnabled
case developerOptionsEnabled
case nextGenHTMLParserEnabled
case linkPreviewsEnabled
// Doug's tweaks 🔧
case hideUnreadMessagesBadge
@@ -385,6 +386,9 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.nextGenHTMLParserEnabled, defaultValue: isDevelopmentBuild, storageType: .userDefaults(store))
var nextGenHTMLParserEnabled
@UserPreference(key: UserDefaultsKeys.linkPreviewsEnabled, defaultValue: false, storageType: .userDefaults(store))
var linkPreviewsEnabled
@UserPreference(key: UserDefaultsKeys.developerOptionsEnabled, defaultValue: isDevelopmentBuild, storageType: .userDefaults(store))
var developerOptionsEnabled
}
@@ -83,6 +83,7 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
appSettings: flowParameters.appSettings,
analytics: flowParameters.analytics,
emojiProvider: flowParameters.emojiProvider,
linkMetadataProvider: flowParameters.linkMetadataProvider,
userIndicatorController: flowParameters.userIndicatorController,
timelineControllerFactory: flowParameters.timelineControllerFactory)
@@ -69,6 +69,7 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
appSettings: flowParameters.appSettings,
analytics: flowParameters.analytics,
emojiProvider: flowParameters.emojiProvider,
linkMetadataProvider: flowParameters.linkMetadataProvider,
timelineControllerFactory: flowParameters.timelineControllerFactory,
userIndicatorController: flowParameters.userIndicatorController))
@@ -543,6 +543,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProvider(),
emojiProvider: flowParameters.emojiProvider,
linkMetadataProvider: flowParameters.linkMetadataProvider,
completionSuggestionService: completionSuggestionService,
ongoingCallRoomIDPublisher: flowParameters.ongoingCallRoomIDPublisher,
appMediator: flowParameters.appMediator,
@@ -639,6 +640,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProvider(),
emojiProvider: flowParameters.emojiProvider,
linkMetadataProvider: flowParameters.linkMetadataProvider,
completionSuggestionService: completionSuggestionService,
appMediator: flowParameters.appMediator,
appSettings: flowParameters.appSettings,
@@ -0,0 +1,58 @@
//
// 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 LinkPresentation
import SwiftUI
struct LinkPreviewView: UIViewRepresentable {
let url: URL
let metadata: LPLinkMetadata?
func makeUIView(context: Context) -> LPLinkView {
let preview = LPLinkView(url: url)
if let metadata {
preview.metadata = metadata
}
return preview
}
func updateUIView(_ uiView: LPLinkView, context: Context) {
if let metadata {
uiView.metadata = metadata
}
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: LPLinkView, context: Context) -> CGSize? {
let width = proposal.width ?? uiView.intrinsicContentSize.width
let bestFit = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return CGSize(width: width, height: bestFit.height)
}
}
struct LinkPreviewView_Previews: PreviewProvider {
static var previews: some View {
if let url = URL(string: "https://www.lunch.club") {
LinkPreviewView(url: url, metadata: appleMetadata)
.previewLayout(.sizeThatFits)
}
}
private static var appleMetadata: LPLinkMetadata {
let metadata = LPLinkMetadata()
metadata.title = "Lunch club"
if let url = Bundle.main.url(forResource: "preview_avatar_room", withExtension: "jpg") {
metadata.url = url
metadata.imageProvider = NSItemProvider(contentsOf: url)
}
return metadata
}
}
@@ -18,6 +18,7 @@ struct MediaEventsTimelineScreenCoordinatorParameters {
let appSettings: AppSettings
let analytics: AnalyticsService
let emojiProvider: EmojiProviderProtocol
let linkMetadataProvider: LinkMetadataProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
let timelineControllerFactory: TimelineControllerFactoryProtocol
}
@@ -49,6 +50,7 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
appSettings: parameters.appSettings,
analyticsService: parameters.analytics,
emojiProvider: parameters.emojiProvider,
linkMetadataProvider: parameters.linkMetadataProvider,
timelineControllerFactory: parameters.timelineControllerFactory)
let filesTimelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
@@ -60,6 +62,7 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
appSettings: parameters.appSettings,
analyticsService: parameters.analytics,
emojiProvider: parameters.emojiProvider,
linkMetadataProvider: parameters.linkMetadataProvider,
timelineControllerFactory: parameters.timelineControllerFactory)
viewModel = MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: mediaTimelineViewModel,
@@ -306,6 +306,7 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
}
}
@@ -17,6 +17,7 @@ struct PinnedEventsTimelineScreenCoordinatorParameters {
let appSettings: AppSettings
let analytics: AnalyticsService
let emojiProvider: EmojiProviderProtocol
let linkMetadataProvider: LinkMetadataProviderProtocol
let timelineControllerFactory: TimelineControllerFactoryProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
@@ -54,6 +55,7 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
appSettings: parameters.appSettings,
analyticsService: parameters.analytics,
emojiProvider: parameters.emojiProvider,
linkMetadataProvider: parameters.linkMetadataProvider,
timelineControllerFactory: parameters.timelineControllerFactory)
}
@@ -78,6 +78,7 @@ struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
}()
@@ -19,6 +19,7 @@ struct RoomScreenCoordinatorParameters {
let timelineController: TimelineControllerProtocol
let mediaPlayerProvider: MediaPlayerProviderProtocol
let emojiProvider: EmojiProviderProtocol
let linkMetadataProvider: LinkMetadataProviderProtocol
let completionSuggestionService: CompletionSuggestionServiceProtocol
let ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>
let appMediator: AppMediatorProtocol
@@ -86,6 +87,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
appSettings: parameters.appSettings,
analyticsService: parameters.analytics,
emojiProvider: parameters.emojiProvider,
linkMetadataProvider: parameters.linkMetadataProvider,
timelineControllerFactory: parameters.timelineControllerFactory)
let wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
@@ -264,6 +264,7 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
return .init(room: roomViewModel, timeline: timelineViewModel)
@@ -54,6 +54,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
var knockingEnabled: Bool { get set }
var nextGenHTMLParserEnabled: Bool { get set }
var linkPreviewsEnabled: Bool { get set }
}
extension AppSettings: DeveloperOptionsProtocol { }
@@ -63,6 +63,15 @@ struct DeveloperOptionsScreen: View {
Text("Low priority filter")
}
}
Section("Timeline") {
Toggle(isOn: $context.linkPreviewsEnabled) {
Text("Link previews")
Text("Follows the timeline media visibility settings.")
Text("Can leak the device IP address when loading link metadata.")
.foregroundStyle(.compound.textCriticalPrimary)
}
}
Section("Join rules") {
Toggle(isOn: $context.knockingEnabled) {
@@ -16,6 +16,7 @@ struct ThreadTimelineScreenCoordinatorParameters {
let timelineController: TimelineControllerProtocol
let mediaPlayerProvider: MediaPlayerProviderProtocol
let emojiProvider: EmojiProviderProtocol
let linkMetadataProvider: LinkMetadataProviderProtocol
let completionSuggestionService: CompletionSuggestionServiceProtocol
let appMediator: AppMediatorProtocol
let appSettings: AppSettings
@@ -65,6 +66,7 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
appSettings: parameters.appSettings,
analyticsService: parameters.analytics,
emojiProvider: parameters.emojiProvider,
linkMetadataProvider: parameters.linkMetadataProvider,
timelineControllerFactory: parameters.timelineControllerFactory)
let wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
@@ -41,6 +41,7 @@ class TimelineInteractionHandler {
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let emojiProvider: EmojiProviderProtocol
private let linkMetadataProvider: LinkMetadataProviderProtocol
private let timelineControllerFactory: TimelineControllerFactoryProtocol
private let pollInteractionHandler: PollInteractionHandlerProtocol
@@ -67,6 +68,7 @@ class TimelineInteractionHandler {
appSettings: AppSettings,
analyticsService: AnalyticsService,
emojiProvider: EmojiProviderProtocol,
linkMetadataProvider: LinkMetadataProviderProtocol,
timelineControllerFactory: TimelineControllerFactoryProtocol) {
self.roomProxy = roomProxy
self.timelineController = timelineController
@@ -78,6 +80,7 @@ class TimelineInteractionHandler {
self.appSettings = appSettings
self.analyticsService = analyticsService
self.emojiProvider = emojiProvider
self.linkMetadataProvider = linkMetadataProvider
self.timelineControllerFactory = timelineControllerFactory
pollInteractionHandler = PollInteractionHandler(analyticsService: analyticsService,
@@ -590,6 +593,7 @@ class TimelineInteractionHandler {
appSettings: appSettings,
analyticsService: analyticsService,
emojiProvider: emojiProvider,
linkMetadataProvider: linkMetadataProvider,
timelineControllerFactory: timelineControllerFactory)
return .displayMediaPreview(item: item, timelineViewModel: .new(timelineViewModel))
@@ -107,9 +107,12 @@ struct TimelineViewState: BindableState {
var canCurrentUserPin = false
var canCurrentUserKick = false
var canCurrentUserBan = false
var hideTimelineMedia: Bool
var isViewSourceEnabled: Bool
var areThreadsEnabled: Bool
var hideTimelineMedia: Bool
var linkPreviewsEnabled: Bool
let hasPredecessor: Bool
@@ -131,6 +134,8 @@ struct TimelineViewState: BindableState {
var emojiProvider: EmojiProviderProtocol
var linkMetadataProvider: LinkMetadataProviderProtocol?
var mapTilerConfiguration: MapTilerConfiguration
var bindings: TimelineViewStateBindings
@@ -56,6 +56,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
appSettings: AppSettings,
analyticsService: AnalyticsService,
emojiProvider: EmojiProviderProtocol,
linkMetadataProvider: LinkMetadataProviderProtocol,
timelineControllerFactory: TimelineControllerFactoryProtocol) {
self.roomProxy = roomProxy
self.timelineController = timelineController
@@ -80,6 +81,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
appSettings: appSettings,
analyticsService: analyticsService,
emojiProvider: emojiProvider,
linkMetadataProvider: linkMetadataProvider,
timelineControllerFactory: timelineControllerFactory)
let hideTimelineMedia = switch userSession.clientProxy.timelineMediaVisibilityPublisher.value {
@@ -95,12 +97,14 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
isDirectOneToOneRoom: roomProxy.isDirectOneToOneRoom,
timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
ownUserID: roomProxy.ownUserID,
hideTimelineMedia: hideTimelineMedia,
isViewSourceEnabled: appSettings.viewSourceEnabled,
areThreadsEnabled: appSettings.threadsEnabled,
hideTimelineMedia: hideTimelineMedia,
linkPreviewsEnabled: appSettings.linkPreviewsEnabled,
hasPredecessor: roomProxy.predecessorRoom != nil,
pinnedEventIDs: roomProxy.infoPublisher.value.pinnedEventIDs,
emojiProvider: emojiProvider,
linkMetadataProvider: hideTimelineMedia ? nil : linkMetadataProvider,
mapTilerConfiguration: appSettings.mapTilerConfiguration,
bindings: .init(reactionsCollapsed: [:])),
mediaProvider: userSession.mediaProvider)
@@ -1007,6 +1011,7 @@ extension TimelineViewModel {
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
}
}
@@ -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 {
@@ -0,0 +1,27 @@
//
// Copyright 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 LinkPresentation
class LinkMetadataProvider: LinkMetadataProviderProtocol {
private(set) var metadataItems = [URL: LinkMetadataProviderItem]()
func fetchMetadataFor(url: URL) async -> Result<LinkMetadataProviderItem, Error> {
if let item = metadataItems[url] {
return .success(item)
}
do {
let metadata = try await LPMetadataProvider().startFetchingMetadata(for: url)
let item = LinkMetadataProviderItem(url: url, metadata: metadata)
metadataItems[url] = item
return .success(item)
} catch {
return .failure(error)
}
}
}
@@ -0,0 +1,20 @@
//
// Copyright 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 LinkPresentation
struct LinkMetadataProviderItem {
let url: URL
let metadata: LPLinkMetadata?
}
protocol LinkMetadataProviderProtocol {
var metadataItems: [URL: LinkMetadataProviderItem] { get }
func fetchMetadataFor(url: URL) async -> Result<LinkMetadataProviderItem, Error>
}
@@ -5,6 +5,7 @@
// Please see LICENSE files in the repository root for full details.
//
import Algorithms
import UIKit
struct TextRoomTimelineItem: TextBasedRoomTimelineItem, Equatable {
@@ -28,4 +29,28 @@ struct TextRoomTimelineItem: TextBasedRoomTimelineItem, Equatable {
var contentType: EventBasedMessageTimelineItemContentType {
.text(content)
}
var links: [URL] {
guard let attributedString = content.formattedBody else {
return []
}
let links = attributedString.runs.compactMap { (run: AttributedString.Runs.Run) -> URL? in
if run.link == nil {
return nil
}
guard run.elementX.eventOnRoomAlias == nil,
run.elementX.eventOnRoomID == nil,
run.elementX.roomAlias == nil,
run.elementX.roomID == nil,
run.elementX.userID == nil else {
return nil
}
return run.link
}
return Array(links.uniqued())
}
}
@@ -6,6 +6,8 @@
//
import SwiftUI
import OrderedCollections
struct RoomTimelineItemView: View {
@Environment(\.timelineContext) var context
@ObservedObject var viewState: RoomTimelineItemViewState
@@ -26,7 +28,7 @@ struct RoomTimelineItemView: View {
@ViewBuilder private var timelineView: some View {
switch viewState.type {
case .text(let item):
TextRoomTimelineView(timelineItem: item)
TextRoomTimelineView(timelineItem: item, linkMetadata: linkMetadataForItem(item))
case .separator(let item):
SeparatorRoomTimelineView(timelineItem: item)
case .image(let item):
@@ -64,13 +66,26 @@ struct RoomTimelineItemView: View {
case .poll(let item):
PollRoomTimelineView(timelineItem: item)
case .voice(let item):
VoiceMessageRoomTimelineView(timelineItem: item, playerState: context?.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(id: .timelineItemIdentifier(item.id),
title: L10n.commonVoiceMessage,
duration: 0))
let playerState = context?.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(id: .timelineItemIdentifier(item.id),
title: L10n.commonVoiceMessage,
duration: 0)
VoiceMessageRoomTimelineView(timelineItem: item, playerState: playerState)
case .callInvite(let item):
CallInviteRoomTimelineView(timelineItem: item)
case .callNotification(let item):
CallNotificationRoomTimelineView(timelineItem: item)
}
}
private func linkMetadataForItem(_ item: TextRoomTimelineItem) -> OrderedDictionary<URL, LinkMetadataProviderItem> {
var linkMetadata = OrderedDictionary<URL, LinkMetadataProviderItem>()
for url in item.links.prefix(TextRoomTimelineView.maxLinkPreviewsToRender) {
if let item = context?.viewState.linkMetadataProvider?.metadataItems[url] {
linkMetadata[url] = item
} else {
linkMetadata[url] = LinkMetadataProviderItem(url: url, metadata: nil)
}
}
return linkMetadata
}
}
@@ -265,6 +265,7 @@ class MockScreen: Identifiable {
timelineController: MockTimelineController(),
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -286,6 +287,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -307,6 +309,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -327,6 +330,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -350,6 +354,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -373,6 +378,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -395,6 +401,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -419,6 +426,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -442,6 +450,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -464,6 +473,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -500,6 +510,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -523,6 +534,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -546,6 +558,7 @@ class MockScreen: Identifiable {
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
linkMetadataProvider: LinkMetadataProvider(),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -594,6 +607,7 @@ class MockScreen: Identifiable {
elementCallService: ElementCallServiceMock(.init()),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
emojiProvider: EmojiProvider(appSettings: appSettings),
linkMetadataProvider: LinkMetadataProvider(),
appMediator: appMediator,
appSettings: appSettings,
appHooks: AppHooks(),
@@ -762,6 +776,7 @@ class MockScreen: Identifiable {
elementCallService: ElementCallServiceMock(.init()),
timelineControllerFactory: TimelineControllerFactoryMock(.init(timelineController: timelineController)),
emojiProvider: EmojiProvider(appSettings: appSettings),
linkMetadataProvider: LinkMetadataProvider(),
appMediator: AppMediatorMock.default,
appSettings: appSettings,
appHooks: AppHooks(),