287 lines
11 KiB
Swift
287 lines
11 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
|
|
@objcMembers
|
|
public class Mention: NSObject {
|
|
public static let mentionPrefix = MessageBodyRanges.mentionPrefix
|
|
public static let mentionPrefixLength = (mentionPrefix as NSString).length
|
|
|
|
public static let attributeKey = NSAttributedString.Key.mention
|
|
|
|
// Each mention has a uniqueID so we can differentiate
|
|
// two mentions for the same address that are side-by-side
|
|
public let uniqueId = UUID().uuidString
|
|
public let address: SignalServiceAddress
|
|
|
|
public let style: Style
|
|
@objc(MentionStyle)
|
|
public enum Style: Int {
|
|
case incoming
|
|
case outgoing
|
|
case composingAttachment
|
|
case quotedReply
|
|
case longMessageView
|
|
case groupReply
|
|
|
|
public static var composing: Self = .incoming
|
|
}
|
|
|
|
public let text: String
|
|
public var length: Int { (text as NSString).length }
|
|
|
|
public class func withSneakyTransaction(address: SignalServiceAddress, style: Style) -> Mention {
|
|
databaseStorage.read { transaction in
|
|
Mention(address: address, style: style, transaction: transaction.unwrapGrdbRead)
|
|
}
|
|
}
|
|
|
|
public convenience init(address: SignalServiceAddress, style: Style, transaction: GRDBReadTransaction) {
|
|
let displayName = Self.contactsManager.displayName(
|
|
for: address,
|
|
transaction: transaction.asAnyRead
|
|
)
|
|
self.init(
|
|
address: address,
|
|
style: style,
|
|
text: Self.mentionPrefix + displayName
|
|
)
|
|
}
|
|
|
|
private init(address: SignalServiceAddress, style: Style, text: String) {
|
|
self.address = address
|
|
self.style = style
|
|
self.text = text
|
|
}
|
|
|
|
public var attributedString: NSAttributedString { NSAttributedString(string: text, attributes: attributes) }
|
|
|
|
public var attributes: [NSAttributedString.Key: Any] {
|
|
var attributes: [NSAttributedString.Key: Any] = [
|
|
.mention: self,
|
|
.font: UIFont.dynamicTypeBody
|
|
]
|
|
|
|
switch style {
|
|
case .incoming:
|
|
attributes[.backgroundColor] = Theme.isDarkThemeEnabled ? UIColor.ows_gray60 : UIColor.ows_gray20
|
|
attributes[.foregroundColor] = ConversationStyle.bubbleTextColorIncoming
|
|
case .outgoing:
|
|
attributes[.backgroundColor] = UIColor(white: 0, alpha: 0.25)
|
|
attributes[.foregroundColor] = ConversationStyle.bubbleTextColorOutgoing
|
|
case .composingAttachment:
|
|
attributes[.backgroundColor] = UIColor.ows_gray75
|
|
attributes[.foregroundColor] = Theme.darkThemePrimaryColor
|
|
case .quotedReply:
|
|
attributes[.backgroundColor] = nil
|
|
attributes[.foregroundColor] = Theme.primaryTextColor
|
|
case .longMessageView:
|
|
attributes[.backgroundColor] = Theme.isDarkThemeEnabled ? UIColor.ows_signalBlueDark : UIColor.ows_blackAlpha20
|
|
attributes[.foregroundColor] = Theme.primaryTextColor
|
|
case .groupReply:
|
|
attributes[.backgroundColor] = UIColor.ows_gray60
|
|
attributes[.foregroundColor] = UIColor.ows_gray05
|
|
}
|
|
|
|
return attributes
|
|
}
|
|
|
|
override public func isEqual(_ object: Any?) -> Bool {
|
|
guard let other = object as? Mention else { return false }
|
|
return other.uniqueId == uniqueId
|
|
}
|
|
override public var hash: Int { uniqueId.hashValue }
|
|
|
|
public class func threadAllowsMentionSend(_ thread: TSThread) -> Bool {
|
|
guard let groupThread = thread as? TSGroupThread else { return false }
|
|
return groupThread.groupModel.groupsVersion == .V2
|
|
}
|
|
|
|
@objc(refreshAttributedInMutableAttributedString:)
|
|
public class func refreshAttributes(in mutableAttributedString: NSMutableAttributedString) {
|
|
mutableAttributedString.enumerateMentionsAndStyles { mention, _, subrange, _ in
|
|
guard let mention = mention else { return }
|
|
mutableAttributedString.addAttributes(mention.attributes, range: subrange)
|
|
}
|
|
}
|
|
|
|
@objc(updateWithStyle:inMutableAttributedString:)
|
|
public class func updateWithStyle(_ style: Style, in mutableAttributedString: NSMutableAttributedString) {
|
|
mutableAttributedString.enumerateMentionsAndStyles { mention, _, subrange, _ in
|
|
guard let mention = mention else { return }
|
|
let restyledMention = Mention(address: mention.address, style: style, text: mention.text)
|
|
mutableAttributedString.addAttributes(restyledMention.attributes, range: subrange)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension NSAttributedString.Key {
|
|
public static let mention = NSAttributedString.Key("Mention")
|
|
}
|
|
|
|
extension MessageBody {
|
|
public convenience init(attributedString: NSAttributedString) {
|
|
var mentions = [NSRange: UUID]()
|
|
var styles = [(NSRange, MessageBodyRanges.Style)]()
|
|
|
|
let filteredAttributedString = attributedString.filterForDisplay
|
|
let mutableAttributedString = NSMutableAttributedString(attributedString: filteredAttributedString)
|
|
|
|
mutableAttributedString.enumerateMentionsAndStyles { mention, style, subrange, _ in
|
|
if let mention {
|
|
// This string may not be a full mention, for example we may
|
|
// have copied a string that only selects part of a mention.
|
|
// We only want to treat it as a mention if we have the full
|
|
// thing.
|
|
guard subrange.length == mention.length else { return }
|
|
|
|
mutableAttributedString.replaceCharacters(in: subrange, with: Self.mentionPlaceholder)
|
|
|
|
let placeholderRange = NSRange(
|
|
location: subrange.location,
|
|
length: (Self.mentionPlaceholder as NSString).length
|
|
)
|
|
mentions[placeholderRange] = mention.address.uuid
|
|
}
|
|
if let style {
|
|
styles.append((subrange, style))
|
|
}
|
|
}
|
|
|
|
self.init(text: mutableAttributedString.string, ranges: .init(mentions: mentions, styles: styles))
|
|
}
|
|
|
|
public func textValue(style: Mention.Style,
|
|
attributes: [NSAttributedString.Key: Any],
|
|
shouldResolveAddress: (SignalServiceAddress) -> Bool,
|
|
transaction: GRDBReadTransaction) -> CVTextValue {
|
|
ranges.textValue(text: text,
|
|
style: style,
|
|
attributes: attributes,
|
|
shouldResolveAddress: shouldResolveAddress,
|
|
transaction: transaction)
|
|
}
|
|
|
|
@objc
|
|
public func attributedBody(
|
|
style: Mention.Style,
|
|
attributes: [NSAttributedString.Key: Any],
|
|
shouldResolveAddress: (SignalServiceAddress) -> Bool,
|
|
transaction: GRDBReadTransaction
|
|
) -> NSAttributedString {
|
|
return ranges.attributedBody(
|
|
text: text,
|
|
style: style,
|
|
attributes: attributes,
|
|
shouldResolveAddress: shouldResolveAddress,
|
|
transaction: transaction
|
|
)
|
|
}
|
|
}
|
|
|
|
extension MessageBodyRanges {
|
|
|
|
public func textValue(text: String,
|
|
style: Mention.Style,
|
|
attributes: [NSAttributedString.Key: Any],
|
|
shouldResolveAddress: (SignalServiceAddress) -> Bool,
|
|
transaction: GRDBReadTransaction) -> CVTextValue {
|
|
|
|
guard hasRanges || !attributes.isEmpty else {
|
|
return .text(text: text)
|
|
}
|
|
let attributedText = attributedBody(text: text,
|
|
style: style,
|
|
attributes: attributes,
|
|
shouldResolveAddress: shouldResolveAddress,
|
|
transaction: transaction)
|
|
return .attributedText(attributedText: attributedText)
|
|
}
|
|
|
|
/// Note: this method does _not_ apply styles; instead it just sets the `.owsStyle`
|
|
/// attribute for any styles, and it is up to callers to apply those styles using
|
|
/// `MessageBodyRanges.applyStyleAttributes` with the appropriate font and themed color.
|
|
@objc
|
|
public func attributedBody(
|
|
text: String,
|
|
style: Mention.Style,
|
|
attributes: [NSAttributedString.Key: Any],
|
|
shouldResolveAddress: (SignalServiceAddress) -> Bool,
|
|
transaction: GRDBReadTransaction
|
|
) -> NSAttributedString {
|
|
guard hasRanges else { return NSAttributedString(string: text, attributes: attributes) }
|
|
|
|
var mentions = [UUID: Mention]()
|
|
for (_, uuid) in orderedMentions {
|
|
mentions[uuid] = mentions[uuid] ?? Mention(
|
|
address: SignalServiceAddress(uuid: uuid),
|
|
style: style,
|
|
transaction: transaction
|
|
)
|
|
}
|
|
|
|
let mutableText = NSMutableAttributedString(string: text, attributes: attributes)
|
|
self.hydrateMentionsAndSetStyleAttributes(
|
|
on: mutableText,
|
|
hydrator: { mentionUuid in
|
|
guard let mention = mentions[mentionUuid] else {
|
|
return .preserveMention
|
|
}
|
|
if shouldResolveAddress(mention.address) {
|
|
return .hydrateAttributed(mention.attributedString, alreadyIncludesPrefix: true)
|
|
} else {
|
|
return .hydrate(mention.text, alreadyIncludesPrefix: true)
|
|
}
|
|
}
|
|
)
|
|
|
|
return NSAttributedString(attributedString: mutableText)
|
|
}
|
|
}
|
|
|
|
extension NSAttributedString {
|
|
public func enumerateMentionsAndStyles(
|
|
in range: NSRange? = nil,
|
|
handler: (Mention?, MessageBodyRanges.Style?, NSRange, UnsafeMutablePointer<ObjCBool>) -> Void
|
|
) {
|
|
enumerateAttributes(
|
|
in: range ?? NSRange(location: 0, length: length),
|
|
options: []
|
|
) { attributes, range, stop in
|
|
let mention = attributes[.mention] as? Mention
|
|
let style = attributes[.owsStyle] as? MessageBodyRanges.Style
|
|
handler(mention, style, range, stop)
|
|
}
|
|
}
|
|
|
|
// This is private because it's *only* safe to use on mention/style attributed strings.
|
|
fileprivate var filterForDisplay: NSAttributedString {
|
|
guard length > 0 else { return self }
|
|
|
|
if string.ows_stripped().isEmpty { return NSAttributedString(string: "") }
|
|
|
|
let mutableString = NSMutableAttributedString(attributedString: self)
|
|
|
|
// Filter each non-mention, non-style substring
|
|
|
|
mutableString.enumerateMentionsAndStyles { mention, style, subrange, _ in
|
|
guard mention == nil && style == nil else { return }
|
|
|
|
let string = mutableString.attributedSubstring(from: subrange).string
|
|
let attributes = mutableString.attributes(at: subrange.location, effectiveRange: nil)
|
|
|
|
mutableString.replaceCharacters(
|
|
in: subrange,
|
|
with: NSAttributedString(string: string.filterSubstringForDisplay(), attributes: attributes)
|
|
)
|
|
}
|
|
|
|
// Strip the resulting string
|
|
mutableString.ows_strip()
|
|
|
|
return NSAttributedString(attributedString: mutableString)
|
|
}
|
|
}
|