Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Messages/BodyRanges/EditableMessageBody.swift
1 views
//
// 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

    func didInsertMemoji(_ memojiGlyph: OWSAdaptiveImageGlyph)
}

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)
    public required init?(coder: NSCoder) {
        owsFail("Use another initializer")
    }

    // MARK: - NSTextStorage

    override public var string: String {
        return body.hydratedText
    }

    public var naturalTextAlignment: NSTextAlignment {
        guard body.hydratedText.isEmpty else {
            return .natural
        }
        return body.hydratedText.naturalTextAlignment
    }

    override public func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key: Any] {
        return displayString.attributes(at: location, effectiveRange: range)
    }

    override public 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),
        )
    }

    override public func setAttributes(_ attrs: [NSAttributedString.Key: Any]?, range: NSRange) {
        // If we get any memoji attributes, remove them and pass them up to the delegate.
        var attrs = attrs
        if let memojiGlyph = OWSAdaptiveImageGlyph.remove(from: &attrs) {
            editableBodyDelegate?.didInsertMemoji(memojiGlyph)
        }
        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

    override public func fixAttributes(in range: NSRange) {
        isFixingAttributes = true
        super.fixAttributes(in: range)
        isFixingAttributes = false
    }

    private var isEditing = false

    private var selectionAfterEdits: NSRange?

    override public func beginEditing() {
        super.beginEditing()
        isEditing = true
        self.selectionAfterEdits = nil
    }

    override public func endEditing() {
        super.endEditing()
        isEditing = false
        DispatchQueue.main.async {
            if let selectionAfterEdits = self.selectionAfterEdits {
                self.selectionAfterEdits = nil
                self.editableBodyDelegate?.editableMessageBodyDidRequestNewSelectedRange(selectionAfterEdits)
            }
        }
    }

    // MARK: - State Representation

    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)
            }
        }
        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.editableBodyDelegate?.editableMessageBodyDidRequestNewSelectedRange(newSelectedRange)
            return
        }

        body.mentions.forEach { mentionRange, mentionAci in
            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
            }
        }

        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()

        // If the new mention is before the already existing mentions, we have to shift the existing mentions.
        body.mentions.forEach { mentionRange, mentionAci in
            if range.upperBound <= mentionRange.location {
                // Since the user may have already typed out part of the mention, we should remove
                // range.length from the final location to avoid double counting those letters.
                let newMentionLowerBound = mentionRange.location - range.length

                body.mentions[mentionRange] = nil
                body.mentions[NSRange(location: newMentionLowerBound + (finalMentionText as NSString).length, length: mentionRange.length)] = mentionAci
            }
        }

        // 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 removeFormatting(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) }) }
    }
}

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)
    }
}