mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user