// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI public class CVComponentBodyText: CVComponentBase, CVComponent { public var componentKey: CVComponentKey { .bodyText } struct State: Equatable { let bodyText: CVComponentState.BodyText let isTextExpanded: Bool let searchText: String? let revealedSpoilerIds: Set let hasTapForMore: Bool let shouldUseAttributedText: Bool let hasPendingMessageRequest: Bool fileprivate let items: [CVTextLabel.Item] public var canUseDedicatedCell: Bool { if hasTapForMore || searchText != nil { return false } switch bodyText { case .bodyText: return true case .oversizeTextDownloading: return false case .remotelyDeleted: return false } } var textValue: CVTextValue? { bodyText.textValue(isTextExpanded: isTextExpanded) } } private let bodyTextState: State private var bodyText: CVComponentState.BodyText { bodyTextState.bodyText } private var textValue: CVTextValue? { bodyTextState.textValue } private var isTextExpanded: Bool { bodyTextState.isTextExpanded } private var searchText: String? { bodyTextState.searchText } private var revealedSpoilerIds: Set { bodyTextState.revealedSpoilerIds } private var hasTapForMore: Bool { bodyTextState.hasTapForMore } private var hasPendingMessageRequest: Bool { bodyTextState.hasPendingMessageRequest } public var shouldUseAttributedText: Bool { bodyTextState.shouldUseAttributedText } init(itemModel: CVItemModel, bodyTextState: State) { self.bodyTextState = bodyTextState super.init(itemModel: itemModel) } public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView { CVComponentViewBodyText(componentDelegate: componentDelegate) } private static func shouldIgnoreEvents(interaction: TSInteraction) -> Bool { guard let outgoingMessage = interaction as? TSOutgoingMessage else { return false } // Ignore taps on links in outgoing messages that haven't been sent yet, as // this interferes with "tap to retry". return outgoingMessage.messageState != .sent } private var shouldIgnoreEvents: Bool { Self.shouldIgnoreEvents(interaction: interaction) } // TODO: private static let shouldDetectDates = false private static func buildDataDetector(shouldAllowLinkification: Bool) -> NSDataDetector? { var checkingTypes = NSTextCheckingResult.CheckingType() if shouldAllowLinkification { checkingTypes.insert(.link) } checkingTypes.insert(.address) checkingTypes.insert(.phoneNumber) if shouldDetectDates { checkingTypes.insert(.date) } do { return try NSDataDetector(types: checkingTypes.rawValue) } catch { owsFailDebug("Error: \(error)") return nil } } private static var dataDetectorWithLinks: NSDataDetector? = { buildDataDetector(shouldAllowLinkification: true) }() private static var dataDetectorWithoutLinks: NSDataDetector? = { buildDataDetector(shouldAllowLinkification: false) }() // DataDetectors are expensive to build, so we reuse them. private static func dataDetector(shouldAllowLinkification: Bool) -> NSDataDetector? { shouldAllowLinkification ? dataDetectorWithLinks : dataDetectorWithoutLinks } private static let unfairLock = UnfairLock() public static func detectItems( text: DisplayableText, hasPendingMessageRequest: Bool, shouldAllowLinkification: Bool, textWasTruncated: Bool, revealedSpoilerIds: Set, interactionUniqueId: String, interactionIdentifier: InteractionSnapshotIdentifier ) -> [CVTextLabel.Item] { // Use a lock to ensure that measurement on and off the main thread // don't conflict. unfairLock.withLock { guard !hasPendingMessageRequest else { // Do not linkify if there is a pending message request. return [] } let dataDetector = buildDataDetector(shouldAllowLinkification: shouldAllowLinkification) func detectItems(plaintext: String) -> [CVTextLabel.Item] { return TextCheckingDataItem.detectedItems(in: plaintext, using: dataDetector).compactMap { if textWasTruncated { if NSMaxRange($0.range) == NSMaxRange(plaintext.entireRange) { // This implies that the data detector *included* our "…" suffix. // We don't expect this to happen, but if it does it's certainly not intended! return nil } if (plaintext as NSString).substring(after: $0.range) == DisplayableText.truncatedTextSuffix { // More likely the item right before the "…" was detected. // Conservatively assume that the item was truncated. return nil } } return .dataItem(dataItem: $0) } } let items: [CVTextLabel.Item] switch text.textValue(isTextExpanded: !textWasTruncated) { case .attributedText(let attributedText): items = detectItems(plaintext: attributedText.string) case .text(let text): items = detectItems(plaintext: text) case .messageBody(let messageBody): items = messageBody .tappableItems( revealedSpoilerIds: revealedSpoilerIds, dataDetector: dataDetector ) .compactMap { switch $0 { case .unrevealedSpoiler(let unrevealedSpoilerItem): return .unrevealedSpoiler(CVTextLabel.UnrevealedSpoilerItem( spoilerId: unrevealedSpoilerItem.id, interactionUniqueId: interactionUniqueId, interactionIdentifier: interactionIdentifier, range: unrevealedSpoilerItem.range )) case .mention(let mentionItem): return .mention(mentionItem: CVTextLabel.MentionItem( mentionAci: mentionItem.mentionAci, range: mentionItem.range )) case .data(let dataItem): // TODO: omit data items ending before elipsis down in tappableItems return .dataItem(dataItem: dataItem) } } } return items } } static func buildState(interaction: TSInteraction, bodyText: CVComponentState.BodyText, viewStateSnapshot: CVViewStateSnapshot, hasTapForMore: Bool, hasPendingMessageRequest: Bool) -> State { let textExpansion = viewStateSnapshot.textExpansion let searchText = viewStateSnapshot.searchText let isTextExpanded = textExpansion.isTextExpanded(interactionId: interaction.uniqueId) let revealedSpoilerIds = viewStateSnapshot.spoilerReveal[.fromInteraction(interaction)] ?? Set() let items: [CVTextLabel.Item] var shouldUseAttributedText = false if let displayableText = bodyText.displayableText { let shouldAllowLinkification = displayableText.shouldAllowLinkification let textWasTruncated = !isTextExpanded && displayableText.isTextTruncated items = detectItems( text: displayableText, hasPendingMessageRequest: hasPendingMessageRequest, shouldAllowLinkification: shouldAllowLinkification, textWasTruncated: textWasTruncated, revealedSpoilerIds: revealedSpoilerIds, interactionUniqueId: interaction.uniqueId, interactionIdentifier: .fromInteraction(interaction) ) switch displayableText.textValue(isTextExpanded: isTextExpanded) { case .text: // UILabels are much cheaper than UITextViews, and we can // usually use them for rendering body text. // // We need to use attributed text in a UITextViews if: // // * We're displaying search results (and need to highlight matches). // * The text value is an attributed string (has mentions). // * The text value should be linkified. if searchText != nil { shouldUseAttributedText = true } else { shouldUseAttributedText = !items.isEmpty } case .attributedText, .messageBody: shouldUseAttributedText = true } } else { items = [] } return State(bodyText: bodyText, isTextExpanded: isTextExpanded, searchText: searchText, revealedSpoilerIds: revealedSpoilerIds, hasTapForMore: hasTapForMore, shouldUseAttributedText: shouldUseAttributedText, hasPendingMessageRequest: hasPendingMessageRequest, items: items) } static func buildComponentState(message: TSMessage, transaction: SDSAnyReadTransaction) throws -> CVComponentState.BodyText? { func build(displayableText: DisplayableText) -> CVComponentState.BodyText? { guard !displayableText.fullTextValue.isEmpty else { return nil } return .bodyText(displayableText: displayableText) } // TODO: We might want to treat text that is completely stripped // as not present. if let oversizeTextAttachment = message.oversizeTextAttachment(transaction: transaction) { if let oversizeTextAttachmentStream = oversizeTextAttachment as? TSAttachmentStream { let displayableText = CVComponentState.displayableBodyText(oversizeTextAttachment: oversizeTextAttachmentStream, ranges: message.bodyRanges, interaction: message, transaction: transaction) return build(displayableText: displayableText) } else if nil != oversizeTextAttachment as? TSAttachmentPointer { // TODO: Handle backup restore. // TODO: If there's media, should we display that while the oversize text is downloading? return .oversizeTextDownloading } else { throw OWSAssertionError("Invalid oversizeTextAttachment.") } } else if let body = message.body, !body.isEmpty { let displayableText = CVComponentState.displayableBodyText(text: body, ranges: message.bodyRanges, interaction: message, transaction: transaction) return build(displayableText: displayableText) } else { // No body text. return nil } } public var textMessageFont: UIFont { owsAssertDebug(DisplayableText.kMaxJumbomojiCount == 5) if let jumbomojiCount = bodyText.jumbomojiCount { let basePointSize = UIFont.dynamicTypeBodyClamped.pointSize switch jumbomojiCount { case 0: break case 1: return UIFont.regularFont(ofSize: basePointSize * 3.5) case 2: return UIFont.regularFont(ofSize: basePointSize * 3.0) case 3: return UIFont.regularFont(ofSize: basePointSize * 2.75) case 4: return UIFont.regularFont(ofSize: basePointSize * 2.5) case 5: return UIFont.regularFont(ofSize: basePointSize * 2.25) default: owsFailDebug("Unexpected jumbomoji count: \(jumbomojiCount)") } } return UIFont.dynamicTypeBody } private var bodyTextColor: UIColor { guard let message = interaction as? TSMessage else { return .black } return conversationStyle.bubbleTextColor(message: message) } private var textSelectionStyling: [NSAttributedString.Key: Any] { var foregroundColor: UIColor = .black if let message = interaction as? TSMessage { foregroundColor = conversationStyle.bubbleSecondaryTextColor(isIncoming: message.isIncoming) } return [ .underlineStyle: NSUnderlineStyle.single.rawValue, .underlineColor: foregroundColor, .foregroundColor: foregroundColor ] } public func bodyTextLabelConfig(textViewConfig: CVTextViewConfig) -> CVTextLabel.Config { CVTextLabel.Config( text: textViewConfig.text, displayConfig: textViewConfig.displayConfiguration, font: textViewConfig.font, textColor: textViewConfig.textColor, selectionStyling: textSelectionStyling, textAlignment: textViewConfig.textAlignment ?? .natural, lineBreakMode: .byWordWrapping, numberOfLines: 0, cacheKey: textViewConfig.cacheKey, items: bodyTextState.items, linkifyStyle: textViewConfig.linkifyStyle ) } public func bodyTextLabelConfig(labelConfig: CVLabelConfig) -> CVTextLabel.Config { // CVTextLabel requires that attributedString has // default attributes applied to the entire string's range. let textAlignment: NSTextAlignment = labelConfig.textAlignment ?? .natural let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = textAlignment return CVTextLabel.Config( text: labelConfig.text, displayConfig: labelConfig.displayConfig, font: labelConfig.font, textColor: labelConfig.textColor, selectionStyling: textSelectionStyling, textAlignment: textAlignment, lineBreakMode: .byWordWrapping, numberOfLines: 0, cacheKey: labelConfig.cacheKey, items: bodyTextState.items, linkifyStyle: linkifyStyle ) } public func configureForRendering(componentView: CVComponentView, cellMeasurement: CVCellMeasurement, componentDelegate: CVComponentDelegate) { AssertIsOnMainThread() guard let componentView = componentView as? CVComponentViewBodyText else { owsFailDebug("Unexpected componentView.") return } let bodyTextLabelConfig = buildBodyTextLabelConfig() configureForBodyTextLabel( componentView: componentView, bodyTextLabelConfig: bodyTextLabelConfig, cellMeasurement: cellMeasurement, spoilerAnimationManager: componentDelegate.spoilerState.animationManager ) } private func configureForBodyTextLabel( componentView: CVComponentViewBodyText, bodyTextLabelConfig: CVTextLabel.Config, cellMeasurement: CVCellMeasurement, spoilerAnimationManager: SpoilerAnimationManager ) { AssertIsOnMainThread() let bodyTextLabel = componentView.bodyTextLabel bodyTextLabel.configureForRendering(config: bodyTextLabelConfig, spoilerAnimationManager: spoilerAnimationManager) if bodyTextLabel.view.superview == nil { let stackView = componentView.stackView stackView.reset() stackView.configure(config: stackViewConfig, cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_stackView, subviews: [ bodyTextLabel.view ]) } } public func buildBodyTextLabelConfig() -> CVTextLabel.Config { switch bodyText { case .bodyText(let displayableText): return bodyTextLabelConfig(textViewConfig: textConfig(displayableText: displayableText)) case .oversizeTextDownloading: return bodyTextLabelConfig(labelConfig: labelConfigForOversizeTextDownloading) case .remotelyDeleted: return bodyTextLabelConfig(labelConfig: labelConfigForRemotelyDeleted) } } private var stackViewConfig: CVStackViewConfig { CVStackViewConfig(axis: .vertical, alignment: .fill, spacing: 0, layoutMargins: .zero) } private var labelConfigForRemotelyDeleted: CVLabelConfig { let text = (isIncoming ? OWSLocalizedString("THIS_MESSAGE_WAS_DELETED", comment: "text indicating the message was remotely deleted") : OWSLocalizedString("YOU_DELETED_THIS_MESSAGE", comment: "text indicating the message was remotely deleted by you")) return CVLabelConfig( text: .text(text), displayConfig: .forUnstyledText(font: textMessageFont.italic(), textColor: bodyTextColor), font: textMessageFont.italic(), textColor: bodyTextColor, numberOfLines: 0, lineBreakMode: .byWordWrapping, textAlignment: .center ) } private var labelConfigForOversizeTextDownloading: CVLabelConfig { let text = OWSLocalizedString("MESSAGE_STATUS_DOWNLOADING", comment: "message status while message is downloading.") return CVLabelConfig( text: .text(text), displayConfig: .forUnstyledText(font: textMessageFont.italic(), textColor: bodyTextColor), font: textMessageFont.italic(), textColor: bodyTextColor, numberOfLines: 0, lineBreakMode: .byWordWrapping, textAlignment: .center ) } private typealias TextConfig = CVTextViewConfig private func textConfig(displayableText: DisplayableText) -> TextConfig { return self.textViewConfig(displayableText: displayableText) } public static func configureTextView(_ textView: UITextView, interaction: TSInteraction, displayableText: DisplayableText) { let dataDetectorTypes: UIDataDetectorTypes = { // If we're link-ifying with NSDataDetector, UITextView doesn't need to do data detection. guard !shouldIgnoreEvents(interaction: interaction), displayableText.shouldAllowLinkification else { return [] } return [.link, .address, .calendarEvent, .phoneNumber] }() if textView.dataDetectorTypes != dataDetectorTypes { // Setting dataDetectorTypes is expensive, so we only // update the property if the value has changed. textView.dataDetectorTypes = dataDetectorTypes } } private var linkTextAttributes: [NSAttributedString.Key: Any] { return [ NSAttributedString.Key.foregroundColor: bodyTextColor, NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue ] } private var linkifyStyle: CVTextLabel.LinkifyStyle { .underlined(bodyTextColor: bodyTextColor) } private func textViewConfig(displayableText: DisplayableText) -> CVTextViewConfig { let textAlignment = (isTextExpanded ? displayableText.fullTextNaturalAlignment : displayableText.displayTextNaturalAlignment) let text = displayableText.textValue(isTextExpanded: isTextExpanded) let linkItems = bodyTextState.items var matchedSearchRanges = [NSRange]() if let searchText = searchText, searchText.count >= ConversationSearchController.kMinimumSearchTextLength { let searchableText = FullTextSearchIndexer.normalizeText(searchText) let pattern = NSRegularExpression.escapedPattern(for: searchableText) do { let regex = try NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) func runRegex(_ string: String) { for match in regex.matches(in: string, options: [.withoutAnchoringBounds], range: string.entireRange) { owsAssertDebug(match.range.length >= ConversationSearchController.kMinimumSearchTextLength) matchedSearchRanges.append(match.range) } } switch text { case .text(let text): runRegex(text) case .attributedText(let attributedText): runRegex(attributedText.string) case .messageBody(let messageBody): matchedSearchRanges = messageBody.matches(for: regex) } } catch { owsFailDebug("Error: \(error)") } } let displayConfiguration = HydratedMessageBody.DisplayConfiguration.messageBubble( isIncoming: isIncoming, revealedSpoilerIds: revealedSpoilerIds, searchRanges: .matchedRanges(matchedSearchRanges) ) var extraCacheKeyFactors = [String]() if hasPendingMessageRequest { extraCacheKeyFactors.append("hasPendingMessageRequest") } extraCacheKeyFactors.append("items: \(!bodyTextState.items.isEmpty)") return CVTextViewConfig( text: text, font: textMessageFont, textColor: bodyTextColor, textAlignment: textAlignment, displayConfiguration: displayConfiguration, linkTextAttributes: linkTextAttributes, linkifyStyle: linkifyStyle, linkItems: linkItems, matchedSearchRanges: matchedSearchRanges, extraCacheKeyFactors: extraCacheKeyFactors ) } private static let measurementKey_stackView = "CVComponentBodyText.measurementKey_stackView" private static let measurementKey_textMeasurement = "CVComponentBodyText.measurementKey_textMeasurement" private static let measurementKey_maxWidth = "CVComponentBodyText.measurementKey_maxWidth" // Extract the max width used for measuring for this component. public static func bodyTextMaxWidth(measurementBuilder: CVCellMeasurement.Builder) -> CGFloat? { measurementBuilder.getValue(key: measurementKey_maxWidth) } // Extract the overall measurement for this component. public static func bodyTextMeasurement(measurementBuilder: CVCellMeasurement.Builder) -> CVTextLabel.Measurement? { measurementBuilder.getObject(key: measurementKey_textMeasurement) as? CVTextLabel.Measurement } public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize { owsAssertDebug(maxWidth > 0) let maxWidth = max(maxWidth, 0) let bodyTextLabelConfig = buildBodyTextLabelConfig() let textMeasurement = CVText.measureBodyTextLabel(config: bodyTextLabelConfig, maxWidth: maxWidth) measurementBuilder.setObject(key: Self.measurementKey_textMeasurement, value: textMeasurement) measurementBuilder.setValue(key: Self.measurementKey_maxWidth, value: maxWidth) let textSize = textMeasurement.size.ceil let textInfo = textSize.asManualSubviewInfo let stackMeasurement = ManualStackView.measure(config: stackViewConfig, measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_stackView, subviewInfos: [ textInfo ], maxWidth: maxWidth) return stackMeasurement.measuredSize } // MARK: - Events public override func handleTap(sender: UITapGestureRecognizer, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem) -> Bool { guard let componentView = componentView as? CVComponentViewBodyText else { owsFailDebug("Unexpected componentView.") return false } guard !shouldIgnoreEvents else { return false } let bodyTextLabel = componentView.bodyTextLabel if let item = bodyTextLabel.itemForGesture(sender: sender) { bodyTextLabel.animate(selectedItem: item) componentDelegate.didTapBodyTextItem(item) return true } if hasTapForMore { let itemViewModel = CVItemViewModelImpl(renderItem: renderItem) componentDelegate.didTapTruncatedTextMessage(itemViewModel) return true } return false } public override func findLongPressHandler(sender: UIGestureRecognizer, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem) -> CVLongPressHandler? { guard let componentView = componentView as? CVComponentViewBodyText else { owsFailDebug("Unexpected componentView.") return nil } guard !shouldIgnoreEvents else { return nil } let bodyTextLabel = componentView.bodyTextLabel guard let item = bodyTextLabel.itemForGesture(sender: sender) else { return nil } bodyTextLabel.animate(selectedItem: item) return CVLongPressHandler(delegate: componentDelegate, renderItem: renderItem, gestureLocation: .bodyText(item: item)) } // MARK: - fileprivate class BodyTextRootView: ManualStackView {} public static func findBodyTextRootView(_ view: UIView) -> UIView? { if view is BodyTextRootView { return view } for subview in view.subviews { if let rootView = findBodyTextRootView(subview) { return rootView } } return nil } // MARK: - // Used for rendering some portion of an Conversation View item. // It could be the entire item or some part thereof. public class CVComponentViewBodyText: NSObject, CVComponentView { public weak var componentDelegate: CVComponentDelegate? fileprivate let stackView = BodyTextRootView(name: "bodyText") public let bodyTextLabel = CVTextLabel() public var isDedicatedCellView = false public var rootView: UIView { stackView } init(componentDelegate: CVComponentDelegate) { self.componentDelegate = componentDelegate super.init() } public func setIsCellVisible(_ isCellVisible: Bool) { bodyTextLabel.setIsCellVisible(isCellVisible) } public func reset() { if !isDedicatedCellView { stackView.reset() } bodyTextLabel.reset() } } } // MARK: - extension CVComponentBodyText: CVAccessibilityComponent { public var accessibilityDescription: String { switch bodyText { case .bodyText(let displayableText): // NOTE: we use the full text. return displayableText.fullTextValue.accessibilityDescription case .oversizeTextDownloading: return labelConfigForOversizeTextDownloading.text.accessibilityDescription case .remotelyDeleted: return labelConfigForRemotelyDeleted.text.accessibilityDescription } } }