894 lines
33 KiB
Swift
894 lines
33 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
public import SignalServiceKit
|
|
public 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<Int>
|
|
let shouldUseAttributedText: Bool
|
|
let hasPendingMessageRequest: Bool
|
|
fileprivate let items: [CVTextLabel.Item]
|
|
|
|
var canUseDedicatedCell: Bool {
|
|
if searchText != nil {
|
|
return false
|
|
}
|
|
switch bodyText {
|
|
case .bodyText(_, let hasTapForMore):
|
|
return !hasTapForMore
|
|
case .oversizeTextDownloading:
|
|
return false
|
|
case .oversizeTextSkipped:
|
|
return false
|
|
case .oversizeTextUndownloadable:
|
|
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<Int> {
|
|
bodyTextState.revealedSpoilerIds
|
|
}
|
|
|
|
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 have failed to send, as
|
|
// this interferes with "tap to retry".
|
|
return outgoingMessage.messageState == .failed
|
|
}
|
|
|
|
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<StyleIdType>,
|
|
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.substring(afterRange: $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,
|
|
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,
|
|
shouldUseAttributedText: shouldUseAttributedText,
|
|
hasPendingMessageRequest: hasPendingMessageRequest,
|
|
items: items,
|
|
)
|
|
}
|
|
|
|
static func buildComponentState(
|
|
message: TSMessage,
|
|
skippedAttachmentIds: Set<Attachment.IDType>,
|
|
viewStateSnapshot: CVViewStateSnapshot,
|
|
transaction: DBReadTransaction,
|
|
) throws -> CVComponentState.BodyText? {
|
|
|
|
func build(displayableText: DisplayableText) -> CVComponentState.BodyText? {
|
|
guard !displayableText.fullTextValue.isEmpty else {
|
|
return nil
|
|
}
|
|
let hasTapForMore: Bool = {
|
|
guard displayableText.isTextTruncated else {
|
|
return false
|
|
}
|
|
let isTruncatedTextVisible = viewStateSnapshot.textExpansion.isTextExpanded(
|
|
interactionId: message.uniqueId,
|
|
)
|
|
return !isTruncatedTextVisible
|
|
}()
|
|
return .bodyText(displayableText: displayableText, hasTapForMore: hasTapForMore)
|
|
}
|
|
|
|
func bodyDisplayableText() -> DisplayableText? {
|
|
if let body = message.body, !body.isEmpty {
|
|
return CVComponentState.displayableBodyText(
|
|
text: body,
|
|
ranges: message.bodyRanges,
|
|
interaction: message,
|
|
transaction: transaction,
|
|
)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// TODO: We might want to treat text that is completely stripped
|
|
// as not present.
|
|
if let oversizeTextAttachment = message.oversizeTextAttachment(transaction: transaction) {
|
|
// We have a fully downloaded oversize text attachment.
|
|
if let oversizeTextAttachmentStream = oversizeTextAttachment.asStream() {
|
|
let displayableText = CVComponentState.displayableBodyText(
|
|
oversizeTextAttachment: oversizeTextAttachmentStream,
|
|
ranges: message.bodyRanges,
|
|
interaction: message,
|
|
transaction: transaction,
|
|
)
|
|
return build(displayableText: displayableText)
|
|
}
|
|
|
|
// We can't possibly download this; show a terminal error.
|
|
if oversizeTextAttachment.asAnyPointer() == nil {
|
|
if let displayableText = bodyDisplayableText() {
|
|
return .oversizeTextUndownloadable(truncatedBody: displayableText)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// We aren't downloading this for some reason; show a download button.
|
|
if skippedAttachmentIds.contains(oversizeTextAttachment.id) {
|
|
if let displayableText = bodyDisplayableText() {
|
|
return .oversizeTextSkipped(truncatedBody: displayableText)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// It's not downloaded and hasn't failed, so it must be downloading.
|
|
if let displayableText = bodyDisplayableText() {
|
|
return .oversizeTextDownloading(truncatedBody: displayableText)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if let displayableText = bodyDisplayableText() {
|
|
return build(displayableText: displayableText)
|
|
}
|
|
|
|
// 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: textViewConfig.linkItems,
|
|
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, _), .oversizeTextUndownloadable(let displayableText), .oversizeTextSkipped(let displayableText), .oversizeTextDownloading(let displayableText):
|
|
return bodyTextLabelConfig(textViewConfig: textConfig(displayableText: displayableText))
|
|
case .remotelyDeleted:
|
|
return bodyTextLabelConfig(textViewConfig: textViewConfigForRemotelyDeleted)
|
|
}
|
|
}
|
|
|
|
private var stackViewConfig: CVStackViewConfig {
|
|
CVStackViewConfig(
|
|
axis: .vertical,
|
|
alignment: .fill,
|
|
spacing: 0,
|
|
layoutMargins: .zero,
|
|
)
|
|
}
|
|
|
|
private func buildAdminDeleteAttributedString(displayName: String, adminNameColor: UIColor) -> NSAttributedString {
|
|
let format = OWSLocalizedString(
|
|
"DELETED_BY_ADMIN",
|
|
comment: "Text indicating the message was remotely deleted by an admin. Embeds {{admin display name}}",
|
|
)
|
|
|
|
let attributedString = NSAttributedString.make(
|
|
fromFormat: format,
|
|
attributedFormatArgs: [
|
|
.string(
|
|
displayName,
|
|
attributes: [.font: textMessageFont.bold(), .foregroundColor: adminNameColor],
|
|
),
|
|
],
|
|
)
|
|
|
|
return attributedString
|
|
}
|
|
|
|
private func rangeOfFirstSubstring(
|
|
in attributedString: NSAttributedString,
|
|
withColor color: UIColor,
|
|
) -> NSRange? {
|
|
var matchingRange: NSRange?
|
|
attributedString.enumerateAttribute(
|
|
.foregroundColor,
|
|
in: NSRange(location: 0, length: attributedString.length),
|
|
options: [],
|
|
) { value, range, stop in
|
|
if
|
|
let currentColor = value as? UIColor,
|
|
currentColor == color
|
|
{
|
|
matchingRange = range
|
|
stop.pointee = true
|
|
}
|
|
}
|
|
return matchingRange
|
|
}
|
|
|
|
private var textViewConfigForRemotelyDeleted: CVTextViewConfig {
|
|
let text = NSMutableAttributedString()
|
|
text.append(
|
|
SignalSymbol.xCircle.attributedString(
|
|
dynamicTypeBaseSize: textMessageFont.pointSize,
|
|
) + " ",
|
|
)
|
|
|
|
var textColor = UIColor.Signal.secondaryLabel
|
|
if conversationStyle.hasWallpaper {
|
|
textColor = conversationStyle.bubbleSecondaryTextColor(isIncoming: isIncoming)
|
|
}
|
|
|
|
var linkItems: [CVTextLabel.Item] = []
|
|
switch bodyText {
|
|
case .remotelyDeleted(let deleteAuthor):
|
|
switch deleteAuthor {
|
|
case .admin(let aci, let displayName):
|
|
let adminNameColor: UIColor
|
|
if conversationStyle.hasWallpaper, isOutgoing {
|
|
adminNameColor = .white
|
|
} else {
|
|
adminNameColor = GroupNameColors.forThread(thread).color(for: aci)
|
|
}
|
|
let attributedString = buildAdminDeleteAttributedString(
|
|
displayName: displayName,
|
|
adminNameColor: adminNameColor,
|
|
)
|
|
text.append(attributedString)
|
|
|
|
if
|
|
let tapItemRange = rangeOfFirstSubstring(
|
|
in: text,
|
|
withColor: adminNameColor,
|
|
)
|
|
{
|
|
linkItems.append(.deleteAuthor(deleteAuthorItem: CVTextLabel.DeleteAuthorItem(
|
|
deleteAuthorAci: aci,
|
|
range: tapItemRange,
|
|
)))
|
|
} else {
|
|
owsFailDebug("Admin delete is missing tappable range")
|
|
}
|
|
|
|
case .regular(let displayName):
|
|
let format = OWSLocalizedString(
|
|
"DELETED_THIS_MESSAGE",
|
|
comment: "Text indicating the message was remotely deleted by its author. Embeds {{ author name }}",
|
|
)
|
|
text.append(
|
|
NSAttributedString(
|
|
string: String.nonPluralLocalizedStringWithFormat(format, displayName),
|
|
),
|
|
)
|
|
|
|
case .localUser:
|
|
text.append(NSAttributedString(string: OWSLocalizedString("YOU_DELETED_THIS_MESSAGE", comment: "text indicating the message was remotely deleted by you")))
|
|
}
|
|
default:
|
|
owsFailDebug("Should be remotelyDeleted")
|
|
}
|
|
|
|
let displayConfiguration = HydratedMessageBody.DisplayConfiguration.messageBubble(
|
|
isIncoming: isIncoming,
|
|
revealedSpoilerIds: revealedSpoilerIds,
|
|
searchRanges: .matchedRanges([]),
|
|
)
|
|
|
|
return CVTextViewConfig(
|
|
text: .attributedText(text),
|
|
font: textMessageFont,
|
|
textColor: textColor,
|
|
textAlignment: .natural,
|
|
displayConfiguration: displayConfiguration,
|
|
linkifyStyle: linkifyStyle,
|
|
linkItems: linkItems,
|
|
matchedSearchRanges: [],
|
|
)
|
|
}
|
|
|
|
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.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
|
|
|
|
override public func handleTap(
|
|
sender: UIGestureRecognizer,
|
|
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
|
|
}
|
|
switch bodyText {
|
|
case .bodyText(_, let hasTapForMore) where hasTapForMore:
|
|
let itemViewModel = CVItemViewModelImpl(renderItem: renderItem)
|
|
componentDelegate.didTapTruncatedTextMessage(itemViewModel)
|
|
return true
|
|
case .oversizeTextUndownloadable:
|
|
componentDelegate.didTapUndownloadableOversizeText()
|
|
return true
|
|
case .oversizeTextSkipped:
|
|
guard let message = interaction as? TSMessage else {
|
|
owsFailDebug("invalid interaction")
|
|
return false
|
|
}
|
|
componentDelegate.didTapSkippedDownloads(message)
|
|
return true
|
|
case .bodyText, .remotelyDeleted, .oversizeTextDownloading:
|
|
break
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
override public 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, _), .oversizeTextUndownloadable(let displayableText), .oversizeTextSkipped(let displayableText), .oversizeTextDownloading(let displayableText):
|
|
// NOTE: we use the full text.
|
|
return displayableText.fullTextValue.accessibilityDescription
|
|
case .remotelyDeleted:
|
|
return textViewConfigForRemotelyDeleted.text.accessibilityDescription
|
|
}
|
|
}
|
|
}
|