element-ios/Riot/Modules/Pills/PillsFormatter.swift

254 lines
11 KiB
Swift

//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
import UIKit
/// Provides utilities funcs to handle Pills inside attributed strings.
@available(iOS 15.0, *)
@objcMembers
class PillsFormatter: NSObject {
// MARK: - Internal Properties
/// UTType identifier for pills. Should be declared as Document type & Exported type identifier inside Info.plist
static let pillUTType: String = "im.vector.app.pills"
// MARK: - Internal Enums
/// Defines a replacement mode for converting Pills to plain text.
@objc enum PillsReplacementTextMode: Int {
case displayname
case identifier
case markdown
}
// MARK: - Internal Methods
/// Insert text attachments for pills inside given message attributed string.
///
/// - Parameters:
/// - attributedString: message string to update
/// - session: current session
/// - eventFormatter: the event formatter
/// - event: the event
/// - roomState: room state for message
/// - latestRoomState: latest room state of the room containing this message
/// - isEditMode: whether this string will be used in the composer
/// - Returns: new attributed string with pills
static func insertPills(in attributedString: NSAttributedString,
withSession session: MXSession,
eventFormatter: MXKEventFormatter,
event: MXEvent,
roomState: MXRoomState,
andLatestRoomState latestRoomState: MXRoomState?,
isEditMode: Bool = false) -> NSAttributedString {
let newAttr = NSMutableAttributedString(attributedString: attributedString)
newAttr.vc_enumerateAttribute(.link) { (url: URL, range: NSRange, _) in
let provider = PillProvider(withSession: session,
eventFormatter: eventFormatter,
event: event,
roomState: roomState,
andLatestRoomState: latestRoomState,
isEditMode: isEditMode)
// try to get a mention pill from the url
let label = Range(range, in: newAttr.string).flatMap { String(newAttr.string[$0]) }
if let attachmentString: NSAttributedString = provider.pillTextAttachmentString(forUrl: url, withLabel: label ?? "") {
// replace the url with the pill
newAttr.replaceCharacters(in: range, with: attachmentString)
}
}
return newAttr
}
/// Insert text attachments for pills inside given attributed string containing markdown.
///
/// - Parameters:
/// - markdownString: An attributed string with markdown formatting
/// - roomState: The current room state
/// - font: The font to use for the pill text
/// - Returns: A new attributed string with pills.
static func insertPills(in markdownString: NSAttributedString,
withSession session: MXSession,
eventFormatter: MXKEventFormatter,
roomState: MXRoomState,
font: UIFont) -> NSAttributedString {
let matches = markdownLinks(in: markdownString)
// If we have some matches, replace permalinks by a pill version.
guard !matches.isEmpty else { return markdownString }
let pillProvider = PillProvider(withSession: session,
eventFormatter: eventFormatter,
event: nil,
roomState: roomState,
andLatestRoomState: nil,
isEditMode: true)
let mutable = NSMutableAttributedString(attributedString: markdownString)
matches.reversed().forEach {
if let attachmentString = pillProvider.pillTextAttachmentString(forUrl: $0.url, withLabel: $0.label) {
mutable.replaceCharacters(in: $0.range, with: attachmentString)
}
}
return mutable
}
/// Creates a string with all pills of given attributed string replaced by display names.
///
/// - Parameters:
/// - attributedString: attributed string with pills
/// - mode: replacement mode for pills (default: displayname)
/// - Returns: string with display names
static func stringByReplacingPills(in attributedString: NSAttributedString,
mode: PillsReplacementTextMode = .displayname) -> String {
let newAttr = NSMutableAttributedString(attributedString: attributedString)
newAttr.vc_enumerateAttribute(.attachment) { (attachment: PillTextAttachment, range: NSRange, _) in
guard let data = attachment.data else {
return
}
let pillString: String
switch mode {
case .displayname:
pillString = data.displayText
case .identifier:
pillString = data.pillIdentifier
case .markdown:
pillString = data.markdown
}
newAttr.replaceCharacters(in: range, with: pillString)
}
return newAttr.string
}
/// Creates an attributed string containing a pill for given room member.
///
/// - Parameters:
/// - roomMember: the room member
/// - url: URL to room member profile. Should be provided to make pill act as a link.
/// - isHighlighted: true to indicate that the pill should be highlighted
/// - font: the text font
/// - Returns: attributed string with a pill attachment and an optional link
static func mentionPill(withRoomMember roomMember: MXRoomMember,
andUrl url: URL? = nil,
isHighlighted: Bool,
font: UIFont) -> NSAttributedString {
guard let attachment = PillTextAttachment(withRoomMember: roomMember, isHighlighted: isHighlighted, font: font) else {
return NSAttributedString(string: roomMember.displayname)
}
return attributedStringWithAttachment(attachment, link: url, font: font)
}
static func mentionPill(withUrl url: URL,
andLabel label: String,
session: MXSession,
eventFormatter: MXKEventFormatter,
roomState: MXRoomState) -> NSAttributedString? {
let pillProvider = PillProvider(withSession: session,
eventFormatter: eventFormatter,
event: nil,
roomState: roomState,
andLatestRoomState: nil,
isEditMode: true)
return pillProvider.pillTextAttachmentString(forUrl: url, withLabel: label)
}
/// Update alpha of all `PillTextAttachment` contained in given attributed string.
///
/// - Parameters:
/// - alpha: Alpha value to apply
/// - attributedString: Attributed string containing the pills
static func setPillAlpha(_ alpha: CGFloat, inAttributedString attributedString: NSAttributedString) {
attributedString.vc_enumerateAttribute(.attachment) { (pill: PillTextAttachment, range: NSRange, _) in
pill.data?.alpha = alpha
}
}
/// Refresh pills inside given attributed string.
///
/// - Parameters:
/// - attributedString: attributed string to update
/// - roomState: room state for refresh, should be the latest available
static func refreshPills(in attributedString: NSAttributedString, with roomState: MXRoomState) {
attributedString.vc_enumerateAttribute(.attachment) { (pill: PillTextAttachment, range: NSRange, _) in
switch pill.data?.pillType {
case .user(let userId):
guard let roomMember = roomState.members.member(withUserId: userId) else {
return
}
let displayName = roomMember.displayname ?? userId
pill.data?.items = [
.avatar(url: roomMember.avatarUrl,
string: displayName,
matrixId: userId),
.text(displayName)
]
default:
break
}
}
}
}
// MARK: - Private Methods
@available(iOS 15.0, *)
extension PillsFormatter {
struct MarkdownLinkResult: Equatable {
let url: URL
let label: String
let range: NSRange
}
static func markdownLinks(in attributedString: NSAttributedString) -> [MarkdownLinkResult] {
// Create a regexp that detects markdown links.
// Pattern source: https://gist.github.com/hugocf/66d6cd241eff921e0e02
let pattern = "\\[([^\\]]+)\\]\\(([^\\)\"\\s]+)(?:\\s+\"(.*)\")?\\)"
guard let regExp = try? NSRegularExpression(pattern: pattern) else { return [] }
let matches = regExp.matches(in: attributedString.string,
range: .init(location: 0, length: attributedString.length))
return matches.compactMap { match in
let labelRange = match.range(at: 1)
let urlRange = match.range(at: 2)
let label = attributedString.attributedSubstring(from: labelRange).string
var url = attributedString.attributedSubstring(from: urlRange).string
// Note: a valid markdown link can be written with
// enclosing <..>, remove them for userId detection.
if url.first == "<" && url.last == ">" {
url = String(url[url.index(after: url.startIndex)...url.index(url.endIndex, offsetBy: -2)])
}
if let url = URL(string: url) {
return MarkdownLinkResult(url: url, label: label, range: match.range)
} else {
return nil
}
}
}
static func attributedStringWithAttachment(_ attachment: PillTextAttachment, link: URL?, font: UIFont) -> NSAttributedString {
let string = NSMutableAttributedString(attachment: attachment)
string.addAttribute(.font, value: font, range: .init(location: 0, length: string.length))
if let url = link {
string.addAttribute(.link, value: url, range: .init(location: 0, length: string.length))
}
return string
}
}