Merge pull request #4790 from vector-im/doug/888_add_url_previews

Add URL previews as a Labs feature
This commit is contained in:
Doug 2021-09-08 17:19:41 +01:00 committed by GitHub
commit 0e64b60f02
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1673 additions and 10 deletions

View file

@ -49,6 +49,9 @@ class CommonConfiguration: NSObject, Configurable {
settings.messageDetailsAllowCopyingMedia = BuildSettings.messageDetailsAllowCopyMedia
settings.messageDetailsAllowPastingMedia = BuildSettings.messageDetailsAllowPasteMedia
// Enable link detection if url preview are enabled
settings.enableBubbleComponentLinkDetection = true
MXKContactManager.shared().allowLocalContactsAccess = BuildSettings.allowLocalContactsAccess
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "url_preview_close.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View file

@ -0,0 +1,116 @@
%PDF-1.7
1 0 obj
<< /ExtGState << /E1 << /ca 0.800000 >> >> >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
/E1 gs
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.890196 0.909804 0.941176 scn
24.000000 12.000000 m
24.000000 5.372583 18.627417 0.000000 12.000000 0.000000 c
5.372583 0.000000 0.000000 5.372583 0.000000 12.000000 c
0.000000 18.627417 5.372583 24.000000 12.000000 24.000000 c
18.627417 24.000000 24.000000 18.627417 24.000000 12.000000 c
h
f
n
Q
q
1.000000 0.000000 -0.000000 1.000000 7.999725 5.805014 cm
0.450980 0.490196 0.549020 scn
0.707107 10.902368 m
0.316583 11.292892 -0.316582 11.292892 -0.707107 10.902368 c
-1.097631 10.511844 -1.097631 9.878678 -0.707107 9.488154 c
0.707107 10.902368 l
h
7.292891 1.488155 m
7.683415 1.097631 8.316580 1.097631 8.707105 1.488155 c
9.097629 1.878679 9.097629 2.511844 8.707105 2.902369 c
7.292891 1.488155 l
h
-0.707107 9.488154 m
7.292891 1.488155 l
8.707105 2.902369 l
0.707107 10.902368 l
-0.707107 9.488154 l
h
f
n
Q
q
-1.000000 -0.000000 -0.000000 1.000000 16.000488 5.805014 cm
0.450980 0.490196 0.549020 scn
0.707107 10.902368 m
0.316582 11.292892 -0.316583 11.292892 -0.707107 10.902368 c
-1.097631 10.511844 -1.097631 9.878678 -0.707107 9.488154 c
0.707107 10.902368 l
h
7.292893 1.488155 m
7.683417 1.097631 8.316583 1.097631 8.707107 1.488155 c
9.097631 1.878679 9.097631 2.511845 8.707107 2.902369 c
7.292893 1.488155 l
h
-0.707107 9.488154 m
7.292893 1.488155 l
8.707107 2.902369 l
0.707107 10.902368 l
-0.707107 9.488154 l
h
f
n
Q
endstream
endobj
3 0 obj
1439
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 24.000000 24.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000074 00000 n
0000001569 00000 n
0000001592 00000 n
0000001765 00000 n
0000001839 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
1898
%%EOF

View file

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "url_preview_close_dark.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View file

@ -0,0 +1,116 @@
%PDF-1.7
1 0 obj
<< /ExtGState << /E1 << /ca 0.800000 >> >> >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
/E1 gs
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.223529 0.250980 0.286275 scn
24.000000 12.000000 m
24.000000 5.372583 18.627417 0.000000 12.000000 0.000000 c
5.372583 0.000000 0.000000 5.372583 0.000000 12.000000 c
0.000000 18.627417 5.372583 24.000000 12.000000 24.000000 c
18.627417 24.000000 24.000000 18.627417 24.000000 12.000000 c
h
f
n
Q
q
1.000000 0.000000 -0.000000 1.000000 7.999756 5.805014 cm
0.662745 0.698039 0.737255 scn
0.707107 10.902368 m
0.316583 11.292892 -0.316582 11.292892 -0.707107 10.902368 c
-1.097631 10.511844 -1.097631 9.878678 -0.707107 9.488154 c
0.707107 10.902368 l
h
7.292891 1.488155 m
7.683415 1.097631 8.316580 1.097631 8.707105 1.488155 c
9.097629 1.878679 9.097629 2.511844 8.707105 2.902369 c
7.292891 1.488155 l
h
-0.707107 9.488154 m
7.292891 1.488155 l
8.707105 2.902369 l
0.707107 10.902368 l
-0.707107 9.488154 l
h
f
n
Q
q
-1.000000 -0.000000 -0.000000 1.000000 16.000488 5.805014 cm
0.662745 0.698039 0.737255 scn
0.707107 10.902368 m
0.316582 11.292892 -0.316583 11.292892 -0.707107 10.902368 c
-1.097631 10.511844 -1.097631 9.878678 -0.707107 9.488154 c
0.707107 10.902368 l
h
7.292893 1.488155 m
7.683417 1.097631 8.316583 1.097631 8.707107 1.488155 c
9.097631 1.878679 9.097631 2.511845 8.707107 2.902369 c
7.292893 1.488155 l
h
-0.707107 9.488154 m
7.292893 1.488155 l
8.707107 2.902369 l
0.707107 10.902368 l
-0.707107 9.488154 l
h
f
n
Q
endstream
endobj
3 0 obj
1439
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 24.000000 24.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000074 00000 n
0000001569 00000 n
0000001592 00000 n
0000001765 00000 n
0000001839 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
1898
%%EOF

View file

@ -541,6 +541,9 @@ Tap the + to start adding people.";
"settings_ui_theme_picker_message_invert_colours" = "\"Auto\" uses your device's \"Invert Colours\" settings";
"settings_ui_theme_picker_message_match_system_theme" = "\"Auto\" matches your device's system theme";
"settings_show_url_previews" = "Show inline URL previews";
"settings_show_url_previews_description" = "Previews will only be shown in unencrypted rooms.";
"settings_unignore_user" = "Show all messages from %@?";
"settings_contacts_discover_matrix_users" = "Use emails and phone numbers to discover users";

View file

@ -139,6 +139,8 @@ internal enum Asset {
internal static let videoCall = ImageAsset(name: "video_call")
internal static let voiceCallHangonIcon = ImageAsset(name: "voice_call_hangon_icon")
internal static let voiceCallHangupIcon = ImageAsset(name: "voice_call_hangup_icon")
internal static let urlPreviewClose = ImageAsset(name: "url_preview_close")
internal static let urlPreviewCloseDark = ImageAsset(name: "url_preview_close_dark")
internal static let voiceMessageCancelGradient = ImageAsset(name: "voice_message_cancel_gradient")
internal static let voiceMessageLockChevron = ImageAsset(name: "voice_message_lock_chevron")
internal static let voiceMessageLockIconLocked = ImageAsset(name: "voice_message_lock_icon_locked")

View file

@ -4566,6 +4566,14 @@ internal enum VectorL10n {
internal static var settingsShowNSFWPublicRooms: String {
return VectorL10n.tr("Vector", "settings_show_NSFW_public_rooms")
}
/// Show inline URL previews
internal static var settingsShowUrlPreviews: String {
return VectorL10n.tr("Vector", "settings_show_url_previews")
}
/// Previews will only be shown in unencrypted rooms.
internal static var settingsShowUrlPreviewsDescription: String {
return VectorL10n.tr("Vector", "settings_show_url_previews_description")
}
/// Sign Out
internal static var settingsSignOut: String {
return VectorL10n.tr("Vector", "settings_sign_out")

View file

@ -160,6 +160,10 @@ final class RiotSettings: NSObject {
@UserDefault(key: "roomScreenAllowFilesAction", defaultValue: BuildSettings.roomScreenAllowFilesAction, storage: defaults)
var roomScreenAllowFilesAction
// labs prefix added to the key can be dropped when default value becomes true
@UserDefault(key: "labsRoomScreenShowsURLPreviews", defaultValue: false, storage: defaults)
var roomScreenShowsURLPreviews
// MARK: - Room Contextual Menu
@UserDefault(key: "roomContextualMenuShowMoreOptionForMessages", defaultValue: BuildSettings.roomContextualMenuShowMoreOptionForMessages, storage: defaults)

View file

@ -0,0 +1,48 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import CoreData
extension URLPreviewDataMO {
convenience init(context: NSManagedObjectContext, preview: URLPreviewData, creationDate: Date) {
self.init(context: context)
update(from: preview, on: creationDate)
}
func update(from preview: URLPreviewData, on date: Date) {
url = preview.url
siteName = preview.siteName
title = preview.title
text = preview.text
image = preview.image
creationDate = date
}
func preview(for event: MXEvent) -> URLPreviewData? {
guard let url = url else { return nil }
let viewData = URLPreviewData(url: url,
eventID: event.eventId,
roomID: event.roomId,
siteName: siteName,
title: title,
text: text)
viewData.image = image as? UIImage
return viewData
}
}

View file

@ -0,0 +1,45 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import CoreData
/// A `ValueTransformer` for ``URLPreviewCacheData``'s `image` field.
/// This class transforms between `UIImage` and it's `pngData()` representation.
class URLPreviewImageTransformer: ValueTransformer {
override class func transformedValueClass() -> AnyClass {
UIImage.self
}
override class func allowsReverseTransformation() -> Bool {
true
}
/// Transforms a `UIImage` into it's `pngData()` representation.
override func transformedValue(_ value: Any?) -> Any? {
guard let image = value as? UIImage else { return nil }
return image.pngData()
}
/// Transforms `Data` into a `UIImage`
override func reverseTransformedValue(_ value: Any?) -> Any? {
guard let data = value as? Data else { return nil }
return UIImage(data: data)
}
}
extension NSValueTransformerName {
static let urlPreviewImageTransformer = NSValueTransformerName("URLPreviewImageTransformer")
}

View file

@ -0,0 +1,165 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import CoreData
/// A cache for URL previews backed by Core Data.
class URLPreviewStore {
// MARK: - Properties
/// The Core Data container for persisting the cache to disk.
private let container: NSPersistentContainer
/// The Core Data context used to store and load data on.
private var context: NSManagedObjectContext {
container.viewContext
}
/// A time interval that represents how long an item in the cache is valid for.
private let dataValidityTime: TimeInterval = 60 * 60 * 24
/// The oldest `creationDate` allowed for valid data.
private var expiryDate: Date {
Date().addingTimeInterval(-dataValidityTime)
}
// MARK: - Lifecycle
/// Create a URLPreview Cache optionally storing the data in memory.
/// - Parameter inMemory: Whether to store the data in memory.
init(inMemory: Bool = false) {
// Register the transformer for the `image` field.
ValueTransformer.setValueTransformer(URLPreviewImageTransformer(), forName: .urlPreviewImageTransformer)
// Create the container, updating it's path if storing the data in memory.
container = NSPersistentContainer(name: "URLPreviewStore")
if inMemory {
if let storeDescription = container.persistentStoreDescriptions.first {
storeDescription.url = URL(fileURLWithPath: "/dev/null")
} else {
MXLog.error("[URLPreviewStore] persistentStoreDescription not found.")
}
}
// Load the persistent stores into the container
container.loadPersistentStores { storeDescription, error in
if let error = error {
MXLog.error("[URLPreviewStore] Core Data container error: \(error.localizedDescription)")
}
}
}
// MARK: - Public
/// Cache a preview in the store. If a preview already exists with the same URL it will be updated from the new preview.
/// - Parameter preview: The preview to add to the store.
/// - Parameter date: Optional: The date the preview was generated. When nil, the current date is assigned.
func cache(_ preview: URLPreviewData, generatedOn generationDate: Date? = nil) {
// Create a fetch request for an existing preview.
let request: NSFetchRequest<URLPreviewDataMO> = URLPreviewDataMO.fetchRequest()
request.predicate = NSPredicate(format: "url == %@", preview.url as NSURL)
// Use the custom date if supplied (currently this is for testing purposes)
let date = generationDate ?? Date()
// Update existing data if found otherwise create new data.
if let cachedPreview = try? context.fetch(request).first {
cachedPreview.update(from: preview, on: date)
} else {
_ = URLPreviewDataMO(context: context, preview: preview, creationDate: date)
}
save()
}
/// Fetches the preview from the cache for the supplied URL. If a preview doesn't exist or
/// if the preview is older than the ``dataValidityTime`` the returned value will be nil.
/// - Parameter url: The URL to fetch the preview for.
/// - Returns: The preview if found, otherwise nil.
func preview(for url: URL, and event: MXEvent) -> URLPreviewData? {
// Create a request for the url excluding any expired items
let request: NSFetchRequest<URLPreviewDataMO> = URLPreviewDataMO.fetchRequest()
request.predicate = NSCompoundPredicate(type: .and, subpredicates: [
NSPredicate(format: "url == %@", url as NSURL),
NSPredicate(format: "creationDate > %@", expiryDate as NSDate)
])
// Fetch the request, returning nil if nothing was found
guard
let cachedPreview = try? context.fetch(request).first
else { return nil }
// Convert and return
return cachedPreview.preview(for: event)
}
/// Returns the number of URL previews cached in the store.
func cacheCount() -> Int {
let request: NSFetchRequest<NSFetchRequestResult> = URLPreviewDataMO.fetchRequest()
return (try? context.count(for: request)) ?? 0
}
/// Removes any expired cache data from the store.
func removeExpiredItems() {
let request: NSFetchRequest<NSFetchRequestResult> = URLPreviewDataMO.fetchRequest()
request.predicate = NSPredicate(format: "creationDate < %@", expiryDate as NSDate)
do {
try context.execute(NSBatchDeleteRequest(fetchRequest: request))
} catch {
MXLog.error("[URLPreviewStore] Error executing batch delete request: \(error.localizedDescription)")
}
}
/// Deletes all cache data and all closed previews from the store.
func deleteAll() {
do {
_ = try context.execute(NSBatchDeleteRequest(fetchRequest: URLPreviewDataMO.fetchRequest()))
_ = try context.execute(NSBatchDeleteRequest(fetchRequest: URLPreviewUserDataMO.fetchRequest()))
} catch {
MXLog.error("[URLPreviewStore] Error executing batch delete request: \(error.localizedDescription)")
}
}
/// Store the dismissal of a preview from the event with `eventId` and `roomId`.
func closePreview(for eventId: String, in roomId: String) {
_ = URLPreviewUserDataMO(context: context, eventID: eventId, roomID: roomId, dismissed: true)
save()
}
/// Whether a preview for an event with the given `eventId` and `roomId` has been closed or not.
func hasClosedPreview(for eventId: String, in roomId: String) -> Bool {
// Create a request for the url excluding any expired items
let request: NSFetchRequest<URLPreviewUserDataMO> = URLPreviewUserDataMO.fetchRequest()
request.predicate = NSCompoundPredicate(type: .and, subpredicates: [
NSPredicate(format: "eventID == %@", eventId),
NSPredicate(format: "roomID == %@", roomId),
NSPredicate(format: "dismissed == true")
])
return (try? context.count(for: request)) ?? 0 > 0
}
// MARK: - Private
/// Saves any changes that are found on the context
private func save() {
guard context.hasChanges else { return }
try? context.save()
}
}

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="18154" systemVersion="20G95" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="URLPreviewData" representedClassName="URLPreviewDataMO" syncable="YES" codeGenerationType="class">
<attribute name="creationDate" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="image" optional="YES" attributeType="Transformable" valueTransformerName="URLPreviewImageTransformer"/>
<attribute name="siteName" optional="YES" attributeType="String"/>
<attribute name="text" optional="YES" attributeType="String"/>
<attribute name="title" optional="YES" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
</entity>
<entity name="URLPreviewUserData" representedClassName="URLPreviewUserDataMO" syncable="YES" codeGenerationType="class">
<attribute name="dismissed" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="eventID" attributeType="String"/>
<attribute name="roomID" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="eventID"/>
<constraint value="roomID"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<elements>
<element name="URLPreviewUserData" positionX="0" positionY="45" width="128" height="74"/>
<element name="URLPreviewData" positionX="160" positionY="192" width="128" height="119"/>
</elements>
</model>

View file

@ -0,0 +1,26 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import CoreData
extension URLPreviewUserDataMO {
convenience init(context: NSManagedObjectContext, eventID: String, roomID: String, dismissed: Bool) {
self.init(context: context)
self.eventID = eventID
self.roomID = roomID
self.dismissed = dismissed
}
}

View file

@ -0,0 +1,52 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
@objcMembers
class URLPreviewData: NSObject {
/// The URL that's represented by the preview data. This may have been sanitized.
/// Note: The original URL, can be found in the bubble components with `eventID` and `roomID`.
let url: URL
/// The ID of the event that created this preview.
let eventID: String
/// The ID of the room that this preview is from.
let roomID: String
/// The OpenGraph site name for the URL.
let siteName: String?
/// The OpenGraph title for the URL.
let title: String?
/// The OpenGraph description for the URL.
let text: String?
/// The OpenGraph image for the URL.
var image: UIImage?
init(url: URL, eventID: String, roomID: String, siteName: String?, title: String?, text: String?) {
self.url = url
self.eventID = eventID
self.roomID = roomID
self.siteName = siteName
self.title = title
// Remove line breaks from the description text
self.text = text?.components(separatedBy: .newlines).joined(separator: " ")
}
}

View file

@ -0,0 +1,159 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum URLPreviewServiceError: Error {
case missingResponse
}
@objcMembers
/// A service for URL preview data that handles fetching, caching and clean-up
/// as well as remembering which previews have been closed by the user.
class URLPreviewService: NSObject {
// MARK: - Properties
/// The shared service object.
static let shared = URLPreviewService()
/// A persistent store backed by Core Data to reduce network requests
private let store = URLPreviewStore()
// MARK: - Public
/// Generates preview data for a URL to be previewed as part of the supplied event,
/// first checking the cache, and if necessary making a request to the homeserver.
/// You should call `hasClosedPreview` first to ensure that a preview is required.
/// - Parameters:
/// - url: The URL to generate the preview for.
/// - event: The event that the preview is for.
/// - session: The session to use to contact the homeserver.
/// - success: The closure called when the operation complete. The generated preview data is passed in.
/// - failure: The closure called when something goes wrong. The error that occured is passed in.
func preview(for url: URL,
and event: MXEvent,
with session: MXSession,
success: @escaping (URLPreviewData) -> Void,
failure: @escaping (Error?) -> Void) {
// Sanitize the URL before checking the store or performing lookup
let sanitizedURL = sanitize(url)
// Check for a valid preview in the store, and use this if found
if let preview = store.preview(for: sanitizedURL, and: event) {
MXLog.debug("[URLPreviewService] Using cached preview.")
success(preview)
return
}
// Otherwise make a request to the homeserver to generate a preview
session.matrixRestClient.preview(for: sanitizedURL, success: { previewResponse in
MXLog.debug("[URLPreviewService] Cached preview not found. Requesting from homeserver.")
guard let previewResponse = previewResponse else {
failure(URLPreviewServiceError.missingResponse)
return
}
// Convert the response to preview data, fetching the image if provided.
self.makePreviewData(from: previewResponse, for: sanitizedURL, and: event, with: session) { previewData in
self.store.cache(previewData)
success(previewData)
}
}, failure: failure)
}
/// Removes any cached preview data that has expired.
func removeExpiredCacheData() {
store.removeExpiredItems()
}
/// Deletes all cached preview data and closed previews from the store.
func clearStore() {
store.deleteAll()
}
/// Store the `eventId` and `roomId` of a closed preview.
func closePreview(for eventId: String, in roomId: String) {
store.closePreview(for: eventId, in: roomId)
}
/// Whether a preview for the given event has been closed or not.
func hasClosedPreview(from event: MXEvent) -> Bool {
store.hasClosedPreview(for: event.eventId, in: event.roomId)
}
// MARK: - Private
/// Convert an `MXURLPreview` object into `URLPreviewData` whilst also getting the image via the media manager.
/// - Parameters:
/// - previewResponse: The `MXURLPreview` object to convert.
/// - url: The URL that response was for.
/// - event: The event that the URL preview is for.
/// - session: The session to use to for media management.
/// - completion: A closure called when the operation completes. This contains the preview data.
private func makePreviewData(from previewResponse: MXURLPreview,
for url: URL,
and event: MXEvent,
with session: MXSession,
completion: @escaping (URLPreviewData) -> Void) {
// Create the preview data and return if no image is needed.
let previewData = URLPreviewData(url: url,
eventID: event.eventId,
roomID: event.roomId,
siteName: previewResponse.siteName,
title: previewResponse.title,
text: previewResponse.text)
guard let imageURL = previewResponse.imageURL else {
completion(previewData)
return
}
// Check for an image in the media cache and use this if found.
if let cachePath = MXMediaManager.cachePath(forMatrixContentURI: imageURL, andType: previewResponse.imageType, inFolder: nil),
let image = MXMediaManager.loadThroughCache(withFilePath: cachePath) {
previewData.image = image
completion(previewData)
return
}
// Don't de-dupe image downloads as the service should de-dupe preview generation.
// Otherwise download the image from the homeserver, treating an error as a preview without an image.
session.mediaManager.downloadMedia(fromMatrixContentURI: imageURL, withType: previewResponse.imageType, inFolder: nil) { path in
guard let image = MXMediaManager.loadThroughCache(withFilePath: path) else {
completion(previewData)
return
}
previewData.image = image
completion(previewData)
} failure: { error in
completion(previewData)
}
}
/// Returns a URL created from the URL passed in, with sanitizations applied to reduce
/// queries and duplicate cache data for URLs that will return the same preview data.
private func sanitize(_ url: URL) -> URL {
// Remove the fragment from the URL.
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.fragment = nil
return components?.url ?? url
}
}

View file

@ -558,6 +558,9 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
// check if some media must be released to reduce the cache size
[MXMediaManager reduceCacheSizeToInsert:0];
// Remove expired URL previews from the cache
[URLPreviewService.shared removeExpiredCacheData];
// Hide potential notification
if (self.mxInAppNotification)
{
@ -4379,6 +4382,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni
[MXMediaManager clearCache];
[MXKAttachment clearCache];
[VoiceMessageAttachmentCacheManagerBridge clearCache];
[URLPreviewService.shared clearStore];
}
@end

View file

@ -15,6 +15,9 @@
*/
#import <MatrixKit/MatrixKit.h>
@class URLPreviewData;
extern NSString *const URLPreviewDidUpdateNotification;
// Custom tags for MXKRoomBubbleCellDataStoring.tag
typedef NS_ENUM(NSInteger, RoomBubbleCellDataTag)
@ -79,7 +82,17 @@ typedef NS_ENUM(NSInteger, RoomBubbleCellDataTag)
@property(nonatomic, readonly) CGFloat additionalContentHeight;
/**
MXKeyVerification object associated to key verifcation event when using key verification by direct message.
The data necessary to show a URL preview.
*/
@property (nonatomic) URLPreviewData *urlPreviewData;
/**
Whether a URL preview should be displayed for this cell.
*/
@property (nonatomic) BOOL showURLPreview;
/**
MXKeyVerification object associated to key verification event when using key verification by direct message.
*/
@property(nonatomic, strong) MXKeyVerification *keyVerification;

View file

@ -24,9 +24,12 @@
#import "BubbleReactionsViewSizer.h"
#import "Riot-Swift.h"
#import <MatrixKit/MXKSwiftHeader.h>
static NSAttributedString *timestampVerticalWhitespace = nil;
NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotification";
@interface RoomBubbleCellData()
@property(nonatomic, readonly) BOOL addVerticalWhitespaceForSelectedComponentTimestamp;
@ -176,11 +179,30 @@ static NSAttributedString *timestampVerticalWhitespace = nil;
// Reset attributedTextMessage to force reset MXKRoomCellData parameters
self.attributedTextMessage = nil;
// Load a url preview if a link was detected
if (self.hasLink)
{
[self loadURLPreview];
}
}
return self;
}
- (NSUInteger)updateEvent:(NSString *)eventId withEvent:(MXEvent *)event
{
NSUInteger retVal = [super updateEvent:eventId withEvent:event];
// Update any URL preview data too.
if (self.hasLink)
{
[self loadURLPreview];
}
return retVal;
}
- (void)prepareBubbleComponentsPosition
{
if (shouldUpdateComponentsPosition)
@ -583,8 +605,8 @@ static NSAttributedString *timestampVerticalWhitespace = nil;
});
BOOL showAllReactions = [self.eventsToShowAllReactions containsObject:eventId];
BubbleReactionsViewModel *viemModel = [[BubbleReactionsViewModel alloc] initWithAggregatedReactions:aggregatedReactions eventId:eventId showAll:showAllReactions];
height = [bubbleReactionsViewSizer heightForViewModel:viemModel fittingWidth:bubbleReactionsViewWidth] + RoomBubbleCellLayout.reactionsViewTopMargin;
BubbleReactionsViewModel *viewModel = [[BubbleReactionsViewModel alloc] initWithAggregatedReactions:aggregatedReactions eventId:eventId showAll:showAllReactions];
height = [bubbleReactionsViewSizer heightForViewModel:viewModel fittingWidth:bubbleReactionsViewWidth] + RoomBubbleCellLayout.reactionsViewTopMargin;
}
return height;
@ -745,6 +767,19 @@ static NSAttributedString *timestampVerticalWhitespace = nil;
{
BOOL shouldAddEvent = YES;
// For unencrypted rooms, don't allow any events to be added
// after a bubble component that contains a link so than any URL
// preview is for the last bubble component in the cell.
if (!self.isEncryptedRoom && self.hasLink && self.bubbleComponents.lastObject)
{
MXKRoomBubbleComponent *lastComponent = self.bubbleComponents.lastObject;
if (event.originServerTs > lastComponent.event.originServerTs)
{
shouldAddEvent = NO;
}
}
switch (self.tag)
{
case RoomBubbleCellDataTagKeyVerificationNoDisplay:
@ -788,6 +823,24 @@ static NSAttributedString *timestampVerticalWhitespace = nil;
if ([messageType isEqualToString:kMXMessageTypeKeyVerificationRequest])
{
shouldAddEvent = NO;
break;
}
// If the message contains a link and comes before this cell data, don't add it to
// ensure that a URL preview is only shown for the last component on some new cell data.
if (!self.isEncryptedRoom && self.bubbleComponents.firstObject)
{
MXKRoomBubbleComponent *firstComponent = self.bubbleComponents.firstObject;
if (event.originServerTs < firstComponent.event.originServerTs)
{
NSString *messageBody = event.content[@"body"];
if (messageBody && [messageBody mxk_firstURLDetected])
{
shouldAddEvent = NO;
}
break;
}
}
}
break;
@ -841,7 +894,15 @@ static NSAttributedString *timestampVerticalWhitespace = nil;
if (shouldAddEvent)
{
BOOL hadLink = self.hasLink;
shouldAddEvent = [super addEvent:event andRoomState:roomState];
// If the cell data now contains a link, set the preview data.
if (shouldAddEvent && self.hasLink && !hadLink)
{
[self loadURLPreview];
}
}
return shouldAddEvent;
@ -1009,4 +1070,63 @@ static NSAttributedString *timestampVerticalWhitespace = nil;
return accessibilityLabel;
}
#pragma mark - URL Previews
- (void)loadURLPreview
{
// Get the last bubble component as that contains the link.
MXKRoomBubbleComponent *lastComponent = bubbleComponents.lastObject;
if (!lastComponent)
{
return;
}
// Don't show the preview if it has been dismissed already.
self.showURLPreview = ![URLPreviewService.shared hasClosedPreviewFrom:lastComponent.event];
if (!self.showURLPreview)
{
return;
}
// If there is existing preview data, the message has been edited
// Clear the data to show the loading state when the preview isn't cached
if (self.urlPreviewData)
{
self.urlPreviewData = nil;
}
// Set the preview data.
MXWeakify(self);
NSDictionary<NSString *, NSString*> *userInfo = @{
@"eventId": lastComponent.event.eventId,
@"roomId": self.roomId
};
[URLPreviewService.shared previewFor:lastComponent.link
and:lastComponent.event
with:self.mxSession
success:^(URLPreviewData * _Nonnull urlPreviewData) {
MXStrongifyAndReturnIfNil(self);
// Update the preview data and send a notification for refresh
self.urlPreviewData = urlPreviewData;
dispatch_async(dispatch_get_main_queue(), ^{
[NSNotificationCenter.defaultCenter postNotificationName:URLPreviewDidUpdateNotification object:nil userInfo:userInfo];
});
} failure:^(NSError * _Nullable error) {
MXLogDebug(@"[RoomBubbleCellData] Failed to get url preview")
// Don't show a preview and send a notification for refresh
self.showURLPreview = NO;
dispatch_async(dispatch_get_main_queue(), ^{
[NSNotificationCenter.defaultCenter postNotificationName:URLPreviewDidUpdateNotification object:nil userInfo:userInfo];
});
}];
}
@end

View file

@ -29,7 +29,7 @@
const CGFloat kTypingCellHeight = 24;
@interface RoomDataSource() <BubbleReactionsViewModelDelegate>
@interface RoomDataSource() <BubbleReactionsViewModelDelegate, URLPreviewViewDelegate>
{
// Observe kThemeServiceDidChangeThemeNotification to handle user interface theme change.
id kThemeServiceDidChangeThemeNotificationObserver;
@ -71,7 +71,7 @@ const CGFloat kTypingCellHeight = 24;
// Replace the event formatter
[self updateEventFormatter];
// Handle timestamp and read receips display at Vector app level (see [tableView: cellForRowAtIndexPath:])
// Handle timestamp and read receipts display at Vector app level (see [tableView: cellForRowAtIndexPath:])
self.useCustomDateTimeLabel = YES;
self.useCustomReceipts = YES;
self.useCustomUnsentButton = YES;
@ -343,7 +343,7 @@ const CGFloat kTypingCellHeight = 24;
// Handle read receipts and read marker display.
// Ignore the read receipts on the bubble without actual display.
// Ignore the read receipts on collapsed bubbles
if ((((self.showBubbleReceipts && cellData.readReceipts.count) || cellData.reactions.count) && !isCollapsableCellCollapsed) || self.showReadMarker)
if ((((self.showBubbleReceipts && cellData.readReceipts.count) || cellData.reactions.count || cellData.hasLink) && !isCollapsableCellCollapsed) || self.showReadMarker)
{
// Read receipts container are inserted here on the right side into the content view.
// Some vertical whitespaces are added in message text view (see RoomBubbleCellData class) to insert correctly multiple receipts.
@ -368,7 +368,41 @@ const CGFloat kTypingCellHeight = 24;
{
continue;
}
NSURL *link = component.link;
URLPreviewView *urlPreviewView;
// Show a URL preview if the component has a link that should be previewed.
if (link && RiotSettings.shared.roomScreenShowsURLPreviews && cellData.showURLPreview)
{
urlPreviewView = [URLPreviewView instantiate];
urlPreviewView.preview = cellData.urlPreviewData;
urlPreviewView.delegate = self;
[temporaryViews addObject:urlPreviewView];
if (!bubbleCell.tmpSubviews)
{
bubbleCell.tmpSubviews = [NSMutableArray array];
}
[bubbleCell.tmpSubviews addObject:urlPreviewView];
urlPreviewView.translatesAutoresizingMaskIntoConstraints = NO;
[bubbleCell.contentView addSubview:urlPreviewView];
CGFloat leftMargin = RoomBubbleCellLayout.reactionsViewLeftMargin;
if (roomBubbleCellData.containsBubbleComponentWithEncryptionBadge)
{
leftMargin+= RoomBubbleCellLayout.encryptedContentLeftMargin;
}
// Set the preview view's origin
[NSLayoutConstraint activateConstraints: @[
[urlPreviewView.leadingAnchor constraintEqualToAnchor:urlPreviewView.superview.leadingAnchor constant:leftMargin],
[urlPreviewView.topAnchor constraintEqualToAnchor:urlPreviewView.superview.topAnchor constant:bottomPositionY + RoomBubbleCellLayout.urlPreviewViewTopMargin + RoomBubbleCellLayout.reactionsViewTopMargin],
]];
}
MXAggregatedReactions* reactions = cellData.reactions[componentEventId].aggregatedReactionsWithNonZeroCount;
BubbleReactionsView *reactionsView;
@ -411,12 +445,23 @@ const CGFloat kTypingCellHeight = 24;
leftMargin+= RoomBubbleCellLayout.encryptedContentLeftMargin;
}
// The top constraint may need to include the URL preview view
NSLayoutConstraint *topConstraint;
if (urlPreviewView)
{
topConstraint = [reactionsView.topAnchor constraintEqualToAnchor:urlPreviewView.bottomAnchor constant:RoomBubbleCellLayout.reactionsViewTopMargin];
}
else
{
topConstraint = [reactionsView.topAnchor constraintEqualToAnchor:reactionsView.superview.topAnchor constant:bottomPositionY + RoomBubbleCellLayout.reactionsViewTopMargin];
}
// Force receipts container size
[NSLayoutConstraint activateConstraints:
@[
[reactionsView.leadingAnchor constraintEqualToAnchor:reactionsView.superview.leadingAnchor constant:leftMargin],
[reactionsView.trailingAnchor constraintEqualToAnchor:reactionsView.superview.trailingAnchor constant:-RoomBubbleCellLayout.reactionsViewRightMargin],
[reactionsView.topAnchor constraintEqualToAnchor:reactionsView.superview.topAnchor constant:bottomPositionY + RoomBubbleCellLayout.reactionsViewTopMargin]
topConstraint
]];
}
}
@ -522,12 +567,16 @@ const CGFloat kTypingCellHeight = 24;
multiplier:1.0
constant:-RoomBubbleCellLayout.readReceiptsViewRightMargin];
// At the bottom, we have reactions or nothing
// At the bottom, we either have reactions, a URL preview or nothing
NSLayoutConstraint *topConstraint;
if (reactionsView)
{
topConstraint = [avatarsContainer.topAnchor constraintEqualToAnchor:reactionsView.bottomAnchor constant:RoomBubbleCellLayout.readReceiptsViewTopMargin];
}
else if (urlPreviewView)
{
topConstraint = [avatarsContainer.topAnchor constraintEqualToAnchor:urlPreviewView.bottomAnchor constant:RoomBubbleCellLayout.readReceiptsViewTopMargin];
}
else
{
topConstraint = [avatarsContainer.topAnchor constraintEqualToAnchor:avatarsContainer.superview.topAnchor constant:bottomPositionY + RoomBubbleCellLayout.readReceiptsViewTopMargin];
@ -1163,4 +1212,45 @@ const CGFloat kTypingCellHeight = 24;
}
}
#pragma mark - URLPreviewViewDelegate
- (void)didOpenURLFromPreviewView:(URLPreviewView *)previewView for:(NSString *)eventID in:(NSString *)roomID
{
// Use the link stored in the bubble component when opening the URL as we only
// store the sanitized URL in the preview data which may differ to the message content.
RoomBubbleCellData *cellData = [self cellDataOfEventWithEventId:eventID];
if (!cellData)
{
return;
}
MXKRoomBubbleComponent *lastComponent = cellData.bubbleComponents.lastObject;
if (!lastComponent)
{
return;
}
[UIApplication.sharedApplication vc_open:lastComponent.link completionHandler:nil];
}
- (void)didCloseURLPreviewView:(URLPreviewView *)previewView for:(NSString *)eventID in:(NSString *)roomID
{
RoomBubbleCellData *cellData = [self cellDataOfEventWithEventId:eventID];
if (!cellData)
{
return;
}
// Remember that the user closed the preview so it isn't shown again.
[URLPreviewService.shared closePreviewFor:eventID in:roomID];
// Hide the preview, remove its data and refresh the cells.
cellData.showURLPreview = NO;
cellData.urlPreviewData = nil;
[self refreshCells];
}
@end

View file

@ -208,6 +208,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
// Observe kThemeServiceDidChangeThemeNotification to handle user interface theme change.
id kThemeServiceDidChangeThemeNotificationObserver;
// Observe URL preview updates to refresh cells.
id URLPreviewDidUpdateNotificationObserver;
// Listener for `m.room.tombstone` event type
id tombstoneEventNotificationsListener;
@ -440,6 +443,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
}];
[self userInterfaceThemeDidChange];
// Observe URL preview updates.
[self registerURLPreviewNotifications];
[self setupActions];
}
@ -1358,6 +1364,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
[[NSNotificationCenter defaultCenter] removeObserver:mxEventDidDecryptNotificationObserver];
mxEventDidDecryptNotificationObserver = nil;
}
if (URLPreviewDidUpdateNotificationObserver)
{
[NSNotificationCenter.defaultCenter removeObserver:URLPreviewDidUpdateNotificationObserver];
URLPreviewDidUpdateNotificationObserver = nil;
}
[self removeCallNotificationsListeners];
[self removeWidgetNotificationsListeners];
@ -1509,6 +1520,47 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
return myPower >= requiredPower;
}
- (void)registerURLPreviewNotifications
{
URLPreviewDidUpdateNotificationObserver = [NSNotificationCenter.defaultCenter addObserverForName:URLPreviewDidUpdateNotification object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification * _Nonnull notification) {
// Ensure this is the correct room
if (![(NSString*)notification.userInfo[@"roomId"] isEqualToString:self.roomDataSource.roomId])
{
return;
}
// Get the indexPath for the updated cell.
NSString *updatedEventId = notification.userInfo[@"eventId"];
NSInteger updatedEventIndex = [self.roomDataSource indexOfCellDataWithEventId:updatedEventId];
NSIndexPath *updatedIndexPath = [NSIndexPath indexPathForRow:updatedEventIndex inSection:0];
// Store the content size and offset before reloading the cell
CGFloat originalContentSize = self.bubblesTableView.contentSize.height;
CGPoint contentOffset = self.bubblesTableView.contentOffset;
// Only update the content offset if the cell is visible or above the current visible cells.
BOOL shouldUpdateContentOffset = NO;
NSIndexPath *lastVisibleIndexPath = [self.bubblesTableView indexPathsForVisibleRows].lastObject;
if (lastVisibleIndexPath && updatedIndexPath.row < lastVisibleIndexPath.row)
{
shouldUpdateContentOffset = YES;
}
// Note: Despite passing in the index path, this reloads the whole table.
[self dataSource:self.roomDataSource didCellChange:updatedIndexPath];
// Update the content offset to include any changes to the scroll view's height.
if (shouldUpdateContentOffset)
{
CGFloat delta = self.bubblesTableView.contentSize.height - originalContentSize;
contentOffset.y += delta;
self.bubblesTableView.contentOffset = contentOffset;
}
}];
}
- (void)refreshRoomTitle
{
NSMutableArray *rightBarButtonItems = nil;

View file

@ -45,4 +45,5 @@ final class RoomBubbleCellLayout: NSObject {
// Others
static let encryptedContentLeftMargin: CGFloat = 15.0
static let urlPreviewViewTopMargin: CGFloat = 8.0
}

View file

@ -39,4 +39,18 @@
[self updateUserNameColor];
}
+ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth
{
RoomBubbleCellData *bubbleData = (RoomBubbleCellData*)cellData;
// Include the URL preview in the height if necessary.
if (RiotSettings.shared.roomScreenShowsURLPreviews && bubbleData && bubbleData.showURLPreview)
{
CGFloat height = [super heightForCellData:cellData withMaximumWidth:maxWidth];
return height + RoomBubbleCellLayout.urlPreviewViewTopMargin + [URLPreviewView contentViewHeightFor:bubbleData.urlPreviewData];
}
return [super heightForCellData:cellData withMaximumWidth:maxWidth];
}
@end

View file

@ -45,4 +45,18 @@
}
}
+ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth
{
RoomBubbleCellData *bubbleData = (RoomBubbleCellData*)cellData;
// Include the URL preview in the height if necessary.
if (RiotSettings.shared.roomScreenShowsURLPreviews && bubbleData && bubbleData.showURLPreview)
{
CGFloat height = [super heightForCellData:cellData withMaximumWidth:maxWidth];
return height + RoomBubbleCellLayout.urlPreviewViewTopMargin + [URLPreviewView contentViewHeightFor:bubbleData.urlPreviewData];
}
return [super heightForCellData:cellData withMaximumWidth:maxWidth];
}
@end

View file

@ -29,4 +29,18 @@
self.messageTextView.tintColor = ThemeService.shared.theme.tintColor;
}
+ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth
{
RoomBubbleCellData *bubbleData = (RoomBubbleCellData*)cellData;
// Include the URL preview in the height if necessary.
if (RiotSettings.shared.roomScreenShowsURLPreviews && bubbleData && bubbleData.showURLPreview)
{
CGFloat height = [super heightForCellData:cellData withMaximumWidth:maxWidth];
return height + RoomBubbleCellLayout.urlPreviewViewTopMargin + [URLPreviewView contentViewHeightFor:bubbleData.urlPreviewData];
}
return [super heightForCellData:cellData withMaximumWidth:maxWidth];
}
@end

View file

@ -40,4 +40,18 @@
[self updateUserNameColor];
}
+ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth
{
RoomBubbleCellData *bubbleData = (RoomBubbleCellData*)cellData;
// Include the URL preview in the height if necessary.
if (RiotSettings.shared.roomScreenShowsURLPreviews && bubbleData && bubbleData.showURLPreview)
{
CGFloat height = [super heightForCellData:cellData withMaximumWidth:maxWidth];
return height + RoomBubbleCellLayout.urlPreviewViewTopMargin + [URLPreviewView contentViewHeightFor:bubbleData.urlPreviewData];
}
return [super heightForCellData:cellData withMaximumWidth:maxWidth];
}
@end

View file

@ -29,4 +29,18 @@
self.messageTextView.tintColor = ThemeService.shared.theme.tintColor;
}
+ (CGFloat)heightForCellData:(MXKCellData *)cellData withMaximumWidth:(CGFloat)maxWidth
{
RoomBubbleCellData *bubbleData = (RoomBubbleCellData*)cellData;
// Include the URL preview in the height if necessary.
if (RiotSettings.shared.roomScreenShowsURLPreviews && bubbleData && bubbleData.showURLPreview)
{
CGFloat height = [super heightForCellData:cellData withMaximumWidth:maxWidth];
return height + RoomBubbleCellLayout.urlPreviewViewTopMargin + [URLPreviewView contentViewHeightFor:bubbleData.urlPreviewData];
}
return [super heightForCellData:cellData withMaximumWidth:maxWidth];
}
@end

View file

@ -0,0 +1,191 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import UIKit
import Reusable
@objc
protocol URLPreviewViewDelegate: AnyObject {
func didOpenURLFromPreviewView(_ previewView: URLPreviewView, for eventID: String, in roomID: String)
func didCloseURLPreviewView(_ previewView: URLPreviewView, for eventID: String, in roomID: String)
}
@objcMembers
/// A view to display `URLPreviewData` generated by the `URLPreviewManager`.
class URLPreviewView: UIView, NibLoadable, Themable {
// MARK: - Constants
private static let sizingView = URLPreviewView.instantiate()
private enum Constants {
/// The fixed width of the preview view.
static let width: CGFloat = 267.0
}
// MARK: - Properties
/// The preview data to display in the view.
var preview: URLPreviewData? {
didSet {
guard let preview = preview else {
renderLoading()
return
}
renderLoaded(preview)
}
}
weak var delegate: URLPreviewViewDelegate?
@IBOutlet weak var imageView: UIImageView!
@IBOutlet weak var closeButton: UIButton!
@IBOutlet weak var textContainerView: UIView!
@IBOutlet weak var siteNameLabel: UILabel!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var descriptionLabel: UILabel!
@IBOutlet weak var loadingView: UIView!
@IBOutlet weak var loadingActivityIndicator: UIActivityIndicatorView!
// Matches the label's height with the close button.
// Use a strong reference to keep it around when deactivating.
@IBOutlet var siteNameLabelHeightConstraint: NSLayoutConstraint!
/// Returns true when `titleLabel` has a non-empty string.
private var hasTitle: Bool {
guard let title = titleLabel.text else { return false }
return !title.isEmpty
}
// MARK: - Setup
static func instantiate() -> Self {
let view = Self.loadFromNib()
view.update(theme: ThemeService.shared().theme)
view.translatesAutoresizingMaskIntoConstraints = false // fixes unsatisfiable constraints encountered by the sizing view
return view
}
// MARK: - Life cycle
override func awakeFromNib() {
super.awakeFromNib()
layer.cornerRadius = 8
layer.masksToBounds = true
imageView.contentMode = .scaleAspectFill
siteNameLabel.isUserInteractionEnabled = false
titleLabel.isUserInteractionEnabled = false
descriptionLabel.isUserInteractionEnabled = false
}
// MARK: - Public
func update(theme: Theme) {
backgroundColor = theme.colors.navigation
siteNameLabel.textColor = theme.colors.secondaryContent
siteNameLabel.font = theme.fonts.caption2SB
titleLabel.textColor = theme.colors.primaryContent
titleLabel.font = theme.fonts.calloutSB
descriptionLabel.textColor = theme.colors.secondaryContent
descriptionLabel.font = theme.fonts.caption1
let closeButtonAsset = ThemeService.shared().isCurrentThemeDark() ? Asset.Images.urlPreviewCloseDark : Asset.Images.urlPreviewClose
closeButton.setImage(closeButtonAsset.image, for: .normal)
}
static func contentViewHeight(for preview: URLPreviewData?) -> CGFloat {
sizingView.frame = CGRect(x: 0, y: 0, width: Constants.width, height: 1)
// Call render directly to avoid storing the preview data in the sizing view
if let preview = preview {
sizingView.renderLoaded(preview)
} else {
sizingView.renderLoading()
}
sizingView.setNeedsLayout()
sizingView.layoutIfNeeded()
let fittingSize = CGSize(width: Constants.width, height: UIView.layoutFittingCompressedSize.height)
let layoutSize = sizingView.systemLayoutSizeFitting(fittingSize)
return layoutSize.height
}
// MARK: - Private
/// Tells the view to show in it's loading state.
private func renderLoading() {
// hide the content
imageView.isHidden = true
textContainerView.isHidden = true
// show the loading interface
loadingView.isHidden = false
loadingActivityIndicator.startAnimating()
}
/// Tells the view to display it's loaded state for the supplied data.
private func renderLoaded(_ preview: URLPreviewData) {
// update preview content
imageView.image = preview.image
siteNameLabel.text = preview.siteName ?? preview.url.host
titleLabel.text = preview.title
descriptionLabel.text = preview.text
// hide the loading interface
loadingView.isHidden = true
loadingActivityIndicator.stopAnimating()
// show the content
textContainerView.isHidden = false
// tweak the layout depending on the content
if imageView.image == nil {
imageView.isHidden = true
siteNameLabelHeightConstraint.isActive = true
descriptionLabel.numberOfLines = hasTitle ? 3 : 5
} else {
imageView.isHidden = false
siteNameLabelHeightConstraint.isActive = false
descriptionLabel.numberOfLines = 2
}
}
// MARK: - Action
@IBAction private func openURL(_ sender: Any) {
MXLog.debug("[URLPreviewView] Link was tapped.")
guard let preview = preview else { return }
// Ask the delegate to open the URL for the event, as the bubble component
// has the original un-sanitized URL that needs to be opened.
delegate?.didOpenURLFromPreviewView(self, for: preview.eventID, in: preview.roomID)
}
@IBAction private func close(_ sender: Any) {
guard let preview = preview else { return }
delegate?.didCloseURLPreviewView(self, for: preview.eventID, in: preview.roomID)
}
}

View file

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<view contentMode="scaleToFill" id="dCz-KI-m5q" customClass="URLPreviewView" customModule="Riot" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="267" height="301"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="Rqc-iY-nm0">
<rect key="frame" x="0.0" y="0.0" width="267" height="301"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="4zc-0W-jb8">
<rect key="frame" x="0.0" y="0.0" width="267" height="140"/>
<constraints>
<constraint firstAttribute="height" constant="140" id="QpS-Ys-x5s"/>
</constraints>
</imageView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="n9x-Yn-0qQ" userLabel="Text Container">
<rect key="frame" x="0.0" y="140" width="267" height="79"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="3Wa-hg-AAN">
<rect key="frame" x="8" y="8" width="251" height="63"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Site Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ay6-fT-tTb">
<rect key="frame" x="0.0" y="0.0" width="56" height="25"/>
<constraints>
<constraint firstAttribute="height" constant="25" id="vhD-hz-f58"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="11"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="IVX-5S-0kr">
<rect key="frame" x="0.0" y="27" width="33.5" height="19.5"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="16"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="250" text="Description" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Z30-YF-eQk">
<rect key="frame" x="0.0" y="48.5" width="65" height="14.5"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="3Wa-hg-AAN" secondAttribute="trailing" constant="8" id="2kO-tW-gxn"/>
<constraint firstItem="3Wa-hg-AAN" firstAttribute="leading" secondItem="n9x-Yn-0qQ" secondAttribute="leading" constant="8" id="9gR-Ab-8qX"/>
<constraint firstItem="3Wa-hg-AAN" firstAttribute="top" secondItem="n9x-Yn-0qQ" secondAttribute="top" constant="8" id="AJk-SF-ghk"/>
<constraint firstAttribute="bottom" secondItem="3Wa-hg-AAN" secondAttribute="bottom" constant="8" id="ysy-Gi-EZT"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="u8r-SW-zAH">
<rect key="frame" x="0.0" y="219" width="267" height="82"/>
<subviews>
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" style="medium" translatesAutoresizingMaskIntoConstraints="NO" id="SSJ-n0-24Z">
<rect key="frame" x="123.5" y="32" width="20" height="18"/>
</activityIndicatorView>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="SSJ-n0-24Z" firstAttribute="centerX" secondItem="u8r-SW-zAH" secondAttribute="centerX" id="121-oq-4Zn"/>
<constraint firstAttribute="bottom" secondItem="SSJ-n0-24Z" secondAttribute="bottom" constant="32" id="1fW-21-XBI"/>
<constraint firstItem="SSJ-n0-24Z" firstAttribute="top" secondItem="u8r-SW-zAH" secondAttribute="top" constant="32" id="9zi-Wb-6V5"/>
<constraint firstItem="SSJ-n0-24Z" firstAttribute="centerY" secondItem="u8r-SW-zAH" secondAttribute="centerY" id="Puk-Mm-Vir"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstAttribute="width" constant="267" id="f2o-yq-NFO"/>
</constraints>
</stackView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="30L-fe-CQa">
<rect key="frame" x="227" y="0.0" width="40" height="40"/>
<inset key="contentEdgeInsets" minX="8" minY="8" maxX="8" maxY="8"/>
<state key="normal" image="url_preview_close"/>
<connections>
<action selector="close:" destination="dCz-KI-m5q" eventType="touchUpInside" id="Bh3-1r-Alc"/>
</connections>
</button>
</subviews>
<color key="backgroundColor" systemColor="tertiarySystemFillColor"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="30L-fe-CQa" secondAttribute="trailing" id="3Vz-ER-W6l"/>
<constraint firstItem="Rqc-iY-nm0" firstAttribute="top" secondItem="dCz-KI-m5q" secondAttribute="top" id="4yJ-eR-8oO"/>
<constraint firstAttribute="trailing" secondItem="Rqc-iY-nm0" secondAttribute="trailing" id="AHA-th-scO"/>
<constraint firstAttribute="bottom" secondItem="Rqc-iY-nm0" secondAttribute="bottom" id="NGE-IA-ky5"/>
<constraint firstItem="Rqc-iY-nm0" firstAttribute="leading" secondItem="dCz-KI-m5q" secondAttribute="leading" id="jJ1-6i-YZj"/>
<constraint firstItem="30L-fe-CQa" firstAttribute="top" secondItem="dCz-KI-m5q" secondAttribute="top" id="ydp-FM-Vv4"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="closeButton" destination="30L-fe-CQa" id="hFu-BX-zRP"/>
<outlet property="descriptionLabel" destination="Z30-YF-eQk" id="DJ4-Bg-MHW"/>
<outlet property="imageView" destination="4zc-0W-jb8" id="QRh-IX-XxR"/>
<outlet property="loadingActivityIndicator" destination="SSJ-n0-24Z" id="ylX-Qd-8t5"/>
<outlet property="loadingView" destination="u8r-SW-zAH" id="s7r-Kl-w5h"/>
<outlet property="siteNameLabel" destination="ay6-fT-tTb" id="2wA-1z-lcs"/>
<outlet property="siteNameLabelHeightConstraint" destination="vhD-hz-f58" id="Bz9-ub-9UA"/>
<outlet property="textContainerView" destination="n9x-Yn-0qQ" id="Zul-rd-vrp"/>
<outlet property="titleLabel" destination="IVX-5S-0kr" id="PRN-5g-HiO"/>
<outletCollection property="gestureRecognizers" destination="rSB-1V-Kev" appends="YES" id="OOJ-ft-VIj"/>
</connections>
<point key="canvasLocation" x="1852.8985507246377" y="14.397321428571427"/>
</view>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tapGestureRecognizer id="rSB-1V-Kev">
<connections>
<action selector="openURL:" destination="dCz-KI-m5q" id="Fu6-Tb-bkW"/>
</connections>
</tapGestureRecognizer>
</objects>
<resources>
<image name="url_preview_close" width="24" height="24"/>
<systemColor name="tertiarySystemFillColor">
<color red="0.46274509803921571" green="0.46274509803921571" blue="0.50196078431372548" alpha="0.12" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View file

@ -126,7 +126,7 @@ enum {
enum
{
USER_INTERFACE_LANGUAGE_INDEX = 0,
USER_INTERFACE_THEME_INDEX,
USER_INTERFACE_THEME_INDEX
};
enum
@ -154,6 +154,8 @@ enum
enum
{
LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0,
LABS_SHOW_URL_PREVIEWS_INDEX,
LABS_SHOW_URL_PREVIEWS_DESCRIPTION_INDEX
};
enum
@ -530,6 +532,8 @@ TableViewSectionsDelegate>
{
Section *sectionLabs = [Section sectionWithTag:SECTION_TAG_LABS];
[sectionLabs addRowWithTag:LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX];
[sectionLabs addRowWithTag:LABS_SHOW_URL_PREVIEWS_INDEX];
[sectionLabs addRowWithTag:LABS_SHOW_URL_PREVIEWS_DESCRIPTION_INDEX];
sectionLabs.headerTitle = NSLocalizedStringFromTable(@"settings_labs", @"Vector", nil);
if (sectionLabs.hasAnyRows)
{
@ -2392,6 +2396,28 @@ TableViewSectionsDelegate>
cell = labelAndSwitchCell;
}
else if (row == LABS_SHOW_URL_PREVIEWS_INDEX)
{
MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath];
labelAndSwitchCell.mxkLabel.text = NSLocalizedStringFromTable(@"settings_show_url_previews", @"Vector", nil);
labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.roomScreenShowsURLPreviews;
labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor;
labelAndSwitchCell.mxkSwitch.enabled = YES;
[labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableURLPreviews:) forControlEvents:UIControlEventValueChanged];
cell = labelAndSwitchCell;
}
else if (row == LABS_SHOW_URL_PREVIEWS_DESCRIPTION_INDEX)
{
MXKTableViewCell *descriptionCell = [self getDefaultTableViewCell:tableView];
descriptionCell.textLabel.text = NSLocalizedStringFromTable(@"settings_show_url_previews_description", @"Vector", nil);
descriptionCell.textLabel.numberOfLines = 0;
descriptionCell.selectionStyle = UITableViewCellSelectionStyleNone;
cell = descriptionCell;
}
}
else if (section == SECTION_TAG_FLAIR)
{
@ -3083,6 +3109,11 @@ TableViewSectionsDelegate>
}
}
- (void)toggleEnableURLPreviews:(UISwitch *)sender
{
RiotSettings.shared.roomScreenShowsURLPreviews = sender.on;
}
- (void)toggleSendCrashReport:(id)sender
{
BOOL enable = RiotSettings.shared.enableCrashReport;

View file

@ -0,0 +1,152 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
@testable import Riot
class URLPreviewStoreTests: XCTestCase {
var store: URLPreviewStore!
/// Creates mock URL preview data for matrix.org
func matrixPreview() -> URLPreviewData {
let preview = URLPreviewData(url: URL(string: "https://www.matrix.org/")!,
eventID: "",
roomID: "",
siteName: "Matrix",
title: "Home",
text: "An open network for secure, decentralized communication")
preview.image = Asset.Images.appSymbol.image
return preview
}
/// Creates mock URL preview data for element.io
func elementPreview() -> URLPreviewData {
URLPreviewData(url: URL(string: "https://element.io/")!,
eventID: "",
roomID: "",
siteName: "Element",
title: "Home",
text: "Secure and independent communication, connected via Matrix")
}
/// Creates a fake `MXEvent` object to be passed to the store as needed.
func fakeEvent() -> MXEvent {
let event = MXEvent()
event.eventId = ""
event.roomId = ""
return event
}
override func setUpWithError() throws {
// Create a fresh in-memory cache for each test.
store = URLPreviewStore(inMemory: true)
}
func testStoreAndRetrieve() {
// Given a URL preview
let preview = matrixPreview()
// When caching and retrieving that preview.
store.cache(preview)
guard let cachedPreview = store.preview(for: preview.url, and: fakeEvent()) else {
XCTFail("The cache should return a preview after storing one with the same URL.")
return
}
// Then the content in the retrieved preview should match the original preview.
XCTAssertEqual(cachedPreview.url, preview.url, "The url should match.")
XCTAssertEqual(cachedPreview.siteName, preview.siteName, "The site name should match.")
XCTAssertEqual(cachedPreview.title, preview.title, "The title should match.")
XCTAssertEqual(cachedPreview.text, preview.text, "The text should match.")
XCTAssertEqual(cachedPreview.image == nil, preview.image == nil, "The cached preview should have an image if the original did.")
}
func testUpdating() {
// Given a preview stored in the cache.
let preview = matrixPreview()
store.cache(preview)
guard let cachedPreview = store.preview(for: preview.url, and: fakeEvent()) else {
XCTFail("The cache should return a preview after storing one with the same URL.")
return
}
XCTAssertEqual(cachedPreview.text, preview.text, "The text should match the original preview's text.")
XCTAssertEqual(store.cacheCount(), 1, "There should be 1 item in the cache.")
// When storing an updated version of that preview.
let updatedPreview = URLPreviewData(url: preview.url,
eventID: "",
roomID: "",
siteName: "Matrix",
title: "Home",
text: "We updated our website.")
store.cache(updatedPreview)
// Then the store should update the original preview.
guard let updatedCachedPreview = store.preview(for: preview.url, and: fakeEvent()) else {
XCTFail("The cache should return a preview after storing one with the same URL.")
return
}
XCTAssertEqual(updatedCachedPreview.text, updatedPreview.text, "The text should match the updated preview's text.")
XCTAssertEqual(store.cacheCount(), 1, "There should still only be 1 item in the cache.")
}
func testPreviewExpiry() {
// Given a preview generated 30 days ago.
let preview = matrixPreview()
store.cache(preview, generatedOn: Date().addingTimeInterval(-60 * 60 * 24 * 30))
// When retrieving that today.
let cachedPreview = store.preview(for: preview.url, and: fakeEvent())
// Then no preview should be returned.
XCTAssertNil(cachedPreview, "The expired preview should not be returned.")
}
func testRemovingExpiredItems() {
// Given a cache with 2 items, one of which has expired.
testPreviewExpiry()
let preview = elementPreview()
store.cache(preview)
XCTAssertEqual(store.cacheCount(), 2, "There should be 2 items in the cache.")
// When removing expired items.
store.removeExpiredItems()
// Then only the expired item should have been removed.
XCTAssertEqual(store.cacheCount(), 1, "Only 1 item should have been removed from the cache.")
if store.preview(for: preview.url, and: fakeEvent()) == nil {
XCTFail("The valid preview should still be in the cache.")
}
}
func testClearingTheCache() {
// Given a cache with 2 items.
testStoreAndRetrieve()
let preview = elementPreview()
store.cache(preview)
XCTAssertEqual(store.cacheCount(), 2, "There should be 2 items in the cache.")
// When clearing the cache.
store.deleteAll()
// Then no items should be left in the cache
XCTAssertEqual(store.cacheCount(), 0, "The cache should be empty.")
}
}

1
changelog.d/888.feature Normal file
View file

@ -0,0 +1 @@
Timeline: Add URL previews under a labs setting.