Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/util/DisplayableText.swift
1 views
//
// Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import SignalServiceKit
public import SignalUI

public class DisplayableText: NSObject {

    private struct Content {
        let textValue: CVTextValue
        let naturalAlignment: NSTextAlignment
    }

    private let _fullContent: AtomicValue<Content>
    private var fullContent: Content {
        get { _fullContent.get() }
        set { _fullContent.set(newValue) }
    }

    private let _truncatedContent = AtomicOptional<Content>(nil, lock: .sharedGlobal)
    private var truncatedContent: Content? {
        get { _truncatedContent.get() }
        set { _truncatedContent.set(newValue) }
    }

    public var fullTextValue: CVTextValue {
        fullContent.textValue
    }

    public var truncatedTextValue: CVTextValue? {
        truncatedContent?.textValue
    }

    public var displayTextValue: CVTextValue {
        return truncatedContent?.textValue ?? fullContent.textValue
    }

    public func textValue(isTextExpanded: Bool) -> CVTextValue {
        if isTextExpanded {
            return fullTextValue
        } else {
            return displayTextValue
        }
    }

    public var fullTextNaturalAlignment: NSTextAlignment {
        return fullContent.naturalAlignment
    }

    @objc
    public var displayTextNaturalAlignment: NSTextAlignment {
        return truncatedContent?.naturalAlignment ?? fullContent.naturalAlignment
    }

    public var isTextTruncated: Bool {
        return truncatedContent != nil
    }

    private static let maxInlineRenderingSizeEstimate = 1024 * 8

    public var canRenderTruncatedTextInline: Bool {
        return isTextTruncated && renderingSizeEstimate <= Self.maxInlineRenderingSizeEstimate
    }

    public let renderingSizeEstimate: Int

    public let jumbomojiCount: UInt

    public static let kMaxJumbomojiCount: Int = 5

    static let truncatedTextSuffix: String = "…"

    // MARK: Initializers

    private init(fullContent: Content, truncatedContent: Content?) {
        self._fullContent = AtomicValue(fullContent, lock: .sharedGlobal)
        self._truncatedContent.set(truncatedContent)
        switch fullContent.textValue {
        case .text(let text):
            self.jumbomojiCount = DisplayableText.jumbomojiCount(in: text)
            self.renderingSizeEstimate = DisplayableText.renderingSizeEstimate(of: text)
        case .attributedText(let attributedText):
            self.jumbomojiCount = DisplayableText.jumbomojiCount(in: attributedText.string)
            self.renderingSizeEstimate = DisplayableText.renderingSizeEstimate(of: attributedText.string)
        case .messageBody(let messageBody):
            self.jumbomojiCount = messageBody.jumbomojiCount(DisplayableText.jumbomojiCount(in:))
            self.renderingSizeEstimate = messageBody.renderingSizeEstimate(DisplayableText.renderingSizeEstimate(of:))
        }

        super.init()
    }

#if TESTABLE_BUILD
    static func testOnlyInit(fullContent: CVTextValue, truncatedContent: CVTextValue?) -> DisplayableText {
        return DisplayableText(
            fullContent: .init(textValue: fullContent, naturalAlignment: fullContent.naturalTextAligment),
            truncatedContent: truncatedContent.map { .init(textValue: $0, naturalAlignment: $0.naturalTextAligment) },
        )
    }
#endif

    // MARK: Emoji

    // If the string...
    //
    // * Is not empty
    // * Contains only emoji
    // * Contains <= kMaxJumbomojiCount emoji
    //
    // ...return the number of emoji (to be treated as "Jumbomoji") in the string.
    private class func jumbomojiCount(in string: String) -> UInt {
        guard string.containsOnlyEmojiIgnoringWhitespace else {
            return 0
        }
        let emojiCount = string.removeCharacters(characterSet: .whitespacesAndNewlines).count
        if emojiCount > kMaxJumbomojiCount {
            return 0
        }
        return UInt(emojiCount)
    }

    // For perf we use a static linkDetector. It doesn't change and building DataDetectors is
    // surprisingly expensive. This should be fine, since NSDataDetector is an NSRegularExpression
    // and NSRegularExpressions are thread safe.
    private static let linkDetector: NSDataDetector? = {
        return try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
    }()

    private static let hostRegex: NSRegularExpression? = {
        let pattern = "^(?:https?:\\/\\/)?([^:\\/\\s]+)(.*)?$"
        return try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
    }()

    public lazy var shouldAllowLinkification: Bool = {
        guard let linkDetector: NSDataDetector = DisplayableText.linkDetector else {
            owsFailDebug("linkDetector was unexpectedly nil")
            return false
        }

        func isValidLink(linkText: String) -> Bool {
            guard LinkValidator.isValidLink(linkText: linkText) else {
                return false
            }
            guard let hostRegex = DisplayableText.hostRegex else {
                owsFailDebug("hostRegex was unexpectedly nil")
                return false
            }

            guard let hostText = hostRegex.parseFirstMatch(inText: linkText) else {
                owsFailDebug("hostText was unexpectedly nil")
                return false
            }

            let strippedHost = hostText.replacingOccurrences(of: ".", with: "")

            if strippedHost.isOnlyASCII() {
                return true
            } else if strippedHost.hasAnyASCII() {
                // mix of ascii and non-ascii is invalid
                return false
            } else {
                // IDN
                return true
            }
        }

        func validate(_ rawText: String) -> Bool {
            guard LinkValidator.canParseURLs(in: rawText) else {
                return false
            }

            for match in linkDetector.matches(in: rawText, options: [], range: rawText.entireRange) {
                guard let matchURL: URL = match.url else {
                    continue
                }

                // We extract the exact text from the `fullText` rather than use match.url.host
                // because match.url.host actually escapes non-ascii domains into puny-code.
                //
                // But what we really want is to check the text which will ultimately be presented to
                // the user.
                let rawTextOfMatch = (rawText as NSString).substring(with: match.range)
                guard isValidLink(linkText: rawTextOfMatch) else {
                    return false
                }
            }
            return true
        }

        switch fullContent.textValue {
        case .text(let text):
            return validate(text)
        case .attributedText(let attributedText):
            return validate(attributedText.string)
        case .messageBody(let messageBody):
            return messageBody.shouldAllowLinkification(linkDetector: linkDetector, isValidLink: isValidLink(linkText:))
        }
    }()

    // MARK: Filter Methods

    private static let newLineScalar = 16

    private class func renderingSizeEstimate(of text: String) -> Int {
        let newlineByte = UInt8(ascii: "\n")
        return text.utf8.lazy.map { $0 == newlineByte ? newLineScalar : 1 }.reduce(0, +)
    }

    public class var empty: DisplayableText {
        return DisplayableText(
            fullContent: .init(textValue: .text(""), naturalAlignment: .natural),
            truncatedContent: nil,
        )
    }

    private static func limitNewLines(in text: String, to maxNewLines: Int) -> String {
        var textSuffix = text[...]
        var remainingCount = maxNewLines
        while remainingCount > 0 {
            guard let newLineRange = textSuffix.range(of: "\n") else {
                return text
            }
            textSuffix = textSuffix[newLineRange.upperBound...]
            remainingCount -= 1
        }
        guard let newLineRange = textSuffix.range(of: "\n") else {
            return text
        }
        return String(text[..<newLineRange.lowerBound])
    }

    public class func displayableText(
        withMessageBody messageBody: MessageBody,
        transaction: DBReadTransaction,
    ) -> DisplayableText {
        let textValue: CVTextValue
        if messageBody.ranges.hasRanges {
            let hydrated = messageBody
                .hydrating(
                    mentionHydrator: ContactsMentionHydrator.mentionHydrator(transaction: transaction),
                )
            textValue = .messageBody(hydrated)
        } else {
            textValue = .text(messageBody.text)
        }
        let fullContent = Content(
            textValue: textValue,
            naturalAlignment: textValue.naturalTextAligment,
        )

        // Only show up to N characters of text.
        let kMaxTextDisplayLength = 512
        let kMaxSnippetNewLines = 15

        func truncatePlaintext(_ text: String) -> String? {
            guard let truncatedText = text.trimmedIfNeeded(maxGlyphCount: kMaxTextDisplayLength) else {
                return nil
            }
            // Message bubbles by default should be short. We don't ever
            // want to show more than X new lines in the truncated text.
            return limitNewLines(in: truncatedText, to: kMaxSnippetNewLines)
        }

        let truncatedContent = { () -> Content? in
            switch textValue {
            case .text(let text):
                guard var truncatedText = truncatePlaintext(text) else {
                    return nil
                }
                truncatedText = truncatedText.ows_stripped() + Self.truncatedTextSuffix
                return Content(
                    textValue: .text(truncatedText),
                    naturalAlignment: truncatedText.naturalTextAlignment,
                )

            case .attributedText(let attributedText):
                guard let truncatedPlaintext = truncatePlaintext(attributedText.string) else {
                    return nil
                }
                let truncatedAttributedText = (
                    attributedText.attributedSubstring(from: truncatedPlaintext.entireRange).ows_stripped()
                        + Self.truncatedTextSuffix,
                )
                return Content(
                    textValue: .attributedText(truncatedAttributedText),
                    naturalAlignment: truncatedAttributedText.string.naturalTextAlignment,
                )

            case .messageBody(let messageBody):
                guard
                    let truncatedBody = messageBody.truncatingIfNeeded(
                        maxGlyphCount: kMaxTextDisplayLength,
                        truncationSuffix: Self.truncatedTextSuffix,
                    )
                else {
                    return nil
                }
                return Content(
                    textValue: .messageBody(truncatedBody),
                    naturalAlignment: truncatedBody.naturalTextAlignment,
                )
            }
        }()

        return DisplayableText(fullContent: fullContent, truncatedContent: truncatedContent)
    }
}