mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
Implement link previews for text messages using Apple's LinkPreview framework (#4520)
This commit is contained in:
+3
@@ -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,
|
||||
|
||||
+1
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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)
|
||||
|
||||
+1
@@ -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 { }
|
||||
|
||||
+9
@@ -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")]
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
+60
-9
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user