Add permalink detection and custom attributed string attributes (#536)

* Pass full attributed string to TimelineItems and let the UI level handle blockquote coalescing
* Add permalink detection and attribute embedding within the attributed string
This commit is contained in:
Stefan Ceriu
2023-02-06 16:59:32 +02:00
committed by GitHub
parent 2734a18217
commit f8e6179933
38 changed files with 287 additions and 171 deletions
@@ -0,0 +1,37 @@
//
// Copyright 2023 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
extension AttributedString {
var formattedComponents: [AttributedStringBuilderComponent] {
runs[\.blockquote].map { value, range in
var attributedString = AttributedString(self[range])
// Remove trailing new lines if any
if attributedString.characters.last?.isNewline ?? false,
let range = attributedString.range(of: "\n", options: .backwards, locale: nil) {
attributedString.removeSubrange(range)
}
let isBlockquote = value != nil
/// This is a temporary workaround until replies are retrieved from the SDK.
let isReply = isBlockquote && attributedString.characters.starts(with: "In reply to @")
return AttributedStringBuilderComponent(attributedString: attributedString, isBlockquote: isBlockquote, isReply: isReply)
}
}
}
@@ -72,6 +72,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString)
removeDefaultForegroundColor(mutableAttributedString)
addLinks(mutableAttributedString)
detectPermalinks(mutableAttributedString)
removeLinkColors(mutableAttributedString)
replaceMarkedBlockquotes(mutableAttributedString)
replaceMarkedCodeBlocks(mutableAttributedString)
@@ -80,28 +81,6 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
return try? AttributedString(mutableAttributedString, including: \.elementX)
}
func blockquoteCoalescedComponentsFrom(_ attributedString: AttributedString?) -> [AttributedStringBuilderComponent]? {
guard let attributedString else {
return nil
}
return attributedString.runs[\.blockquote].map { value, range in
var attributedString = AttributedString(attributedString[range])
// Remove trailing new lines if any
if attributedString.characters.last?.isNewline ?? false,
let range = attributedString.range(of: "\n", options: .backwards, locale: nil) {
attributedString.removeSubrange(range)
}
let isBlockquote = value != nil
/// This is a temporary workaround until replies are retrieved from the SDK.
let isReply = isBlockquote && attributedString.characters.starts(with: "In reply to @")
return AttributedStringBuilderComponent(attributedString: attributedString, isBlockquote: isBlockquote, isReply: isReply)
}
}
// MARK: - Private
private func replaceMarkedBlockquotes(_ attributedString: NSMutableAttributedString) {
@@ -115,7 +94,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
return
}
attributedString.addAttribute(.MXBlockquote, value: true, range: range)
attributedString.addAttribute(.MatrixBlockquote, value: true, range: range)
}
attributedString.enumerateAttribute(.backgroundColor, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
@@ -125,7 +104,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
}
attributedString.removeAttribute(.backgroundColor, range: range)
attributedString.addAttribute(.MXBlockquote, value: true, range: range)
attributedString.addAttribute(.MatrixBlockquote, value: true, range: range)
}
}
@@ -188,6 +167,27 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
}
}
private func detectPermalinks(_ attributedString: NSMutableAttributedString) {
attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
if value != nil {
if let url = value as? URL {
switch PermalinkBuilder.detectPermalink(in: url) {
case .userIdentifier(let identifier):
attributedString.addAttributes([.MatrixUserID: identifier], range: range)
case .roomIdentifier(let identifier):
attributedString.addAttributes([.MatrixRoomID: identifier], range: range)
case .roomAlias(let alias):
attributedString.addAttributes([.MatrixRoomAlias: alias], range: range)
case .event(let roomIdentifier, let eventIdentifier):
attributedString.addAttributes([.MatrixEventID: EventIDAttributeValue(roomID: roomIdentifier, eventID: eventIdentifier)], range: range)
case .none:
break
}
}
}
}
}
private func removeDefaultForegroundColor(_ attributedString: NSMutableAttributedString) {
attributedString.enumerateAttribute(.foregroundColor, in: .init(location: 0, length: attributedString.length), options: []) { value, range, _ in
if value as? UIColor == UIColor.black {
@@ -241,5 +241,9 @@ extension UIColor {
extension NSAttributedString.Key {
static let DTTextBlocks: NSAttributedString.Key = .init(rawValue: DTTextBlocksAttribute)
static let MXBlockquote: NSAttributedString.Key = .init(rawValue: BlockquoteAttribute.name)
static let MatrixBlockquote: NSAttributedString.Key = .init(rawValue: BlockquoteAttribute.name)
static let MatrixUserID: NSAttributedString.Key = .init(rawValue: UserIDAttribute.name)
static let MatrixRoomID: NSAttributedString.Key = .init(rawValue: RoomIDAttribute.name)
static let MatrixRoomAlias: NSAttributedString.Key = .init(rawValue: RoomAliasAttribute.name)
static let MatrixEventID: NSAttributedString.Key = .init(rawValue: EventIDAttribute.name)
}
@@ -26,6 +26,4 @@ protocol AttributedStringBuilderProtocol {
func fromPlain(_ string: String?) -> AttributedString?
func fromHTML(_ htmlString: String?) -> AttributedString?
func blockquoteCoalescedComponentsFrom(_ attributedString: AttributedString?) -> [AttributedStringBuilderComponent]?
}
@@ -21,10 +21,40 @@ enum BlockquoteAttribute: AttributedStringKey {
public static var name = "MXBlockquoteAttribute"
}
enum UserIDAttribute: AttributedStringKey {
typealias Value = String
public static var name = "MXUserIDAttribute"
}
enum RoomIDAttribute: AttributedStringKey {
typealias Value = String
public static var name = "MXRoomIDAttribute"
}
enum RoomAliasAttribute: AttributedStringKey {
typealias Value = String
public static var name = "MXRoomAliasAttribute"
}
struct EventIDAttributeValue: Hashable {
let roomID: String
let eventID: String
}
enum EventIDAttribute: AttributedStringKey {
typealias Value = EventIDAttributeValue
public static var name = "MXEventIDAttribute"
}
extension AttributeScopes {
struct ElementXAttributes: AttributeScope {
let blockquote: BlockquoteAttribute
let userID: UserIDAttribute
let roomID: RoomIDAttribute
let roomAlias: RoomAliasAttribute
let eventID: EventIDAttribute
let swiftUI: SwiftUIAttributes
let uiKit: UIKitAttributes
}
+47 -1
View File
@@ -25,13 +25,59 @@ enum PermalinkBuilderError: Error {
case failedAddingPercentEncoding
}
enum PermalinkType: Equatable {
case userIdentifier(String)
case roomIdentifier(String)
case roomAlias(String)
case event(roomIdentifier: String, eventIdentifier: String)
}
enum PermalinkBuilder {
static var uriComponentCharacterSet: CharacterSet = {
private static var uriComponentCharacterSet: CharacterSet = {
var charset = CharacterSet.alphanumerics
charset.insert(charactersIn: "-_.!~*'()")
return charset
}()
static func detectPermalink(in url: URL) -> PermalinkType? {
guard url.absoluteString.hasPrefix(ServiceLocator.shared.settings.permalinkBaseURL.absoluteString) else {
return nil
}
guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return nil
}
guard var fragment = urlComponents.fragment else {
return nil
}
if fragment.hasPrefix("/") {
fragment = String(fragment.dropFirst(1))
}
if let userIdentifierRange = MatrixEntityRegex.userIdentifierRegex.firstMatch(in: fragment, range: .init(location: 0, length: fragment.count))?.range {
return .userIdentifier((fragment as NSString).substring(with: userIdentifierRange))
}
if let roomAliasRange = MatrixEntityRegex.roomAliasRegex.firstMatch(in: fragment, range: .init(location: 0, length: fragment.count))?.range {
return .roomAlias((fragment as NSString).substring(with: roomAliasRange))
}
if let roomIdentifierRange = MatrixEntityRegex.roomIdentifierRegex.firstMatch(in: fragment, range: .init(location: 0, length: fragment.count))?.range {
let roomIdentifier = (fragment as NSString).substring(with: roomIdentifierRange)
if let eventIdentifierRange = MatrixEntityRegex.eventIdentifierRegex.firstMatch(in: fragment, range: .init(location: 0, length: fragment.count))?.range {
let eventIdentifier = (fragment as NSString).substring(with: eventIdentifierRange)
return .event(roomIdentifier: roomIdentifier, eventIdentifier: eventIdentifier)
}
return .roomIdentifier(roomIdentifier)
}
return nil
}
static func permalinkTo(userIdentifier: String) throws -> URL {
guard MatrixEntityRegex.isMatrixUserIdentifier(userIdentifier) else {
throw PermalinkBuilderError.invalidUserIdentifier