mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
1439 lines
81 KiB
Swift
1439 lines
81 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import TelegramCore
|
|
import Postbox
|
|
import TextFormat
|
|
import UrlEscaping
|
|
import TelegramUniversalVideoContent
|
|
import TextSelectionNode
|
|
import InvisibleInkDustNode
|
|
import Emoji
|
|
import AnimatedStickerNode
|
|
import TelegramAnimatedStickerNode
|
|
import SwiftSignalKit
|
|
import AccountContext
|
|
import YuvConversion
|
|
import AnimationCache
|
|
import LottieAnimationCache
|
|
import MultiAnimationRenderer
|
|
import EmojiTextAttachmentView
|
|
import TextNodeWithEntities
|
|
import ChatMessageDateAndStatusNode
|
|
import ChatMessageBubbleContentNode
|
|
import ShimmeringLinkNode
|
|
import ChatMessageItemCommon
|
|
import TextLoadingEffect
|
|
import ChatControllerInteraction
|
|
|
|
private final class CachedChatMessageText {
|
|
let text: String
|
|
let inputEntities: [MessageTextEntity]?
|
|
let entities: [MessageTextEntity]?
|
|
|
|
init(text: String, inputEntities: [MessageTextEntity]?, entities: [MessageTextEntity]?) {
|
|
self.text = text
|
|
self.inputEntities = inputEntities
|
|
self.entities = entities
|
|
}
|
|
|
|
func matches(text: String, inputEntities: [MessageTextEntity]?) -> Bool {
|
|
if self.text != text {
|
|
return false
|
|
}
|
|
if let current = self.inputEntities, let inputEntities = inputEntities {
|
|
if current != inputEntities {
|
|
return false
|
|
}
|
|
} else if (self.inputEntities != nil) != (inputEntities != nil) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
private func findQuoteRange(string: String, quoteText: String, offset: Int?) -> NSRange? {
|
|
let nsString = string as NSString
|
|
var currentRange: NSRange?
|
|
while true {
|
|
let startOffset = currentRange?.upperBound ?? 0
|
|
let range = nsString.range(of: quoteText, range: NSRange(location: startOffset, length: nsString.length - startOffset))
|
|
if range.location != NSNotFound {
|
|
if let offset {
|
|
if let currentRangeValue = currentRange {
|
|
if abs(range.location - offset) > abs(currentRangeValue.location - offset) {
|
|
break
|
|
} else {
|
|
currentRange = range
|
|
}
|
|
} else {
|
|
currentRange = range
|
|
}
|
|
} else {
|
|
currentRange = range
|
|
break
|
|
}
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return currentRange
|
|
}
|
|
|
|
public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
|
|
private let containerNode: ASDisplayNode
|
|
private let textNode: TextNodeWithEntities
|
|
private var spoilerTextNode: TextNodeWithEntities?
|
|
private var dustNode: InvisibleInkDustNode?
|
|
|
|
private let textAccessibilityOverlayNode: TextAccessibilityOverlayNode
|
|
public var statusNode: ChatMessageDateAndStatusNode?
|
|
private var linkHighlightingNode: LinkHighlightingNode?
|
|
private var shimmeringNode: ShimmeringLinkNode?
|
|
private var textSelectionNode: TextSelectionNode?
|
|
|
|
private var textHighlightingNodes: [LinkHighlightingNode] = []
|
|
|
|
private var cachedChatMessageText: CachedChatMessageText?
|
|
|
|
private var textSelectionState: Promise<ChatControllerSubject.MessageOptionsInfo.SelectionState>?
|
|
|
|
private var linkPreviewHighlightText: String?
|
|
private var linkPreviewOptionsDisposable: Disposable?
|
|
private var linkPreviewHighlightingNodes: [LinkHighlightingNode] = []
|
|
|
|
private var quoteHighlightingNode: LinkHighlightingNode?
|
|
|
|
private var linkProgressRange: NSRange?
|
|
private var linkProgressView: TextLoadingEffectView?
|
|
private var linkProgressDisposable: Disposable?
|
|
|
|
private var codeHighlightState: (id: EngineMessage.Id, specs: [CachedMessageSyntaxHighlight.Spec], disposable: Disposable)?
|
|
|
|
override public var visibility: ListViewItemNodeVisibility {
|
|
didSet {
|
|
if oldValue != self.visibility {
|
|
switch self.visibility {
|
|
case .none:
|
|
self.textNode.visibilityRect = nil
|
|
self.spoilerTextNode?.visibilityRect = nil
|
|
case let .visible(_, subRect):
|
|
var subRect = subRect
|
|
subRect.origin.x = 0.0
|
|
subRect.size.width = 10000.0
|
|
self.textNode.visibilityRect = subRect
|
|
self.spoilerTextNode?.visibilityRect = subRect
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
required public init() {
|
|
self.containerNode = ASDisplayNode()
|
|
|
|
self.textNode = TextNodeWithEntities()
|
|
|
|
self.textAccessibilityOverlayNode = TextAccessibilityOverlayNode()
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.containerNode)
|
|
|
|
self.textNode.textNode.isUserInteractionEnabled = false
|
|
self.textNode.textNode.contentMode = .topLeft
|
|
self.textNode.textNode.contentsScale = UIScreenScale
|
|
self.textNode.textNode.displaysAsynchronously = true
|
|
self.containerNode.addSubnode(self.textNode.textNode)
|
|
self.containerNode.addSubnode(self.textAccessibilityOverlayNode)
|
|
|
|
self.textAccessibilityOverlayNode.openUrl = { [weak self] url in
|
|
self?.item?.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl(url: url, concealed: false, external: false))
|
|
}
|
|
}
|
|
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
self.linkPreviewOptionsDisposable?.dispose()
|
|
self.linkProgressDisposable?.dispose()
|
|
self.codeHighlightState?.disposable.dispose()
|
|
}
|
|
|
|
override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) {
|
|
let textLayout = TextNodeWithEntities.asyncLayout(self.textNode)
|
|
let spoilerTextLayout = TextNodeWithEntities.asyncLayout(self.spoilerTextNode)
|
|
let statusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.statusNode)
|
|
|
|
let currentCachedChatMessageText = self.cachedChatMessageText
|
|
|
|
return { item, layoutConstants, _, _, _, _ in
|
|
let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none)
|
|
|
|
return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in
|
|
var topInset: CGFloat = 0.0
|
|
var bottomInset: CGFloat = 0.0
|
|
if case let .linear(top, bottom) = position {
|
|
switch top {
|
|
case .None:
|
|
topInset = layoutConstants.text.bubbleInsets.top
|
|
case let .Neighbour(_, topType, _):
|
|
switch topType {
|
|
case .text:
|
|
topInset = layoutConstants.text.bubbleInsets.top - 2.0
|
|
case .header, .footer, .media, .reactions:
|
|
topInset = layoutConstants.text.bubbleInsets.top
|
|
}
|
|
default:
|
|
topInset = layoutConstants.text.bubbleInsets.top
|
|
}
|
|
|
|
switch bottom {
|
|
case .None:
|
|
bottomInset = layoutConstants.text.bubbleInsets.bottom
|
|
default:
|
|
bottomInset = layoutConstants.text.bubbleInsets.bottom - 3.0
|
|
}
|
|
}
|
|
|
|
let message = item.message
|
|
|
|
let incoming: Bool
|
|
if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info {
|
|
incoming = false
|
|
} else {
|
|
incoming = item.message.effectivelyIncoming(item.context.account.peerId)
|
|
}
|
|
|
|
var maxTextWidth = CGFloat.greatestFiniteMagnitude
|
|
for media in item.message.media {
|
|
if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content, content.type == "telegram_background" || content.type == "telegram_theme" {
|
|
maxTextWidth = layoutConstants.wallpapers.maxTextWidth
|
|
break
|
|
}
|
|
}
|
|
|
|
let horizontalInset = layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right
|
|
let textConstrainedSize = CGSize(width: min(maxTextWidth, constrainedSize.width - horizontalInset), height: constrainedSize.height)
|
|
|
|
var edited = false
|
|
if item.attributes.updatingMedia != nil {
|
|
edited = true
|
|
}
|
|
var viewCount: Int?
|
|
var dateReplies = 0
|
|
var dateReactionsAndPeers = mergedMessageReactionsAndPeers(accountPeer: item.associatedData.accountPeer, message: item.topMessage)
|
|
if item.message.isRestricted(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) {
|
|
dateReactionsAndPeers = ([], [])
|
|
}
|
|
|
|
for attribute in item.message.attributes {
|
|
if let attribute = attribute as? EditedMessageAttribute {
|
|
edited = !attribute.isHidden
|
|
} else if let attribute = attribute as? ViewCountMessageAttribute {
|
|
viewCount = attribute.count
|
|
} else if let attribute = attribute as? ReplyThreadMessageAttribute, case .peer = item.chatLocation {
|
|
if let channel = item.message.peers[item.message.id.peerId] as? TelegramChannel, case .group = channel.info {
|
|
dateReplies = Int(attribute.count)
|
|
}
|
|
}
|
|
}
|
|
|
|
let dateFormat: MessageTimestampStatusFormat
|
|
if item.presentationData.isPreview {
|
|
dateFormat = .full
|
|
} else if let subject = item.associatedData.subject, case .messageOptions = subject {
|
|
dateFormat = .minimal
|
|
} else {
|
|
dateFormat = .regular
|
|
}
|
|
let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: item.message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, format: dateFormat, associatedData: item.associatedData)
|
|
|
|
let statusType: ChatMessageDateAndStatusType?
|
|
var displayStatus = false
|
|
switch position {
|
|
case let .linear(_, neighbor):
|
|
if case .None = neighbor {
|
|
displayStatus = true
|
|
} else if case .Neighbour(true, _, _) = neighbor {
|
|
displayStatus = true
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
if displayStatus {
|
|
if incoming {
|
|
statusType = .BubbleIncoming
|
|
} else {
|
|
if message.flags.contains(.Failed) {
|
|
statusType = .BubbleOutgoing(.Failed)
|
|
} else if (message.flags.isSending && !message.isSentOrAcknowledged) || item.attributes.updatingMedia != nil {
|
|
statusType = .BubbleOutgoing(.Sending)
|
|
} else {
|
|
statusType = .BubbleOutgoing(.Sent(read: item.read))
|
|
}
|
|
}
|
|
} else {
|
|
statusType = nil
|
|
}
|
|
|
|
var rawText: String
|
|
var attributedText: NSAttributedString
|
|
var messageEntities: [MessageTextEntity]?
|
|
|
|
var mediaDuration: Double? = nil
|
|
var isSeekableWebMedia = false
|
|
var isUnsupportedMedia = false
|
|
var story: Stories.Item?
|
|
for media in item.message.media {
|
|
if let file = media as? TelegramMediaFile, let duration = file.duration {
|
|
mediaDuration = Double(duration)
|
|
}
|
|
if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content, webEmbedType(content: content).supportsSeeking {
|
|
isSeekableWebMedia = true
|
|
} else if media is TelegramMediaUnsupported {
|
|
isUnsupportedMedia = true
|
|
} else if let storyMedia = media as? TelegramMediaStory {
|
|
if let value = item.message.associatedStories[storyMedia.storyId]?.get(Stories.StoredItem.self) {
|
|
if case let .item(storyValue) = value {
|
|
story = storyValue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var isTranslating = false
|
|
if let story {
|
|
rawText = story.text
|
|
messageEntities = story.entities
|
|
} else if isUnsupportedMedia {
|
|
rawText = item.presentationData.strings.Conversation_UnsupportedMediaPlaceholder
|
|
messageEntities = [MessageTextEntity(range: 0..<rawText.count, type: .Italic)]
|
|
} else {
|
|
if let updatingMedia = item.attributes.updatingMedia {
|
|
rawText = updatingMedia.text
|
|
} else {
|
|
rawText = item.message.text
|
|
}
|
|
|
|
for attribute in item.message.attributes {
|
|
if let attribute = attribute as? TextEntitiesMessageAttribute {
|
|
messageEntities = attribute.entities
|
|
} else if mediaDuration == nil, let attribute = attribute as? ReplyMessageAttribute {
|
|
if let replyMessage = item.message.associatedMessages[attribute.messageId] {
|
|
for media in replyMessage.media {
|
|
if let file = media as? TelegramMediaFile, let duration = file.duration {
|
|
mediaDuration = Double(duration)
|
|
}
|
|
if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content, webEmbedType(content: content).supportsSeeking {
|
|
isSeekableWebMedia = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let updatingMedia = item.attributes.updatingMedia {
|
|
messageEntities = updatingMedia.entities?.entities ?? []
|
|
}
|
|
|
|
if let subject = item.associatedData.subject, case .messageOptions = subject {
|
|
} else if let translateToLanguage = item.associatedData.translateToLanguage, !item.message.text.isEmpty && incoming {
|
|
isTranslating = true
|
|
for attribute in item.message.attributes {
|
|
if let attribute = attribute as? TranslationMessageAttribute, !attribute.text.isEmpty, attribute.toLang == translateToLanguage {
|
|
rawText = attribute.text
|
|
messageEntities = attribute.entities
|
|
isTranslating = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var entities: [MessageTextEntity]?
|
|
var updatedCachedChatMessageText: CachedChatMessageText?
|
|
if let cached = currentCachedChatMessageText, cached.matches(text: rawText, inputEntities: messageEntities) {
|
|
entities = cached.entities
|
|
} else {
|
|
entities = messageEntities
|
|
|
|
if entities == nil && (mediaDuration != nil || isSeekableWebMedia) {
|
|
entities = []
|
|
}
|
|
|
|
if let entitiesValue = entities {
|
|
var enabledTypes: EnabledEntityTypes = .all
|
|
if mediaDuration != nil || isSeekableWebMedia {
|
|
enabledTypes.insert(.timecode)
|
|
if mediaDuration == nil {
|
|
mediaDuration = 60.0 * 60.0 * 24.0
|
|
}
|
|
}
|
|
if let result = addLocallyGeneratedEntities(rawText, enabledTypes: enabledTypes, entities: entitiesValue, mediaDuration: mediaDuration) {
|
|
entities = result
|
|
}
|
|
} else {
|
|
var generateEntities = false
|
|
for media in message.media {
|
|
if media is TelegramMediaImage || media is TelegramMediaFile {
|
|
generateEntities = true
|
|
break
|
|
}
|
|
}
|
|
if message.id.peerId.namespace == Namespaces.Peer.SecretChat {
|
|
generateEntities = true
|
|
}
|
|
if generateEntities {
|
|
let parsedEntities = generateTextEntities(rawText, enabledTypes: .all)
|
|
if !parsedEntities.isEmpty {
|
|
entities = parsedEntities
|
|
}
|
|
}
|
|
}
|
|
|
|
if !item.associatedData.hasBots {
|
|
messageEntities = messageEntities?.filter { $0.type != .BotCommand }
|
|
entities = entities?.filter { $0.type != .BotCommand }
|
|
}
|
|
|
|
updatedCachedChatMessageText = CachedChatMessageText(text: rawText, inputEntities: messageEntities, entities: entities)
|
|
}
|
|
|
|
|
|
let messageTheme = incoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing
|
|
|
|
let textFont = item.presentationData.messageFont
|
|
|
|
var codeHighlightSpecs: [CachedMessageSyntaxHighlight.Spec] = []
|
|
var cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight?
|
|
|
|
if let entities = entities {
|
|
var underlineLinks = true
|
|
if !messageTheme.primaryTextColor.isEqual(messageTheme.linkTextColor) {
|
|
underlineLinks = false
|
|
}
|
|
|
|
let author = item.message.author
|
|
let mainColor: UIColor
|
|
var secondaryColor: UIColor? = nil
|
|
var tertiaryColor: UIColor? = nil
|
|
|
|
let nameColors = author?.nameColor.flatMap { item.context.peerNameColors.get($0, dark: item.presentationData.theme.theme.overallDarkAppearance) }
|
|
let codeBlockTitleColor: UIColor
|
|
let codeBlockAccentColor: UIColor
|
|
let codeBlockBackgroundColor: UIColor
|
|
if !incoming {
|
|
mainColor = messageTheme.accentTextColor
|
|
if let _ = nameColors?.secondary {
|
|
secondaryColor = .clear
|
|
}
|
|
if let _ = nameColors?.tertiary {
|
|
tertiaryColor = .clear
|
|
}
|
|
|
|
if item.presentationData.theme.theme.overallDarkAppearance {
|
|
codeBlockTitleColor = .white
|
|
codeBlockAccentColor = UIColor(white: 1.0, alpha: 0.5)
|
|
codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.25)
|
|
} else {
|
|
codeBlockTitleColor = mainColor
|
|
codeBlockAccentColor = mainColor
|
|
codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1)
|
|
}
|
|
} else {
|
|
let authorNameColor = nameColors?.main
|
|
secondaryColor = nameColors?.secondary
|
|
tertiaryColor = nameColors?.tertiary
|
|
|
|
if let authorNameColor {
|
|
mainColor = authorNameColor
|
|
} else {
|
|
mainColor = messageTheme.accentTextColor
|
|
}
|
|
|
|
codeBlockTitleColor = mainColor
|
|
codeBlockAccentColor = mainColor
|
|
|
|
if item.presentationData.theme.theme.overallDarkAppearance {
|
|
codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.65)
|
|
} else {
|
|
codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.05)
|
|
}
|
|
}
|
|
|
|
codeHighlightSpecs = extractMessageSyntaxHighlightSpecs(text: rawText, entities: entities)
|
|
|
|
if !codeHighlightSpecs.isEmpty {
|
|
for attribute in message.attributes {
|
|
if let attribute = attribute as? DerivedDataMessageAttribute {
|
|
if let value = attribute.data["code"]?.get(CachedMessageSyntaxHighlight.self) {
|
|
cachedMessageSyntaxHighlight = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
attributedText = stringWithAppliedEntities(rawText, entities: entities, baseColor: messageTheme.primaryTextColor, linkColor: messageTheme.linkTextColor, baseQuoteTintColor: mainColor, baseQuoteSecondaryTintColor: secondaryColor, baseQuoteTertiaryTintColor: tertiaryColor, codeBlockTitleColor: codeBlockTitleColor, codeBlockAccentColor: codeBlockAccentColor, codeBlockBackgroundColor: codeBlockBackgroundColor, baseFont: textFont, linkFont: textFont, boldFont: item.presentationData.messageBoldFont, italicFont: item.presentationData.messageItalicFont, boldItalicFont: item.presentationData.messageBoldItalicFont, fixedFont: item.presentationData.messageFixedFont, blockQuoteFont: item.presentationData.messageBlockQuoteFont, underlineLinks: underlineLinks, message: item.message, adjustQuoteFontSize: true, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight)
|
|
} else if !rawText.isEmpty {
|
|
attributedText = NSAttributedString(string: rawText, font: textFont, textColor: messageTheme.primaryTextColor)
|
|
} else {
|
|
attributedText = NSAttributedString(string: " ", font: textFont, textColor: messageTheme.primaryTextColor)
|
|
}
|
|
|
|
if let entities = entities {
|
|
let updatedString = NSMutableAttributedString(attributedString: attributedText)
|
|
|
|
for entity in entities.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
|
|
guard case let .CustomEmoji(_, fileId) = entity.type else {
|
|
continue
|
|
}
|
|
|
|
let range = NSRange(location: entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound)
|
|
|
|
let currentDict = updatedString.attributes(at: range.lowerBound, effectiveRange: nil)
|
|
var updatedAttributes: [NSAttributedString.Key: Any] = currentDict
|
|
//updatedAttributes[NSAttributedString.Key.foregroundColor] = UIColor.clear.cgColor
|
|
updatedAttributes[ChatTextInputAttributes.customEmoji] = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: item.message.associatedMedia[MediaId(namespace: Namespaces.Media.CloudFile, id: fileId)] as? TelegramMediaFile)
|
|
|
|
let insertString = NSAttributedString(string: updatedString.attributedSubstring(from: range).string, attributes: updatedAttributes)
|
|
updatedString.replaceCharacters(in: range, with: insertString)
|
|
}
|
|
attributedText = updatedString
|
|
}
|
|
|
|
var customTruncationToken: NSAttributedString?
|
|
var maximumNumberOfLines: Int = 0
|
|
if item.presentationData.isPreview {
|
|
if item.message.groupingKey != nil {
|
|
maximumNumberOfLines = 6
|
|
} else if let image = item.message.media.first(where: { $0 is TelegramMediaImage }) as? TelegramMediaImage, let dimensions = image.representations.first?.dimensions {
|
|
if dimensions.width > dimensions.height {
|
|
maximumNumberOfLines = 9
|
|
} else {
|
|
maximumNumberOfLines = 6
|
|
}
|
|
} else if let file = item.message.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile, file.isVideo || file.isAnimated, let dimensions = file.dimensions {
|
|
if dimensions.width > dimensions.height {
|
|
maximumNumberOfLines = 9
|
|
} else {
|
|
maximumNumberOfLines = 6
|
|
}
|
|
} else if let _ = item.message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage {
|
|
maximumNumberOfLines = 9
|
|
} else {
|
|
maximumNumberOfLines = 12
|
|
}
|
|
|
|
let truncationToken = NSMutableAttributedString()
|
|
truncationToken.append(NSAttributedString(string: "\u{2026} ", font: textFont, textColor: messageTheme.primaryTextColor))
|
|
truncationToken.append(NSAttributedString(string: item.presentationData.strings.Conversation_ReadMore, font: textFont, textColor: messageTheme.accentTextColor))
|
|
customTruncationToken = truncationToken
|
|
}
|
|
|
|
let textInsets = UIEdgeInsets(top: 2.0, left: 2.0, bottom: 5.0, right: 2.0)
|
|
let (textLayout, textApply) = textLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: maximumNumberOfLines, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets, lineColor: messageTheme.accentControlColor, customTruncationToken: customTruncationToken))
|
|
|
|
let spoilerTextLayoutAndApply: (TextNodeLayout, (TextNodeWithEntities.Arguments?) -> TextNodeWithEntities)?
|
|
if !textLayout.spoilers.isEmpty {
|
|
spoilerTextLayoutAndApply = spoilerTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: maximumNumberOfLines, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets, lineColor: messageTheme.accentControlColor, displaySpoilers: true, displayEmbeddedItemsUnderSpoilers: true))
|
|
} else {
|
|
spoilerTextLayoutAndApply = nil
|
|
}
|
|
|
|
var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode))?
|
|
if let statusType = statusType {
|
|
var isReplyThread = false
|
|
if case .replyThread = item.chatLocation {
|
|
isReplyThread = true
|
|
}
|
|
|
|
let trailingWidthToMeasure: CGFloat
|
|
if textLayout.hasRTL {
|
|
trailingWidthToMeasure = 10000.0
|
|
} else {
|
|
trailingWidthToMeasure = textLayout.trailingLineWidth
|
|
}
|
|
|
|
let dateLayoutInput: ChatMessageDateAndStatusNode.LayoutInput
|
|
dateLayoutInput = .trailingContent(contentWidth: trailingWidthToMeasure, reactionSettings: item.presentationData.isPreview ? nil : ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: shouldDisplayInlineDateReactions(message: item.message, isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions), preferAdditionalInset: false))
|
|
|
|
statusSuggestedWidthAndContinue = statusLayout(ChatMessageDateAndStatusNode.Arguments(
|
|
context: item.context,
|
|
presentationData: item.presentationData,
|
|
edited: edited && !item.presentationData.isPreview,
|
|
impressionCount: !item.presentationData.isPreview ? viewCount : nil,
|
|
dateText: dateText,
|
|
type: statusType,
|
|
layoutInput: dateLayoutInput,
|
|
constrainedSize: textConstrainedSize,
|
|
availableReactions: item.associatedData.availableReactions,
|
|
reactions: dateReactionsAndPeers.reactions,
|
|
reactionPeers: dateReactionsAndPeers.peers,
|
|
displayAllReactionPeers: item.message.id.peerId.namespace == Namespaces.Peer.CloudUser,
|
|
isSavedMessages: item.chatLocation.peerId == item.context.account.peerId,
|
|
replyCount: dateReplies,
|
|
isPinned: item.message.tags.contains(.pinned) && (!item.associatedData.isInPinnedListMode || isReplyThread),
|
|
hasAutoremove: item.message.isSelfExpiring,
|
|
canViewReactionList: canViewMessageReactionList(message: item.message),
|
|
animationCache: item.controllerInteraction.presentationContext.animationCache,
|
|
animationRenderer: item.controllerInteraction.presentationContext.animationRenderer
|
|
))
|
|
}
|
|
|
|
var textFrame = CGRect(origin: CGPoint(x: -textInsets.left, y: -textInsets.top), size: textLayout.size)
|
|
var textFrameWithoutInsets = CGRect(origin: CGPoint(x: textFrame.origin.x + textInsets.left, y: textFrame.origin.y + textInsets.top), size: CGSize(width: textFrame.width - textInsets.left - textInsets.right, height: textFrame.height - textInsets.top - textInsets.bottom))
|
|
|
|
textFrame = textFrame.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: topInset)
|
|
textFrameWithoutInsets = textFrameWithoutInsets.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: topInset)
|
|
|
|
var suggestedBoundingWidth: CGFloat = textFrameWithoutInsets.width
|
|
if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue {
|
|
suggestedBoundingWidth = max(suggestedBoundingWidth, statusSuggestedWidthAndContinue.0)
|
|
}
|
|
let sideInsets = layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right
|
|
suggestedBoundingWidth += sideInsets
|
|
|
|
return (suggestedBoundingWidth, { boundingWidth in
|
|
var boundingSize: CGSize
|
|
|
|
let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth - sideInsets)
|
|
|
|
boundingSize = textFrameWithoutInsets.size
|
|
if let statusSizeAndApply = statusSizeAndApply {
|
|
boundingSize.height += statusSizeAndApply.0.height
|
|
}
|
|
|
|
boundingSize.width += layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right
|
|
|
|
boundingSize.height += topInset + bottomInset
|
|
|
|
return (boundingSize, { [weak self] animation, synchronousLoads, _ in
|
|
if let strongSelf = self {
|
|
strongSelf.item = item
|
|
if let updatedCachedChatMessageText = updatedCachedChatMessageText {
|
|
strongSelf.cachedChatMessageText = updatedCachedChatMessageText
|
|
}
|
|
|
|
strongSelf.textNode.textNode.displaysAsynchronously = !item.presentationData.isPreview
|
|
strongSelf.containerNode.frame = CGRect(origin: CGPoint(), size: boundingSize)
|
|
|
|
let cachedLayout = strongSelf.textNode.textNode.cachedLayout
|
|
|
|
if case .System = animation {
|
|
if let cachedLayout = cachedLayout {
|
|
if !cachedLayout.areLinesEqual(to: textLayout) {
|
|
if let textContents = strongSelf.textNode.textNode.contents {
|
|
let fadeNode = ASDisplayNode()
|
|
fadeNode.displaysAsynchronously = false
|
|
fadeNode.contents = textContents
|
|
fadeNode.frame = strongSelf.textNode.textNode.frame
|
|
fadeNode.isLayerBacked = true
|
|
strongSelf.containerNode.addSubnode(fadeNode)
|
|
fadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak fadeNode] _ in
|
|
fadeNode?.removeFromSupernode()
|
|
})
|
|
strongSelf.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let _ = textApply(TextNodeWithEntities.Arguments(context: item.context, cache: item.controllerInteraction.presentationContext.animationCache, renderer: item.controllerInteraction.presentationContext.animationRenderer, placeholderColor: messageTheme.mediaPlaceholderColor, attemptSynchronous: synchronousLoads))
|
|
animation.animator.updateFrame(layer: strongSelf.textNode.textNode.layer, frame: textFrame, completion: nil)
|
|
|
|
if let (_, spoilerTextApply) = spoilerTextLayoutAndApply {
|
|
let spoilerTextNode = spoilerTextApply(TextNodeWithEntities.Arguments(context: item.context, cache: item.controllerInteraction.presentationContext.animationCache, renderer: item.controllerInteraction.presentationContext.animationRenderer, placeholderColor: messageTheme.mediaPlaceholderColor, attemptSynchronous: synchronousLoads))
|
|
if strongSelf.spoilerTextNode == nil {
|
|
spoilerTextNode.textNode.alpha = 0.0
|
|
spoilerTextNode.textNode.isUserInteractionEnabled = false
|
|
spoilerTextNode.textNode.contentMode = .topLeft
|
|
spoilerTextNode.textNode.contentsScale = UIScreenScale
|
|
spoilerTextNode.textNode.displaysAsynchronously = false
|
|
strongSelf.containerNode.insertSubnode(spoilerTextNode.textNode, aboveSubnode: strongSelf.textAccessibilityOverlayNode)
|
|
|
|
strongSelf.spoilerTextNode = spoilerTextNode
|
|
}
|
|
|
|
strongSelf.spoilerTextNode?.textNode.frame = textFrame
|
|
|
|
let dustNode: InvisibleInkDustNode
|
|
if let current = strongSelf.dustNode {
|
|
dustNode = current
|
|
} else {
|
|
dustNode = InvisibleInkDustNode(textNode: spoilerTextNode.textNode, enableAnimations: item.context.sharedContext.energyUsageSettings.fullTranslucency && !item.presentationData.isPreview)
|
|
strongSelf.dustNode = dustNode
|
|
strongSelf.containerNode.insertSubnode(dustNode, aboveSubnode: spoilerTextNode.textNode)
|
|
}
|
|
dustNode.frame = textFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 3.0)
|
|
dustNode.update(size: dustNode.frame.size, color: messageTheme.secondaryTextColor, textColor: messageTheme.primaryTextColor, rects: textLayout.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: textLayout.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) })
|
|
} else if let spoilerTextNode = strongSelf.spoilerTextNode {
|
|
strongSelf.spoilerTextNode = nil
|
|
spoilerTextNode.textNode.removeFromSupernode()
|
|
|
|
if let dustNode = strongSelf.dustNode {
|
|
strongSelf.dustNode = nil
|
|
dustNode.removeFromSupernode()
|
|
}
|
|
}
|
|
|
|
switch strongSelf.visibility {
|
|
case .none:
|
|
strongSelf.textNode.visibilityRect = nil
|
|
strongSelf.spoilerTextNode?.visibilityRect = nil
|
|
case let .visible(_, subRect):
|
|
var subRect = subRect
|
|
subRect.origin.x = 0.0
|
|
subRect.size.width = 10000.0
|
|
strongSelf.textNode.visibilityRect = subRect
|
|
strongSelf.spoilerTextNode?.visibilityRect = subRect
|
|
}
|
|
|
|
if let textSelectionNode = strongSelf.textSelectionNode {
|
|
let shouldUpdateLayout = textSelectionNode.frame.size != textFrame.size
|
|
textSelectionNode.frame = textFrame
|
|
textSelectionNode.highlightAreaNode.frame = textFrame
|
|
if shouldUpdateLayout {
|
|
textSelectionNode.updateLayout()
|
|
}
|
|
}
|
|
strongSelf.textAccessibilityOverlayNode.frame = textFrame
|
|
strongSelf.textAccessibilityOverlayNode.cachedLayout = textLayout
|
|
|
|
strongSelf.updateIsTranslating(isTranslating)
|
|
|
|
if let statusSizeAndApply {
|
|
let statusNode = statusSizeAndApply.1(strongSelf.statusNode == nil ? .None : animation)
|
|
let statusFrame = CGRect(origin: CGPoint(x: textFrameWithoutInsets.minX, y: textFrameWithoutInsets.maxY), size: statusSizeAndApply.0)
|
|
|
|
if strongSelf.statusNode !== statusNode {
|
|
strongSelf.statusNode?.removeFromSupernode()
|
|
strongSelf.statusNode = statusNode
|
|
|
|
strongSelf.addSubnode(statusNode)
|
|
|
|
statusNode.reactionSelected = { [weak strongSelf] value in
|
|
guard let strongSelf, let item = strongSelf.item else {
|
|
return
|
|
}
|
|
item.controllerInteraction.updateMessageReaction(item.message, .reaction(value))
|
|
}
|
|
statusNode.openReactionPreview = { [weak strongSelf] gesture, sourceNode, value in
|
|
guard let strongSelf, let item = strongSelf.item else {
|
|
gesture?.cancel()
|
|
return
|
|
}
|
|
|
|
item.controllerInteraction.openMessageReactionContextMenu(item.topMessage, sourceNode, gesture, value)
|
|
}
|
|
statusNode.frame = statusFrame
|
|
} else {
|
|
animation.animator.updateFrame(layer: statusNode.layer, frame: statusFrame, completion: nil)
|
|
}
|
|
} else if let statusNode = strongSelf.statusNode {
|
|
strongSelf.statusNode = nil
|
|
statusNode.removeFromSupernode()
|
|
}
|
|
|
|
if let forwardInfo = item.message.forwardInfo, forwardInfo.flags.contains(.isImported), let statusNode = strongSelf.statusNode {
|
|
statusNode.pressed = {
|
|
guard let strongSelf = self, let statusNode = strongSelf.statusNode else {
|
|
return
|
|
}
|
|
item.controllerInteraction.displayImportedMessageTooltip(statusNode)
|
|
}
|
|
} else {
|
|
strongSelf.statusNode?.pressed = nil
|
|
}
|
|
|
|
if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject {
|
|
if case let .reply(info) = info {
|
|
if strongSelf.textSelectionNode == nil {
|
|
strongSelf.updateIsExtractedToContextPreview(true)
|
|
if let initialQuote = info.quote, item.message.id == initialQuote.messageId {
|
|
let nsString = item.message.text as NSString
|
|
let subRange = nsString.range(of: initialQuote.text)
|
|
if subRange.location != NSNotFound {
|
|
strongSelf.beginTextSelection(range: subRange, displayMenu: true)
|
|
}
|
|
}
|
|
|
|
if strongSelf.textSelectionState == nil {
|
|
if let textSelectionNode = strongSelf.textSelectionNode {
|
|
let range = textSelectionNode.getSelection()
|
|
strongSelf.textSelectionState = Promise(strongSelf.getSelectionState(range: range))
|
|
} else {
|
|
strongSelf.textSelectionState = Promise(strongSelf.getSelectionState(range: nil))
|
|
}
|
|
}
|
|
if let textSelectionState = strongSelf.textSelectionState {
|
|
info.selectionState.set(textSelectionState.get())
|
|
}
|
|
}
|
|
} else if case let .link(link) = info {
|
|
if strongSelf.linkPreviewOptionsDisposable == nil {
|
|
strongSelf.linkPreviewOptionsDisposable = (link.options
|
|
|> deliverOnMainQueue).startStrict(next: { [weak strongSelf] options in
|
|
guard let strongSelf else {
|
|
return
|
|
}
|
|
|
|
if options.hasAlternativeLinks {
|
|
strongSelf.linkPreviewHighlightText = options.url
|
|
strongSelf.updateLinkPreviewTextHighlightState(text: strongSelf.linkPreviewHighlightText)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
strongSelf.updateLinkProgressState()
|
|
if let linkPreviewHighlightText = strongSelf.linkPreviewHighlightText {
|
|
strongSelf.updateLinkPreviewTextHighlightState(text: linkPreviewHighlightText)
|
|
}
|
|
|
|
if !codeHighlightSpecs.isEmpty {
|
|
if let current = strongSelf.codeHighlightState, current.id == message.id, current.specs == codeHighlightSpecs {
|
|
} else {
|
|
if let codeHighlightState = strongSelf.codeHighlightState {
|
|
strongSelf.codeHighlightState = nil
|
|
codeHighlightState.disposable.dispose()
|
|
}
|
|
|
|
let disposable = MetaDisposable()
|
|
strongSelf.codeHighlightState = (message.id, codeHighlightSpecs, disposable)
|
|
disposable.set(asyncUpdateMessageSyntaxHighlight(engine: item.context.engine, messageId: message.id, current: cachedMessageSyntaxHighlight, specs: codeHighlightSpecs).startStrict(completed: {
|
|
}))
|
|
}
|
|
} else if let codeHighlightState = strongSelf.codeHighlightState {
|
|
strongSelf.codeHighlightState = nil
|
|
codeHighlightState.disposable.dispose()
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
override public func animateInsertion(_ currentTimestamp: Double, duration: Double) {
|
|
self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
self.statusNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
}
|
|
|
|
override public func animateAdded(_ currentTimestamp: Double, duration: Double) {
|
|
self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
self.statusNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
}
|
|
|
|
override public func animateRemoved(_ currentTimestamp: Double, duration: Double) {
|
|
self.textNode.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
|
|
self.statusNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
|
|
}
|
|
|
|
override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction {
|
|
if case .tap = gesture {
|
|
} else {
|
|
if let item = self.item, let subject = item.associatedData.subject, case .messageOptions = subject {
|
|
return ChatMessageBubbleContentTapAction(content: .none)
|
|
}
|
|
}
|
|
|
|
let textNodeFrame = self.textNode.textNode.frame
|
|
if let (index, attributes) = self.textNode.textNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY)) {
|
|
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)], !(self.dustNode?.isRevealed ?? true) {
|
|
return ChatMessageBubbleContentTapAction(content: .none)
|
|
} else if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String {
|
|
var concealed = true
|
|
var urlRange: NSRange?
|
|
if let (attributeText, fullText, urlRangeValue) = self.textNode.textNode.attributeSubstringWithRange(name: TelegramTextAttributes.URL, index: index) {
|
|
urlRange = urlRangeValue
|
|
concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText)
|
|
}
|
|
return ChatMessageBubbleContentTapAction(content: .url(ChatMessageBubbleContentTapAction.Url(url: url, concealed: concealed)), activate: { [weak self] in
|
|
guard let self else {
|
|
return nil
|
|
}
|
|
|
|
let promise = Promise<Bool>()
|
|
|
|
self.linkProgressDisposable?.dispose()
|
|
|
|
if self.linkProgressRange != nil {
|
|
self.linkProgressRange = nil
|
|
self.updateLinkProgressState()
|
|
}
|
|
|
|
self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let updatedRange: NSRange? = value ? urlRange : nil
|
|
if self.linkProgressRange != updatedRange {
|
|
self.linkProgressRange = updatedRange
|
|
self.updateLinkProgressState()
|
|
}
|
|
})
|
|
|
|
return promise
|
|
})
|
|
} else if let peerMention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention {
|
|
return ChatMessageBubbleContentTapAction(content: .peerMention(peerId: peerMention.peerId, mention: peerMention.mention, openProfile: false))
|
|
} else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String {
|
|
var urlRange: NSRange?
|
|
if let (_, _, urlRangeValue) = self.textNode.textNode.attributeSubstringWithRange(name: TelegramTextAttributes.PeerTextMention, index: index) {
|
|
urlRange = urlRangeValue
|
|
}
|
|
|
|
return ChatMessageBubbleContentTapAction(content: .textMention(peerName), activate: { [weak self] in
|
|
guard let self else {
|
|
return nil
|
|
}
|
|
|
|
let promise = Promise<Bool>()
|
|
|
|
self.linkProgressDisposable?.dispose()
|
|
|
|
if self.linkProgressRange != nil {
|
|
self.linkProgressRange = nil
|
|
self.updateLinkProgressState()
|
|
}
|
|
|
|
self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let updatedRange: NSRange? = value ? urlRange : nil
|
|
if self.linkProgressRange != updatedRange {
|
|
self.linkProgressRange = updatedRange
|
|
self.updateLinkProgressState()
|
|
}
|
|
})
|
|
|
|
return promise
|
|
})
|
|
} else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String {
|
|
return ChatMessageBubbleContentTapAction(content: .botCommand(botCommand))
|
|
} else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag {
|
|
return ChatMessageBubbleContentTapAction(content: .hashtag(hashtag.peerName, hashtag.hashtag))
|
|
} else if let timecode = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Timecode)] as? TelegramTimecode {
|
|
return ChatMessageBubbleContentTapAction(content: .timecode(timecode.time, timecode.text))
|
|
} else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String {
|
|
return ChatMessageBubbleContentTapAction(content: .bankCard(bankCard))
|
|
} else if let pre = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Pre)] as? String {
|
|
return ChatMessageBubbleContentTapAction(content: .copy(pre))
|
|
} else if let code = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Code)] as? String {
|
|
return ChatMessageBubbleContentTapAction(content: .copy(code))
|
|
} else if let emoji = attributes[NSAttributedString.Key(rawValue: ChatTextInputAttributes.customEmoji.rawValue)] as? ChatTextInputTextCustomEmojiAttribute, let file = emoji.file {
|
|
return ChatMessageBubbleContentTapAction(content: .customEmoji(file))
|
|
} else {
|
|
if let item = self.item, item.message.text.count == 1, !item.presentationData.largeEmoji {
|
|
let (emoji, fitz) = item.message.text.basicEmoji
|
|
var emojiFile: TelegramMediaFile?
|
|
|
|
emojiFile = item.associatedData.animatedEmojiStickers[emoji]?.first?.file
|
|
if emojiFile == nil {
|
|
emojiFile = item.associatedData.animatedEmojiStickers[emoji.strippedEmoji]?.first?.file
|
|
}
|
|
|
|
if let emojiFile = emojiFile {
|
|
return ChatMessageBubbleContentTapAction(content: .largeEmoji(emoji, fitz, emojiFile))
|
|
} else {
|
|
return ChatMessageBubbleContentTapAction(content: .none)
|
|
}
|
|
} else {
|
|
return ChatMessageBubbleContentTapAction(content: .none)
|
|
}
|
|
}
|
|
} else {
|
|
if let statusNode = self.statusNode, let _ = statusNode.hitTest(self.view.convert(point, to: statusNode.view), with: nil) {
|
|
return ChatMessageBubbleContentTapAction(content: .ignore)
|
|
}
|
|
return ChatMessageBubbleContentTapAction(content: .none)
|
|
}
|
|
}
|
|
|
|
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
if let statusNode = self.statusNode, statusNode.supernode != nil, let result = statusNode.hitTest(self.view.convert(point, to: statusNode.view), with: event) {
|
|
return result
|
|
}
|
|
return super.hitTest(point, with: event)
|
|
}
|
|
|
|
private func updateIsTranslating(_ isTranslating: Bool) {
|
|
guard let item = self.item else {
|
|
return
|
|
}
|
|
let rects = self.textNode.textNode.rangeRects(in: NSRange(location: 0, length: self.textNode.textNode.cachedLayout?.attributedString?.length ?? 0))?.rects ?? []
|
|
if isTranslating, !rects.isEmpty {
|
|
let shimmeringNode: ShimmeringLinkNode
|
|
if let current = self.shimmeringNode {
|
|
shimmeringNode = current
|
|
} else {
|
|
shimmeringNode = ShimmeringLinkNode(color: item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.secondaryTextColor.withAlphaComponent(0.1) : item.presentationData.theme.theme.chat.message.outgoing.secondaryTextColor.withAlphaComponent(0.1))
|
|
shimmeringNode.updateRects(rects)
|
|
shimmeringNode.frame = self.textNode.textNode.frame
|
|
shimmeringNode.updateLayout(self.textNode.textNode.frame.size)
|
|
shimmeringNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
self.shimmeringNode = shimmeringNode
|
|
self.containerNode.insertSubnode(shimmeringNode, belowSubnode: self.textNode.textNode)
|
|
}
|
|
} else if let shimmeringNode = self.shimmeringNode {
|
|
self.shimmeringNode = nil
|
|
shimmeringNode.alpha = 0.0
|
|
shimmeringNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak shimmeringNode] _ in
|
|
shimmeringNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
}
|
|
override public func updateTouchesAtPoint(_ point: CGPoint?) {
|
|
if let item = self.item {
|
|
var rects: [CGRect]?
|
|
var spoilerRects: [CGRect]?
|
|
if let point = point {
|
|
let textNodeFrame = self.textNode.textNode.frame
|
|
if let (index, attributes) = self.textNode.textNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY)) {
|
|
let possibleNames: [String] = [
|
|
TelegramTextAttributes.URL,
|
|
TelegramTextAttributes.PeerMention,
|
|
TelegramTextAttributes.PeerTextMention,
|
|
TelegramTextAttributes.BotCommand,
|
|
TelegramTextAttributes.Hashtag,
|
|
TelegramTextAttributes.Timecode,
|
|
TelegramTextAttributes.BankCard
|
|
]
|
|
for name in possibleNames {
|
|
if let _ = attributes[NSAttributedString.Key(rawValue: name)] {
|
|
rects = self.textNode.textNode.attributeRects(name: name, at: index)
|
|
break
|
|
}
|
|
}
|
|
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)] {
|
|
spoilerRects = self.textNode.textNode.attributeRects(name: TelegramTextAttributes.Spoiler, at: index)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let spoilerRects = spoilerRects, !spoilerRects.isEmpty, let dustNode = self.dustNode, !dustNode.isRevealed {
|
|
|
|
} else if let rects = rects {
|
|
let linkHighlightingNode: LinkHighlightingNode
|
|
if let current = self.linkHighlightingNode {
|
|
linkHighlightingNode = current
|
|
} else {
|
|
linkHighlightingNode = LinkHighlightingNode(color: item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.linkHighlightColor : item.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor)
|
|
self.linkHighlightingNode = linkHighlightingNode
|
|
self.containerNode.insertSubnode(linkHighlightingNode, belowSubnode: self.textNode.textNode)
|
|
}
|
|
linkHighlightingNode.frame = self.textNode.textNode.frame
|
|
linkHighlightingNode.updateRects(rects)
|
|
} else if let linkHighlightingNode = self.linkHighlightingNode {
|
|
self.linkHighlightingNode = nil
|
|
linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in
|
|
linkHighlightingNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
override public func updateSearchTextHighlightState(text: String?, messages: [MessageIndex]?) {
|
|
guard let item = self.item else {
|
|
return
|
|
}
|
|
let rectsSet: [[CGRect]]
|
|
if let text = text, let messages = messages, !text.isEmpty, messages.contains(item.message.index) {
|
|
rectsSet = self.textNode.textNode.textRangesRects(text: text)
|
|
} else {
|
|
rectsSet = []
|
|
}
|
|
for i in 0 ..< rectsSet.count {
|
|
let rects = rectsSet[i]
|
|
let textHighlightNode: LinkHighlightingNode
|
|
if i < self.textHighlightingNodes.count {
|
|
textHighlightNode = self.textHighlightingNodes[i]
|
|
} else {
|
|
textHighlightNode = LinkHighlightingNode(color: item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.textHighlightColor : item.presentationData.theme.theme.chat.message.outgoing.textHighlightColor)
|
|
self.textHighlightingNodes.append(textHighlightNode)
|
|
self.containerNode.insertSubnode(textHighlightNode, belowSubnode: self.textNode.textNode)
|
|
}
|
|
textHighlightNode.frame = self.textNode.textNode.frame
|
|
textHighlightNode.updateRects(rects)
|
|
}
|
|
for i in (rectsSet.count ..< self.textHighlightingNodes.count).reversed() {
|
|
self.textHighlightingNodes[i].removeFromSupernode()
|
|
self.textHighlightingNodes.remove(at: i)
|
|
}
|
|
}
|
|
|
|
private func updateLinkPreviewTextHighlightState(text: String?) {
|
|
guard let item = self.item else {
|
|
return
|
|
}
|
|
|
|
var rectsSet: [[CGRect]] = []
|
|
if let text = text, !text.isEmpty, let cachedLayout = self.textNode.textNode.cachedLayout, let string = cachedLayout.attributedString?.string {
|
|
let nsString = string as NSString
|
|
let range = nsString.range(of: text)
|
|
if range.location != NSNotFound {
|
|
if let rects = cachedLayout.rangeRects(in: range)?.rects, !rects.isEmpty {
|
|
rectsSet = [rects]
|
|
}
|
|
}
|
|
}
|
|
for i in 0 ..< rectsSet.count {
|
|
let rects = rectsSet[i]
|
|
let textHighlightNode: LinkHighlightingNode
|
|
if i < self.linkPreviewHighlightingNodes.count {
|
|
textHighlightNode = self.linkPreviewHighlightingNodes[i]
|
|
} else {
|
|
textHighlightNode = LinkHighlightingNode(color: item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.linkHighlightColor.withMultipliedAlpha(0.5) : item.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor.withMultipliedAlpha(0.5))
|
|
self.linkPreviewHighlightingNodes.append(textHighlightNode)
|
|
self.containerNode.insertSubnode(textHighlightNode, belowSubnode: self.textNode.textNode)
|
|
}
|
|
textHighlightNode.frame = self.textNode.textNode.frame
|
|
textHighlightNode.updateRects(rects)
|
|
}
|
|
for i in (rectsSet.count ..< self.linkPreviewHighlightingNodes.count).reversed() {
|
|
self.linkPreviewHighlightingNodes[i].removeFromSupernode()
|
|
self.linkPreviewHighlightingNodes.remove(at: i)
|
|
}
|
|
}
|
|
|
|
private func updateLinkProgressState() {
|
|
guard let item = self.item else {
|
|
return
|
|
}
|
|
|
|
let range: NSRange = self.linkProgressRange ?? NSRange(location: NSNotFound, length: 0)
|
|
if range.location != NSNotFound {
|
|
let linkProgressView: TextLoadingEffectView
|
|
if let current = self.linkProgressView {
|
|
linkProgressView = current
|
|
} else {
|
|
linkProgressView = TextLoadingEffectView(frame: CGRect())
|
|
self.linkProgressView = linkProgressView
|
|
self.containerNode.view.addSubview(linkProgressView)
|
|
}
|
|
linkProgressView.frame = self.textNode.textNode.frame
|
|
|
|
let progressColor: UIColor = item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.linkHighlightColor : item.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor
|
|
|
|
linkProgressView.update(color: progressColor, textNode: self.textNode.textNode, range: range)
|
|
} else {
|
|
if let linkProgressView = self.linkProgressView {
|
|
self.linkProgressView = nil
|
|
linkProgressView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak linkProgressView] _ in
|
|
linkProgressView?.removeFromSuperview()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
public func animateQuoteTextHighlightIn(sourceFrame: CGRect, transition: ContainedViewLayoutTransition) -> CGRect? {
|
|
if let quoteHighlightingNode = self.quoteHighlightingNode {
|
|
var currentRect = CGRect()
|
|
for rect in quoteHighlightingNode.rects {
|
|
if currentRect.isEmpty {
|
|
currentRect = rect
|
|
} else {
|
|
currentRect = currentRect.union(rect)
|
|
}
|
|
}
|
|
if !currentRect.isEmpty {
|
|
currentRect = currentRect.insetBy(dx: -quoteHighlightingNode.inset, dy: -quoteHighlightingNode.inset)
|
|
let innerRect = currentRect.offsetBy(dx: quoteHighlightingNode.frame.minX, dy: quoteHighlightingNode.frame.minY)
|
|
|
|
quoteHighlightingNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, delay: 0.04)
|
|
|
|
let fromScale = CGPoint(x: sourceFrame.width / innerRect.width, y: sourceFrame.height / innerRect.height)
|
|
|
|
var fromTransform = CATransform3DIdentity
|
|
let fromOffset = CGPoint(x: sourceFrame.midX - innerRect.midX, y: sourceFrame.midY - innerRect.midY)
|
|
|
|
fromTransform = CATransform3DTranslate(fromTransform, fromOffset.x, fromOffset.y, 0.0)
|
|
|
|
fromTransform = CATransform3DTranslate(fromTransform, -quoteHighlightingNode.bounds.width * 0.5 + currentRect.midX, -quoteHighlightingNode.bounds.height * 0.5 + currentRect.midY, 0.0)
|
|
fromTransform = CATransform3DScale(fromTransform, fromScale.x, fromScale.y, 1.0)
|
|
fromTransform = CATransform3DTranslate(fromTransform, quoteHighlightingNode.bounds.width * 0.5 - currentRect.midX, quoteHighlightingNode.bounds.height * 0.5 - currentRect.midY, 0.0)
|
|
|
|
quoteHighlightingNode.transform = fromTransform
|
|
transition.updateTransform(node: quoteHighlightingNode, transform: CGAffineTransformIdentity)
|
|
|
|
return currentRect.offsetBy(dx: quoteHighlightingNode.frame.minX, dy: quoteHighlightingNode.frame.minY)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func getQuoteRect(quote: String, offset: Int?) -> CGRect? {
|
|
var rectsSet: [CGRect] = []
|
|
if !quote.isEmpty, let cachedLayout = self.textNode.textNode.cachedLayout, let string = cachedLayout.attributedString?.string {
|
|
|
|
let range = findQuoteRange(string: string, quoteText: quote, offset: offset)
|
|
if let range, let rects = cachedLayout.rangeRects(in: range)?.rects, !rects.isEmpty {
|
|
rectsSet = rects
|
|
}
|
|
}
|
|
if !rectsSet.isEmpty {
|
|
var currentRect = CGRect()
|
|
for rect in rectsSet {
|
|
if currentRect.isEmpty {
|
|
currentRect = rect
|
|
} else {
|
|
currentRect = currentRect.union(rect)
|
|
}
|
|
}
|
|
|
|
return currentRect.offsetBy(dx: self.textNode.textNode.frame.minX, dy: self.textNode.textNode.frame.minY)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
public func updateQuoteTextHighlightState(text: String?, offset: Int?, color: UIColor, animated: Bool) {
|
|
var rectsSet: [CGRect] = []
|
|
if let text = text, !text.isEmpty, let cachedLayout = self.textNode.textNode.cachedLayout, let string = cachedLayout.attributedString?.string {
|
|
|
|
let quoteRange = findQuoteRange(string: string, quoteText: text, offset: offset)
|
|
if let quoteRange, let rects = cachedLayout.rangeRects(in: quoteRange)?.rects, !rects.isEmpty {
|
|
rectsSet = rects
|
|
}
|
|
}
|
|
if !rectsSet.isEmpty {
|
|
let rects = rectsSet
|
|
let textHighlightNode: LinkHighlightingNode
|
|
if let current = self.quoteHighlightingNode {
|
|
textHighlightNode = current
|
|
} else {
|
|
textHighlightNode = LinkHighlightingNode(color: color)
|
|
self.quoteHighlightingNode = textHighlightNode
|
|
self.containerNode.insertSubnode(textHighlightNode, belowSubnode: self.textNode.textNode)
|
|
}
|
|
textHighlightNode.frame = self.textNode.textNode.frame
|
|
textHighlightNode.updateRects(rects)
|
|
} else {
|
|
if let quoteHighlightingNode = self.quoteHighlightingNode {
|
|
self.quoteHighlightingNode = nil
|
|
if animated {
|
|
quoteHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak quoteHighlightingNode] _ in
|
|
quoteHighlightingNode?.removeFromSupernode()
|
|
})
|
|
} else {
|
|
quoteHighlightingNode.removeFromSupernode()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override public func willUpdateIsExtractedToContextPreview(_ value: Bool) {
|
|
if !value {
|
|
if let textSelectionNode = self.textSelectionNode {
|
|
self.textSelectionNode = nil
|
|
textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
|
|
textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in
|
|
textSelectionNode?.highlightAreaNode.removeFromSupernode()
|
|
textSelectionNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
override public func updateIsExtractedToContextPreview(_ value: Bool) {
|
|
if value {
|
|
if self.textSelectionNode == nil, let item = self.item, let rootNode = item.controllerInteraction.chatControllerNode() {
|
|
let selectionColor: UIColor
|
|
let knobColor: UIColor
|
|
if item.message.effectivelyIncoming(item.context.account.peerId) {
|
|
selectionColor = item.presentationData.theme.theme.chat.message.incoming.textSelectionColor
|
|
knobColor = item.presentationData.theme.theme.chat.message.incoming.textSelectionKnobColor
|
|
} else {
|
|
selectionColor = item.presentationData.theme.theme.chat.message.outgoing.textSelectionColor
|
|
knobColor = item.presentationData.theme.theme.chat.message.outgoing.textSelectionKnobColor
|
|
}
|
|
|
|
let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: item.presentationData.theme.theme.overallDarkAppearance), strings: item.presentationData.strings, textNode: self.textNode.textNode, updateIsActive: { [weak self] value in
|
|
self?.updateIsTextSelectionActive?(value)
|
|
}, present: { [weak self] c, a in
|
|
guard let self, let item = self.item else {
|
|
return
|
|
}
|
|
|
|
if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info {
|
|
item.controllerInteraction.presentControllerInCurrent(c, a)
|
|
} else {
|
|
item.controllerInteraction.presentGlobalOverlayController(c, a)
|
|
}
|
|
}, rootNode: { [weak rootNode] in
|
|
return rootNode
|
|
}, performAction: { [weak self] text, action in
|
|
guard let strongSelf = self, let item = strongSelf.item else {
|
|
return
|
|
}
|
|
item.controllerInteraction.performTextSelectionAction(item.message, true, text, action)
|
|
})
|
|
textSelectionNode.updateRange = { [weak self] selectionRange in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
if let dustNode = strongSelf.dustNode, !dustNode.isRevealed, let textLayout = strongSelf.textNode.textNode.cachedLayout, !textLayout.spoilers.isEmpty, let selectionRange = selectionRange {
|
|
for (spoilerRange, _) in textLayout.spoilers {
|
|
if let intersection = selectionRange.intersection(spoilerRange), intersection.length > 0 {
|
|
dustNode.update(revealed: true)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if let textSelectionState = strongSelf.textSelectionState {
|
|
textSelectionState.set(.single(strongSelf.getSelectionState(range: selectionRange)))
|
|
}
|
|
}
|
|
|
|
let enableCopy = !item.associatedData.isCopyProtectionEnabled && !item.message.isCopyProtected()
|
|
textSelectionNode.enableCopy = enableCopy
|
|
|
|
var enableQuote = !item.message.text.isEmpty
|
|
var enableOtherActions = true
|
|
if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info {
|
|
enableOtherActions = false
|
|
} else if item.controllerInteraction.canSetupReply(item.message) == .reply {
|
|
//enableOtherActions = false
|
|
}
|
|
|
|
if !item.controllerInteraction.canSendMessages() && !enableCopy {
|
|
enableQuote = false
|
|
}
|
|
if item.message.id.peerId.namespace == Namespaces.Peer.SecretChat {
|
|
enableQuote = false
|
|
}
|
|
if item.message.containsSecretMedia {
|
|
enableQuote = false
|
|
}
|
|
|
|
textSelectionNode.enableQuote = enableQuote
|
|
textSelectionNode.enableTranslate = enableOtherActions
|
|
textSelectionNode.enableShare = enableOtherActions && enableCopy
|
|
textSelectionNode.menuSkipCoordnateConversion = !enableOtherActions
|
|
self.textSelectionNode = textSelectionNode
|
|
self.containerNode.addSubnode(textSelectionNode)
|
|
self.containerNode.insertSubnode(textSelectionNode.highlightAreaNode, belowSubnode: self.textNode.textNode)
|
|
textSelectionNode.frame = self.textNode.textNode.frame
|
|
textSelectionNode.highlightAreaNode.frame = self.textNode.textNode.frame
|
|
}
|
|
} else {
|
|
if let textSelectionNode = self.textSelectionNode {
|
|
self.textSelectionNode = nil
|
|
self.updateIsTextSelectionActive?(false)
|
|
textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
|
|
textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in
|
|
textSelectionNode?.highlightAreaNode.removeFromSupernode()
|
|
textSelectionNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
|
|
if let dustNode = self.dustNode, dustNode.isRevealed {
|
|
dustNode.update(revealed: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? {
|
|
if let statusNode = self.statusNode, !statusNode.isHidden {
|
|
return statusNode.reactionView(value: value)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
override public func getStatusNode() -> ASDisplayNode? {
|
|
return self.statusNode
|
|
}
|
|
|
|
public func animateFrom(sourceView: UIView, scrollOffset: CGFloat, widthDifference: CGFloat, transition: CombinedTransition) {
|
|
self.containerNode.view.addSubview(sourceView)
|
|
|
|
sourceView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak sourceView] _ in
|
|
sourceView?.removeFromSuperview()
|
|
})
|
|
self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.08)
|
|
|
|
let offset = CGPoint(
|
|
x: sourceView.frame.minX - (self.textNode.textNode.frame.minX - 0.0),
|
|
y: sourceView.frame.minY - (self.textNode.textNode.frame.minY - 3.0) - scrollOffset
|
|
)
|
|
|
|
transition.vertical.animatePositionAdditive(node: self.textNode.textNode, offset: offset)
|
|
transition.updatePosition(layer: sourceView.layer, position: CGPoint(x: sourceView.layer.position.x - offset.x, y: sourceView.layer.position.y - offset.y))
|
|
|
|
if let statusNode = self.statusNode {
|
|
statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
|
|
transition.horizontal.animatePositionAdditive(node: statusNode, offset: CGPoint(x: -widthDifference, y: 0.0))
|
|
}
|
|
}
|
|
|
|
public func beginTextSelection(range: NSRange?, displayMenu: Bool = true) {
|
|
guard let textSelectionNode = self.textSelectionNode else {
|
|
return
|
|
}
|
|
guard let string = self.textNode.textNode.cachedLayout?.attributedString else {
|
|
return
|
|
}
|
|
let nsString = string.string as NSString
|
|
let range = range ?? NSRange(location: 0, length: nsString.length)
|
|
textSelectionNode.setSelection(range: range, displayMenu: displayMenu)
|
|
}
|
|
|
|
public func cancelTextSelection() {
|
|
guard let textSelectionNode = self.textSelectionNode else {
|
|
return
|
|
}
|
|
textSelectionNode.cancelSelection()
|
|
}
|
|
|
|
private func getSelectionState(range: NSRange?) -> ChatControllerSubject.MessageOptionsInfo.SelectionState {
|
|
var quote: ChatControllerSubject.MessageOptionsInfo.Quote?
|
|
if let item = self.item, let range, let selection = self.getCurrentTextSelection(customRange: range) {
|
|
quote = ChatControllerSubject.MessageOptionsInfo.Quote(messageId: item.message.id, text: selection.text, offset: selection.offset)
|
|
}
|
|
return ChatControllerSubject.MessageOptionsInfo.SelectionState(canQuote: true, quote: quote)
|
|
}
|
|
|
|
public func getCurrentTextSelection(customRange: NSRange? = nil) -> (text: String, entities: [MessageTextEntity], offset: Int)? {
|
|
guard let textSelectionNode = self.textSelectionNode else {
|
|
return nil
|
|
}
|
|
guard let range = customRange ?? textSelectionNode.getSelection() else {
|
|
return nil
|
|
}
|
|
guard let item = self.item else {
|
|
return nil
|
|
}
|
|
guard let string = self.textNode.textNode.cachedLayout?.attributedString else {
|
|
return nil
|
|
}
|
|
let nsString = string.string as NSString
|
|
let substring = nsString.substring(with: range)
|
|
let offset = range.location
|
|
|
|
var entities: [MessageTextEntity] = []
|
|
if let textEntitiesAttribute = item.message.textEntitiesAttribute {
|
|
entities = messageTextEntitiesInRange(entities: textEntitiesAttribute.entities, range: range, onlyQuoteable: true)
|
|
}
|
|
|
|
return (substring, entities, offset)
|
|
}
|
|
|
|
public func animateClippingTransition(offset: CGFloat, animation: ListViewItemUpdateAnimation) {
|
|
self.containerNode.clipsToBounds = true
|
|
self.containerNode.bounds = CGRect(origin: CGPoint(x: 0.0, y: offset), size: self.containerNode.bounds.size)
|
|
self.containerNode.alpha = 0.0
|
|
animation.animator.updateAlpha(layer: self.containerNode.layer, alpha: 1.0, completion: nil)
|
|
animation.animator.updateBounds(layer: self.containerNode.layer, bounds: CGRect(origin: CGPoint(), size: self.containerNode.bounds.size), completion: { [weak self] completed in
|
|
guard let self, completed else {
|
|
return
|
|
}
|
|
self.containerNode.clipsToBounds = false
|
|
})
|
|
}
|
|
}
|