TM-SGNL-iOS/SignalServiceKit/Messages/BodyRanges/EditableMessageBody.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

1003 lines
42 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
public protocol EditableMessageBodyDelegate: AnyObject {
func editableMessageBodyHydrator(tx: DBReadTransaction) -> MentionHydrator
func editableMessageSelectedRange() -> NSRange
func editableMessageBodyDidRequestNewSelectedRange(_ newSelectedRange: NSRange)
func editableMessageBodyDisplayConfig() -> HydratedMessageBody.DisplayConfiguration
func isEditableMessageBodyDarkThemeEnabled() -> Bool
// If this key changes, the cached mentions will be invalidated at read-time.
func mentionCacheInvalidationKey() -> String
}
public class EditableMessageBodyTextStorage: NSTextStorage {
public typealias Style = MessageBodyRanges.Style
public typealias SingleStyle = MessageBodyRanges.SingleStyle
/// Abstraction so callers can either provide an already-open transaction or allow
/// opening a new transaction.
public typealias ReadTxProvider = ((DBReadTransaction) -> Void) -> Void
// MARK: - Init
// DB reference so we can hydrate mentions.
private let db: any DB
public weak var editableBodyDelegate: EditableMessageBodyDelegate?
public init(
db: any DB
) {
self.db = db
super.init()
}
@available(*, unavailable)
required public init?(coder: NSCoder) {
owsFail("Use another initializer")
}
// MARK: - NSTextStorage
public override var string: String {
return body.hydratedText
}
public var naturalTextAlignment: NSTextAlignment {
guard body.hydratedText.isEmpty else {
return .natural
}
return body.hydratedText.naturalTextAlignment
}
public override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key: Any] {
return displayString.attributes(at: location, effectiveRange: range)
}
public override func replaceCharacters(in range: NSRange, with str: String) {
self.replaceCharacters(
in: range,
with: str,
selectedRange: editableBodyDelegate?.editableMessageSelectedRange()
?? NSRange(location: (body.hydratedText as NSString).length, length: 0)
)
}
public override func setAttributes(_ attrs: [NSAttributedString.Key: Any]?, range: NSRange) {
guard isFixingAttributes else {
// Don't allow external attribute setting except from
// fixing, which is applied for emojis.
return
}
displayString.setAttributes(attrs, range: range)
}
private var isFixingAttributes = false
public override func fixAttributes(in range: NSRange) {
isFixingAttributes = true
super.fixAttributes(in: range)
isFixingAttributes = false
}
private var isEditing = false
private var selectionAfterEdits: NSRange?
public override func beginEditing() {
super.beginEditing()
isEditing = true
self.selectionAfterEdits = nil
}
public override func endEditing() {
super.endEditing()
isEditing = false
DispatchQueue.main.async {
if let selectionAfterEdits = self.selectionAfterEdits {
self.selectionAfterEdits = nil
self.editableBodyDelegate?.editableMessageBodyDidRequestNewSelectedRange(selectionAfterEdits)
}
}
}
// MARK: - State Representation
internal struct Body: Equatable {
var hydratedText: String
var mentions: [NSRange: Aci]
var flattenedStyles: [NSRangedValue<SingleStyle>]
}
private var body = Body(hydratedText: "", mentions: [:], flattenedStyles: []) {
didSet {
cachedMessageBody = nil
}
}
private var displayString: NSMutableAttributedString = NSMutableAttributedString(string: "")
public var hydratedPlaintext: String {
return body.hydratedText
}
public var attributedString: NSAttributedString {
return displayString
}
// Unordered
public var mentionRanges: [NSRange] {
return body.mentions.keys.map({ $0 })
}
// MARK: - Making Updates
public func didUpdateTheming() {
let selectedRange = editableBodyDelegate?.editableMessageSelectedRange() ?? NSRange(location: displayString.length, length: 0)
regenerateDisplayString(
hydratedTextBeforeChange: body.hydratedText,
hydrator: makeMentionHydratorForCurrentBody(),
modifiedRange: NSRange(location: 0, length: (body.hydratedText as NSString).length),
selectedRangeAfterChange: selectedRange
)
}
/// Replace characters in the provided range with a plaintext string. The string will not
/// have any formatting properties applied, even if inserted in the middle of a formatted range.
/// If any change is made to a mention range, the mention will be removed (but its representation
/// as plaintext will persist).
public func replaceCharacters(in range: NSRange, with string: String, selectedRange: NSRange) {
replaceCharacters(
in: range,
with: string,
selectedRange: selectedRange,
forceIgnoreStylesInReplacedRange: false,
txProvider: db.readTxProvider
)
}
private func replaceCharacters(
in range: NSRange,
with string: String,
selectedRange: NSRange,
forceIgnoreStylesInReplacedRange: Bool,
txProvider: ReadTxProvider
) {
let string = string.removingPlaceholders()
let hydratedTextBeforeChange = body.hydratedText
let changeInLength = (string as NSString).length - range.length
var modifiedRange = range
// For append-only, we can efficiently update without recomputing anything.
if range.location == displayString.length, range.length == 0 {
self.efficientAppendText(string, range: range, changeInLength: changeInLength)
return
}
// If the change is within a mention, that mention is eliminated.
// Note that the hydrated text of the mention is preserved; its just plaintext now.
var intersectingMentionRanges = [NSRange]()
body.mentions.forEach { (mentionRange, mentionAci) in
if
// An insert, which can happen in the middle of a mention.
(range.length == 0 && mentionRange.contains(range.location) && range.location != mentionRange.location)
|| (mentionRange.intersection(range)?.length ?? 0) > 0
{
intersectingMentionRanges.append(mentionRange)
} else if range.upperBound <= mentionRange.location {
// If the change is before a mention, we have to shift the mention.
body.mentions[mentionRange] = nil
body.mentions[NSRange(location: mentionRange.location + changeInLength, length: mentionRange.length)] = mentionAci
}
}
if
string.isEmpty,
selectedRange.length <= 1,
let intersectingMentionRange = intersectingMentionRanges.first,
range.length == 1,
range.upperBound == intersectingMentionRange.upperBound
{
// Backspace at the end of a mention, just clear the whole mention minus the prefix.
self.replaceCharacters(in: intersectingMentionRange, with: Mention.prefix, selectedRange: selectedRange)
// Put the selection after the prefix so a new mention can be typed.
let newSelectedRange = NSRange(
location: intersectingMentionRange.location + (Mention.prefix as NSString).length,
length: 0
)
self.selectionAfterEdits = newSelectedRange
return
}
intersectingMentionRanges.forEach {
body.mentions.removeValue(forKey: $0)
modifiedRange.formUnion($0)
}
// Styles need updated ranges.
body.flattenedStyles = Self.updateFlattenedStyles(
body.flattenedStyles,
forReplacementOf: range,
with: string,
preserveStyleInReplacement: (!forceIgnoreStylesInReplacedRange && string.shouldContinueExistingStyle)
)
body.hydratedText = (body.hydratedText as NSString)
.replacingCharacters(in: range, with: string)
.removingPlaceholders()
regenerateDisplayString(
hydratedTextBeforeChange: hydratedTextBeforeChange,
hydrator: makeMentionHydrator(for: Array(self.body.mentions.values), txProvider: txProvider),
modifiedRange: modifiedRange,
selectedRangeAfterChange: nil
)
}
private func efficientAppendText(_ string: String, range: NSRange, changeInLength: Int) {
self.body.hydratedText = body.hydratedText + string
guard let editableBodyDelegate else {
owsFailDebug("Should have delegate")
self.displayString.append(string)
return
}
// See if there are styles to preserve.
var stylesToApply: Style?
if string.shouldContinueExistingStyle, range.location > 0 {
let indexToCheck = range.location - 1
for (i, style) in body.flattenedStyles.enumerated() {
if style.range.contains(indexToCheck) {
// Extend the existing style.
let newStyle = NSRangedValue<SingleStyle>(
style.value,
range: NSRange(
location: style.range.location,
length: style.range.length + range.length + changeInLength
)
)
// Safe to reinsert in place as its sorted by location,
// which didn't change as we only touched length.
body.flattenedStyles[i] = newStyle
if stylesToApply != nil {
stylesToApply?.insert(style: style.value)
} else {
stylesToApply = style.value.asStyle
}
}
}
}
let config = editableBodyDelegate.editableMessageBodyDisplayConfig()
let isDarkThemeEnabled = editableBodyDelegate.isEditableMessageBodyDarkThemeEnabled()
let stringToAppend: NSAttributedString
let editActions: NSTextStorage.EditActions
if let stylesToApply {
stringToAppend = StyleOnlyMessageBody(text: string, styles: stylesToApply).asAttributedStringForDisplay(
config: config.style,
textAlignment: string.nilIfEmpty?.naturalTextAlignment ?? .natural,
isDarkThemeEnabled: isDarkThemeEnabled
)
editActions = [.editedAttributes, .editedCharacters]
} else {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = string.nilIfEmpty?.naturalTextAlignment ?? .natural
stringToAppend = NSAttributedString(
string: string,
attributes: [
.font: config.mention.font,
.foregroundColor: config.baseTextColor.color(isDarkThemeEnabled: isDarkThemeEnabled),
.paragraphStyle: paragraphStyle
]
)
editActions = .editedCharacters
}
self.displayString.append(stringToAppend)
super.edited(editActions, range: range, changeInLength: changeInLength)
}
public func replaceCharacters(in range: NSRange, withMentionAci mentionAci: Aci, txProvider: ReadTxProvider) {
let hydrator = makeMentionHydrator(for: Array(body.mentions.values) + [mentionAci], txProvider: txProvider)
replaceCharacters(in: range, withMentionAci: mentionAci, hydrator: hydrator, insertSpaceAfter: true)
}
private func replaceCharacters(
in range: NSRange,
withMentionAci mentionAci: Aci,
hydrator: CacheMentionHydrator,
insertSpaceAfter: Bool
) {
let hydratedTextBeforeChange = body.hydratedText
var modifiedRange = range
let hydratedMention: String
switch hydrator.hydrator(mentionAci) {
case .hydrate(let mentionString):
if CurrentAppContext().isRTL {
hydratedMention = mentionString + Mention.prefix
} else {
hydratedMention = Mention.prefix + mentionString
}
case .preserveMention:
return
}
// If the change is within an existing mention, that mention is eliminated.
// Note that the hydrated text of the mention is preserved; its just plaintext now.
let intersectingMentionRanges = body.mentions.keys.filter { mentionRange in
if range.length == 0 {
// An insert, which can happen in the middle of a mention.
return mentionRange.contains(range.location)
} else {
return (mentionRange.intersection(range)?.length ?? 0) > 0
}
}
intersectingMentionRanges.forEach {
body.mentions.removeValue(forKey: $0)
modifiedRange.formUnion($0)
}
// Add a space after the inserted mention
let suffix = insertSpaceAfter ? " " : ""
let finalMentionText = hydratedMention + suffix
// Styles need updated ranges.
body.flattenedStyles = Self.updateFlattenedStyles(
body.flattenedStyles,
forReplacementOf: range,
with: finalMentionText,
preserveStyleInReplacement: true
)
body.hydratedText = (body.hydratedText as NSString).replacingCharacters(
in: range,
with: finalMentionText
).removingPlaceholders()
// Any space isn't included in the mention's range.
let mentionRange = NSRange(location: range.location, length: (hydratedMention as NSString).length)
body.mentions[mentionRange] = mentionAci
// Put the cursor after the space, if any
let newSelectedRange = NSRange(location: mentionRange.upperBound + (suffix as NSString).length, length: 0)
regenerateDisplayString(
hydratedTextBeforeChange: hydratedTextBeforeChange,
hydrator: hydrator,
modifiedRange: modifiedRange,
selectedRangeAfterChange: newSelectedRange
)
}
public func hasFormatting(in range: NSRange) -> Bool {
return body.flattenedStyles.contains(where: { ($0.range.intersection(range)?.length ?? 0) > 0 })
}
public func clearFormatting(in range: NSRange) {
// Check for overlaps with mentions; any styles we apply to a mention applies
// to the whole mention.
var range = range
for mentionRange in mentionRanges {
if let intersection = mentionRange.intersection(range), intersection.length > 0 {
range.formUnion(mentionRange)
}
}
let previouslySelectedRange = editableBodyDelegate?.editableMessageSelectedRange()
// Reverse order so we can modify indexes in the for loop and not hit problems.
for (i, style) in body.flattenedStyles.enumerated().reversed() {
guard style.range.upperBound > range.location else {
// We got past all relevant ranges, safe to stop now.
break
}
guard let intersection = style.range.intersection(range), intersection.length > 0 else {
continue
}
body.flattenedStyles.remove(at: i)
if range.location > style.range.location {
// Chop off the start of the existing range and reinsert it.
let newStyle = NSRangedValue(
style.value,
range: NSRange(
location: style.range.location,
length: range.location - style.range.location
)
)
insertStylePreservingSort(newStyle)
}
if range.upperBound < style.range.upperBound {
// Chop off the end of the existing range and reinsert it.
let newStyle = NSRangedValue(
style.value,
range: NSRange(
location: range.upperBound,
length: style.range.upperBound - range.upperBound
)
)
insertStylePreservingSort(newStyle)
}
}
let newSelectedRange: NSRange
if let previouslySelectedRange {
newSelectedRange = NSRange(location: previouslySelectedRange.upperBound, length: 0)
} else {
// Put it at the end.
newSelectedRange = NSRange(location: (body.hydratedText as NSString).length, length: 0)
}
regenerateDisplayString(
hydratedTextBeforeChange: body.hydratedText /* text doesn't change */,
hydrator: makeMentionHydratorForCurrentBody(),
modifiedRange: range,
selectedRangeAfterChange: newSelectedRange
)
}
public func toggleStyle(_ style: SingleStyle, in range: NSRange) {
toggleStyle(style, in: range, txProvider: db.readTxProvider)
}
private func toggleStyle(_ style: SingleStyle, in range: NSRange, txProvider: ReadTxProvider) {
let hydratedTextBeforeChange = body.hydratedText
// We want to put the selection at the end of the previously selected range.
let previouslySelectedRange = editableBodyDelegate?.editableMessageSelectedRange()
// Check for overlaps with mentions; any styles we apply to a mention applies
// to the whole mention.
var range = range
for mentionRange in mentionRanges {
if let intersection = mentionRange.intersection(range), intersection.length > 0 {
range.formUnion(mentionRange)
}
}
let newStyle = NSRangedValue<SingleStyle>(style, range: range)
let overlaps = NSRangedValue<SingleStyle>.overlaps(
of: newStyle,
in: self.body.flattenedStyles,
isEqual: ==
)
switch overlaps {
case .none(let insertionIndex):
// Easiest case; no overlaps so just insert as a new style.
body.flattenedStyles.insert(newStyle, at: insertionIndex)
case .withinExistingRange(let containingRangeIndex):
// Contained within one range, so we want to un-apply.
// Remove the existing range, then determine if there are any
// non-overlapping sections to chop off and reinsert.
let containingStyle = self.body.flattenedStyles[containingRangeIndex]
self.body.flattenedStyles.remove(at: containingRangeIndex)
if range.location > containingStyle.range.location {
// Chop off the start of the existing range and reinsert it.
let newStyle = NSRangedValue(
style,
range: NSRange(
location: containingStyle.range.location,
length: range.location - containingStyle.range.location
)
)
insertStylePreservingSort(newStyle)
}
if range.upperBound < containingStyle.range.upperBound {
// Chop off the end of the existing range and reinsert it.
let newStyle = NSRangedValue(
style,
range: NSRange(
location: range.upperBound,
length: containingStyle.range.upperBound - range.upperBound
)
)
insertStylePreservingSort(newStyle)
}
case .acrossExistingRanges(let overlapIndexes, let gaps):
let shouldUnapply: Bool
if gaps.isEmpty {
// If there are no gaps, we will un-apply.
shouldUnapply = true
} else {
// There are gaps. For some styles, we ignore whitespace gaps.
switch style {
case .strikethrough, .monospace, .spoiler:
// Styles visually apply to all gaps, so we should apply.
shouldUnapply = false
case .bold, .italic:
// Ignore gaps if they're all whitespace, so its like
// if we had no gaps.
shouldUnapply = gaps.allSatisfy({ gap in
return self.body.hydratedText.substring(withRange: gap).allSatisfy(\.isWhitespace)
})
}
}
if shouldUnapply {
// If unapplying, remove existing styles but be careful to keep
// any hanging head or tail sections.
var newRangesToInsert = [NSRangedValue<SingleStyle>]()
if let firstIndex = overlapIndexes.first {
// Chop off the start of the first overlapping range and reinsert it.
let existingRange = self.body.flattenedStyles[firstIndex]
let newStyle = NSRangedValue(
style,
range: NSRange(
location: existingRange.range.location,
length: range.location - existingRange.range.location
)
)
if newStyle.range.length > 0 {
newRangesToInsert.append(newStyle)
}
}
if let lastIndex = overlapIndexes.last {
// Chop off the end of the last overlapping range and reinsert it.
let existingRange = self.body.flattenedStyles[lastIndex]
let newStyle = NSRangedValue(
style,
range: NSRange(
location: range.upperBound,
length: existingRange.range.upperBound - range.upperBound
)
)
if newStyle.range.length > 0 {
newRangesToInsert.append(newStyle)
}
}
// Remove the overlaps.
for i in overlapIndexes.reversed() {
self.body.flattenedStyles.remove(at: i)
}
newRangesToInsert.forEach(insertStylePreservingSort(_:))
} else {
// If applying, merge all styles into one.
var mergedRange = range
for i in overlapIndexes.reversed() {
let existingRange = self.body.flattenedStyles.remove(at: i)
mergedRange.formUnion(existingRange.range)
}
insertStylePreservingSort(.init(style, range: mergedRange))
}
}
let newSelectedRange: NSRange
if let previouslySelectedRange {
newSelectedRange = NSRange(location: previouslySelectedRange.upperBound, length: 0)
} else {
// Put it at the end.
newSelectedRange = NSRange(location: (body.hydratedText as NSString).length, length: 0)
}
regenerateDisplayString(
hydratedTextBeforeChange: hydratedTextBeforeChange,
hydrator: makeMentionHydrator(for: Array(self.body.mentions.values), txProvider: txProvider),
modifiedRange: range,
selectedRangeAfterChange: newSelectedRange
)
}
/// Be careful using this method; styles cannot overlap with styles of the same type and that
/// invariant must be enforced by callers of this method.
private func insertStylePreservingSort(_ newStyle: NSRangedValue<SingleStyle>) {
var low = self.body.flattenedStyles.startIndex
var high = self.body.flattenedStyles.endIndex
while low != high {
let mid = self.body.flattenedStyles.index(
low,
offsetBy: self.body.flattenedStyles.distance(from: low, to: high) / 2
)
let element = self.body.flattenedStyles[mid]
if newStyle.range.location == element.range.location {
// Good insertion point; we can stop
self.body.flattenedStyles.insert(newStyle, at: mid)
return
} else if newStyle.range.location > element.range.location {
low = self.body.flattenedStyles.index(after: mid)
} else {
high = mid
}
}
self.body.flattenedStyles.insert(newStyle, at: low)
}
public func replaceCharacters(in range: NSRange, withPastedMessageBody messageBody: MessageBody, txProvider: ReadTxProvider) {
let hydrator = self.makeMentionHydrator(for: Array(messageBody.ranges.mentions.values), txProvider: txProvider)
let hydrated = messageBody.hydrating(mentionHydrator: hydrator.hydrator)
let insertedBody = hydrated.asEditableMessageBody()
// First replace with plaintext, then apply the styles and mentions.
self.replaceCharacters(
in: range,
with: insertedBody.hydratedText,
selectedRange: range,
forceIgnoreStylesInReplacedRange: true,
txProvider: txProvider
)
for mention in insertedBody.mentions {
self.replaceCharacters(
in: NSRange(location: range.location + mention.key.location, length: mention.key.length),
withMentionAci: mention.value,
hydrator: hydrator,
insertSpaceAfter: false
)
}
for style in insertedBody.flattenedStyles {
self.toggleStyle(
style.value,
in: NSRange(location: range.location + style.range.location, length: style.range.length),
txProvider: txProvider
)
}
let hydratedTextBeforeChange = body.hydratedText
let wholeBodyHydrator = makeMentionHydrator(for: Array(self.body.mentions.values), txProvider: txProvider)
// Put the range at the very end.
let newSelectedRange = NSRange(location: range.location + (insertedBody.hydratedText as NSString).length, length: 0)
self.regenerateDisplayString(
hydratedTextBeforeChange: hydratedTextBeforeChange,
hydrator: wholeBodyHydrator,
modifiedRange: range,
selectedRangeAfterChange: newSelectedRange
)
}
/// If `preserveStyleInReplacement` is true, any styles existing
/// in the first character of the range being replaced will be applied to the
/// entirety of the new text.
/// For replacement ranges of length 0 (aka insertions), we look a the style
/// on the preceding character and extend it.
/// Only false if inserting a copy-pasted MessageBody that has styles
/// of its own.
private static func updateFlattenedStyles(
_ flattenedStyles: [NSRangedValue<SingleStyle>],
forReplacementOf range: NSRange,
with string: String,
preserveStyleInReplacement: Bool
) -> [NSRangedValue<SingleStyle>] {
let stringLength = (string as NSString).length
let changeLengthDiff = stringLength - range.length
var finalStyles = [NSRangedValue<SingleStyle>]()
func appendToFinalStyles(_ style: NSRangedValue<SingleStyle>) {
guard style.range.length > 0 else {
return
}
finalStyles.append(style)
}
let targetIndexForPreservation: Int?
if range.length == 0 {
if range.location > 0 {
targetIndexForPreservation = range.location - 1
} else {
targetIndexForPreservation = nil
}
} else {
targetIndexForPreservation = range.location
}
for style in flattenedStyles {
if
preserveStyleInReplacement,
let targetIndexForPreservation,
style.range.contains(targetIndexForPreservation)
{
// We should preserve this style, and apply it to the entire new range.
let newLength =
range.location - style.range.location /* part before the range start */
+ range.length + changeLengthDiff /* applies to the entire range */
+ max(0, style.range.upperBound - range.upperBound) /* part after the end, if any */
appendToFinalStyles(.init(
style.value,
range: NSRange(
location: style.range.location,
length: newLength
)
))
} else if style.range.upperBound <= range.location {
// Its before the changed region, no changes needed.
appendToFinalStyles(style)
} else if style.range.location >= range.upperBound {
// Its after the changed region, just update the location.
appendToFinalStyles(.init(
style.value,
range: NSRange(
location: style.range.location + changeLengthDiff,
length: style.range.length
)
))
} else if style.range.location >= range.location, style.range.upperBound <= range.upperBound {
// Total overlap; the range being replaced fully contains the existing style.
// But we already determined this style _isn't_ to be preserved since this
// is an "else" after the first "if". So we can skip this style.
continue
} else if style.range.location < range.location, style.range.upperBound > range.upperBound {
// The style contains the changed range. We have to split it two on either side of the eliminated region.
appendToFinalStyles(.init(
style.value,
range: NSRange(
location: style.range.location,
length: range.location - style.range.location
)
))
appendToFinalStyles(.init(
style.value,
range: NSRange(
location: range.upperBound + changeLengthDiff,
length: style.range.upperBound - range.upperBound
)
))
} else if style.range.location < range.location {
// The style hangs off the start of the affected range.
// Slice off the overlapping bit and keep the start.
appendToFinalStyles(.init(
style.value,
range: NSRange(
location: style.range.location,
length: range.location - style.range.location
)
))
} else {
// The style hangs off the end of the affected range.
// Slice off the overlapping bit and keep the end.
appendToFinalStyles(.init(
style.value,
range: NSRange(
location: range.upperBound + changeLengthDiff,
length: style.range.upperBound - range.upperBound
)
))
}
}
return finalStyles
}
// MARK: - MessageBody
public var messageBody: MessageBody { return getOrMakeMessageBody() }
public func messageBody(forHydratedTextSubrange subrange: NSRange) -> MessageBody {
return Self.makeMessageBody(body: self.body, subrange: subrange)
}
public func setMessageBody(_ messageBody: MessageBody?, txProvider: ReadTxProvider) {
let hydratedTextBeforeChange = body.hydratedText
let messageBody = messageBody ?? MessageBody(text: "", ranges: .empty)
let hydrator = self.makeMentionHydrator(for: Array(messageBody.ranges.mentions.values), txProvider: txProvider)
let hydrated = messageBody.hydrating(mentionHydrator: hydrator.hydrator)
self.body = hydrated.asEditableMessageBody()
// While this could open a _second_ transaction, in practice it won't because
// we have the cached values from the hydator above
regenerateDisplayString(
hydratedTextBeforeChange: hydratedTextBeforeChange,
hydrator: hydrator,
modifiedRange: NSRange(location: 0, length: (hydratedTextBeforeChange as NSString).length),
selectedRangeAfterChange: NSRange(location: (body.hydratedText as NSString).length, length: 0)
)
// Immediately apply any selection changes; otherwise the selected range
// may end up with out of range values.
if let selectionAfterEdits = self.selectionAfterEdits {
self.selectionAfterEdits = nil
self.editableBodyDelegate?.editableMessageBodyDidRequestNewSelectedRange(selectionAfterEdits)
}
}
// Constructing this is expensive and is used as input to the displayed string. Cache it.
private var cachedMessageBody: MessageBody?
private func getOrMakeMessageBody() -> MessageBody {
Self.getOrMakeMessageBody(cache: &cachedMessageBody, body: body)
}
private static func getOrMakeMessageBody(cache: inout MessageBody?, body: Body) -> MessageBody {
if let cache {
return cache
}
let body = makeMessageBody(body: body, subrange: nil)
cache = body
return body
}
// Note: subrange is denoted in the _hydrated_ text, not in the final
// message body text after un-hydrating.
private static func makeMessageBody(body: Body, subrange: NSRange?) -> MessageBody {
// Un-hydrate the mentions first.
var text: NSString = (body.hydratedText as NSString)
var flattenedStyles = body.flattenedStyles
if let subrange {
text = text.substring(with: subrange) as NSString
flattenedStyles = flattenedStyles.compactMap { flattenedStyle in
guard
let intersection = flattenedStyle.range.intersection(subrange),
intersection.length > 0
else {
return nil
}
return .init(
flattenedStyle.value,
range: NSRange(
location: intersection.location - subrange.location,
length: intersection.length
)
)
}
}
let orderedMentions: [NSRangedValue<Aci>] = body.mentions.lazy
.compactMap({ (range: NSRange, aci: Aci) -> NSRangedValue<Aci>? in
guard let subrange else {
return .init(aci, range: range)
}
guard
let intersection = range.intersection(subrange),
// We need total overlap or we won't preserve the mention.
intersection.length == range.length
else {
return nil
}
return .init(
aci,
range: NSRange(
location: intersection.location - subrange.location,
length: intersection.length
)
)
})
.sorted(by: {
return $0.range.location < $1.range.location
})
let mentionPlaceholderLength = (MessageBody.mentionPlaceholder as NSString).length
var finalMentions = [NSRange: Aci]()
var mentionOffset = 0
for mention in orderedMentions {
let effectiveRange = NSRange(location: mention.range.location + mentionOffset, length: mention.range.length)
text = text.replacingCharacters(in: effectiveRange, with: MessageBody.mentionPlaceholder) as NSString
finalMentions[NSRange(location: effectiveRange.location, length: mentionPlaceholderLength)] = mention.value
flattenedStyles = Self.updateFlattenedStyles(
flattenedStyles,
forReplacementOf: effectiveRange,
with: MessageBody.mentionPlaceholder,
preserveStyleInReplacement: true
)
mentionOffset += mentionPlaceholderLength - mention.range.length
}
return MessageBody(
text: text as String,
ranges: MessageBodyRanges(
mentions: finalMentions,
styles: flattenedStyles
)
)
}
private func regenerateDisplayString(
hydratedTextBeforeChange: String,
hydrator: CacheMentionHydrator,
modifiedRange: NSRange,
selectedRangeAfterChange: NSRange?
) {
guard let editableBodyDelegate else {
owsFailDebug("Should have delegate")
return
}
let config = editableBodyDelegate.editableMessageBodyDisplayConfig()
let isDarkThemeEnabled = editableBodyDelegate.isEditableMessageBodyDarkThemeEnabled()
let displayString = getOrMakeMessageBody()
.hydrating(mentionHydrator: hydrator.hydrator, filterStringForDisplay: false)
.asAttributedStringForDisplay(
config: config,
textAlignment: hydratedPlaintext.nilIfEmpty?.naturalTextAlignment ?? .natural,
isDarkThemeEnabled: isDarkThemeEnabled
)
self.displayString = (displayString as? NSMutableAttributedString) ?? NSMutableAttributedString(attributedString: displayString)
self.fixAttributes(in: NSRange(location: 0, length: displayString.length))
let changeInLength = (body.hydratedText as NSString).length - (hydratedTextBeforeChange as NSString).length
super.edited(
body.hydratedText != hydratedTextBeforeChange ? [.editedCharacters, .editedAttributes] : .editedAttributes,
range: modifiedRange,
changeInLength: changeInLength
)
self.selectionAfterEdits = selectedRangeAfterChange
if !isEditing, let selectedRangeAfterChange {
self.selectionAfterEdits = nil
editableBodyDelegate.editableMessageBodyDidRequestNewSelectedRange(selectedRangeAfterChange)
}
}
// MARK: - Hydrating
private var mentionCacheKey: String?
private var mentionCache = [Aci: String]()
private var skippedMentionAcis = Set<Aci>()
/// This object represents the results of already having opened, and finished with, a
/// transaction to read mention hydrated names. We cache the results, put them in this
/// object, and make them available for reading without needing to open a new transaction.
///
/// Cache mention hydration results so we don't constantly fetch; we avoid even opening
/// a transaction until we absolutely have to.
/// Note that if this gets out of sync with the DB because some contact name changes that's ultimately fine;
/// we un-hydrate mentions before we send them so this state is only for display of the message being composed.
class CacheMentionHydrator {
private let mentionCache: [Aci: String]
init(mentionCache: [Aci: String]) {
self.mentionCache = mentionCache
}
var hydrator: MentionHydrator {
return { [mentionCache] aci in
guard let mentionString = mentionCache[aci] else {
return .preserveMention
}
return .hydrate(mentionString)
}
}
}
private func makeMentionHydratorForCurrentBody() -> CacheMentionHydrator {
return makeMentionHydrator(for: Array(self.body.mentions.values), txProvider: db.readTxProvider)
}
private func makeMentionHydrator(for mentions: [Aci], txProvider: ReadTxProvider) -> CacheMentionHydrator {
var mentionCache: [Aci: String]
if let mentionCacheKey, mentionCacheKey == editableBodyDelegate?.mentionCacheInvalidationKey() {
mentionCache = self.mentionCache
} else {
self.mentionCache = [:]
mentionCache = [:]
}
// If all mentions are in the cache, no need to recompute.
if !mentions.allSatisfy({ mentionCache[$0] != nil || skippedMentionAcis.contains($0) }) {
// If any are missing, we have to open a transaction and put them in the cache.
txProvider { tx in
let hydrator = editableBodyDelegate?.editableMessageBodyHydrator(tx: tx) ?? ContactsMentionHydrator.mentionHydrator(transaction: tx)
mentions.forEach { aci in
switch hydrator(aci) {
case .hydrate(let hydratedString):
mentionCache[aci] = hydratedString
case .preserveMention:
skippedMentionAcis.insert(aci)
}
}
}
}
self.mentionCache = mentionCache
self.mentionCacheKey = editableBodyDelegate?.mentionCacheInvalidationKey()
return .init(mentionCache: mentionCache)
}
}
extension DB {
public var readTxProvider: EditableMessageBodyTextStorage.ReadTxProvider {
return { self.read(block: $0) }
}
}
extension SDSDatabaseStorage {
public var readTxProvider: EditableMessageBodyTextStorage.ReadTxProvider {
return { block in self.read(block: { block($0.asV2Read) }) }
}
}
extension String {
fileprivate func removingPlaceholders() -> String {
return (self as NSString).replacingOccurrences(of: "\u{fffc}", with: "")
}
// We preserve style as long as we are not adding a single whitespace.
fileprivate var shouldContinueExistingStyle: Bool {
return !(self.count == 1 && self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}