mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-06 03:33:41 +02:00
Design and plan, then implementation: include the details title in selectableTextItems, restore V2 text selection in the rich bubble, drop the InstantPageMultiTextAdapter init parameter-name split, and split LinkHighlightingNode modern-path groups on disjoint rects. Also includes the IP V2 inline-formula baseline alignment design doc.
886 lines
46 KiB
Swift
886 lines
46 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import TelegramCore
|
|
import SwiftSignalKit
|
|
import AccountContext
|
|
import ChatMessageBubbleContentNode
|
|
import ChatMessageDateAndStatusNode
|
|
import ChatMessageItemCommon
|
|
import ChatControllerInteraction
|
|
import InstantPageUI
|
|
import TelegramUIPreferences
|
|
import TextLoadingEffect
|
|
import TextSelectionNode
|
|
|
|
public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode {
|
|
public final class ContainerNode: ASDisplayNode {
|
|
}
|
|
|
|
private let containerNode: ContainerNode
|
|
public var statusNode: ChatMessageDateAndStatusNode?
|
|
// `init()` may run off the main thread; UIView construction must happen on the main thread.
|
|
// The page view is built lazily inside the apply closure (always main-thread) via ensurePageView().
|
|
private var pageView: InstantPageV2View?
|
|
// Tracks the message (id + stableVersion) baked into the current pageView's render context.
|
|
// The synthesized webpage uses a sentinel id (namespace 0, id 0) shared across all richText
|
|
// messages, so we key cache invalidation on the message itself. When the bubble is recycled
|
|
// with a different message we must discard pageView (render context is constructor-fixed).
|
|
private var pageViewMessageKey: (id: EngineMessage.Id, stableVersion: UInt32)?
|
|
private var currentPageLayout: (boundingWidth: CGFloat,
|
|
presentationThemeIdentity: ObjectIdentifier,
|
|
expandedDetails: [Int: Bool],
|
|
layout: InstantPageV2Layout)?
|
|
private var currentExpandedDetails: [Int: Bool] = [:]
|
|
private var linkProgressDisposable: Disposable?
|
|
private var linkProgressRects: [CGRect]?
|
|
private var linkHighlightingNode: LinkHighlightingNode?
|
|
private var linkProgressView: TextLoadingEffectView?
|
|
private var textSelectionAdapter: InstantPageMultiTextAdapter?
|
|
private var textSelectionNode: TextSelectionNode?
|
|
|
|
required public init() {
|
|
self.containerNode = ContainerNode()
|
|
self.containerNode.clipsToBounds = true
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.containerNode)
|
|
}
|
|
|
|
/// Builds (or reuses) the V2View. The render context is constructor-fixed on V2View, so
|
|
/// when the bubble is recycled with a different webpage we must rebuild the V2View.
|
|
private func ensurePageView(item: ChatMessageBubbleContentItem, webpage: TelegramMediaWebpage) -> InstantPageV2View {
|
|
let key = (id: item.message.id, stableVersion: item.message.stableVersion)
|
|
if let existing = self.pageView,
|
|
let current = self.pageViewMessageKey,
|
|
current.id == key.id,
|
|
current.stableVersion == key.stableVersion {
|
|
return existing
|
|
}
|
|
self.pageView?.removeFromSuperview()
|
|
self.pageView = nil
|
|
|
|
// Capture only the MessageReference (value type) — the closures are retained on the
|
|
// render context which is owned by the V2View, so we must avoid making them retain
|
|
// the bubble (`self`) or the message indirectly via `item`.
|
|
let messageReference = MessageReference(item.message)
|
|
let renderContext = InstantPageV2RenderContext(
|
|
context: item.context,
|
|
webpage: webpage,
|
|
sourceLocation: InstantPageSourceLocation(userLocation: .other, peerType: .channel),
|
|
imageReference: { image in
|
|
return ImageMediaReference.message(message: messageReference, media: image)
|
|
},
|
|
fileReference: { file in
|
|
return FileMediaReference.message(message: messageReference, media: file)
|
|
},
|
|
present: { [weak self] controller, args in
|
|
self?.item?.controllerInteraction.presentController(controller, args)
|
|
},
|
|
push: { [weak self] controller in
|
|
self?.item?.controllerInteraction.navigationController()?.pushViewController(controller)
|
|
},
|
|
openUrl: { [weak self] urlItem in
|
|
self?.openInstantPageUrl(urlItem)
|
|
},
|
|
baseNavigationController: { [weak self] in
|
|
self?.item?.controllerInteraction.navigationController()
|
|
}
|
|
)
|
|
let view = InstantPageV2View(renderContext: renderContext)
|
|
self.pageView = view
|
|
self.pageViewMessageKey = key
|
|
self.containerNode.view.addSubview(view)
|
|
view.detailsTapped = { [weak self] index in
|
|
guard let self else { return }
|
|
let current = self.currentExpandedDetails[index] ?? self.defaultExpanded(forDetailsIndex: index)
|
|
self.currentExpandedDetails[index] = !current
|
|
if let item = self.item {
|
|
item.controllerInteraction.requestMessageUpdate(item.message.id, true, nil)
|
|
}
|
|
}
|
|
return view
|
|
}
|
|
|
|
private func defaultExpanded(forDetailsIndex index: Int) -> Bool {
|
|
guard let layout = self.currentPageLayout?.layout else { return false }
|
|
for item in layout.items {
|
|
if case let .details(d) = item, d.index == index {
|
|
return d.defaultExpanded
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
self.linkProgressDisposable?.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 currentPageLayout = self.currentPageLayout
|
|
let currentExpandedDetails = self.currentExpandedDetails
|
|
let statusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.statusNode)
|
|
|
|
return { [weak self] item, _, _, _, _, _ in
|
|
let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none)
|
|
|
|
return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in
|
|
let suggestedBoundingWidth: CGFloat = constrainedSize.width
|
|
|
|
var boundingSize = CGSize(width: suggestedBoundingWidth, height: 0.0)
|
|
|
|
var pageLayout: InstantPageV2Layout?
|
|
// Built alongside pageLayout so the apply closure can hand it to ensurePageView.
|
|
var pageWebpage: TelegramMediaWebpage?
|
|
|
|
let isDark = item.presentationData.theme.theme.overallDarkAppearance
|
|
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
|
|
let messageTheme = isIncoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing
|
|
|
|
var underlineLinks = true
|
|
if !messageTheme.primaryTextColor.isEqual(messageTheme.linkTextColor) {
|
|
underlineLinks = false
|
|
}
|
|
let _ = underlineLinks
|
|
|
|
let author = item.message.author
|
|
let mainColor: UIColor
|
|
var secondaryColor: UIColor? = nil
|
|
var tertiaryColor: UIColor? = nil
|
|
|
|
let nameColors: PeerNameColors.Colors?
|
|
switch author?.nameColor {
|
|
case let .preset(nameColor):
|
|
nameColors = item.context.peerNameColors.get(nameColor, dark: item.presentationData.theme.theme.overallDarkAppearance)
|
|
case let .collectible(collectibleColor):
|
|
nameColors = collectibleColor.peerNameColors(dark: item.presentationData.theme.theme.overallDarkAppearance)
|
|
default:
|
|
nameColors = nil
|
|
}
|
|
|
|
let codeBlockTitleColor: UIColor
|
|
let codeBlockAccentColor: UIColor
|
|
let codeBlockBackgroundColor: UIColor
|
|
if !isIncoming {
|
|
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)
|
|
}
|
|
}
|
|
|
|
let _ = secondaryColor
|
|
let _ = tertiaryColor
|
|
|
|
let _ = codeBlockTitleColor
|
|
let _ = codeBlockAccentColor
|
|
|
|
let textCategories = InstantPageTextCategories(
|
|
kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
|
|
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 24.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
|
|
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
|
|
paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
|
|
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
|
|
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
|
|
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
|
|
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor)
|
|
)
|
|
let pageTheme = InstantPageTheme(
|
|
type: isDark ? .dark : .light,
|
|
pageBackgroundColor: .clear,
|
|
textCategories: textCategories,
|
|
serif: false,
|
|
codeBlockBackgroundColor: codeBlockBackgroundColor,
|
|
linkColor: messageTheme.linkTextColor,
|
|
textHighlightColor: messageTheme.accentTextColor.withMultipliedAlpha(0.1),
|
|
linkHighlightColor: messageTheme.linkTextColor.withMultipliedAlpha(0.1),
|
|
markerColor: UIColor(rgb: 0xfef3bc),
|
|
panelBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1),
|
|
panelHighlightedBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25),
|
|
panelPrimaryColor: messageTheme.primaryTextColor,
|
|
panelSecondaryColor: messageTheme.secondaryTextColor,
|
|
panelAccentColor: messageTheme.accentTextColor,
|
|
tableBorderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.25) : UIColor(white: 0.0, alpha: 0.1),
|
|
tableHeaderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.1) : UIColor(white: 0.0, alpha: 0.05),
|
|
controlColor: messageTheme.accentControlColor,
|
|
imageTintColor: nil,
|
|
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13)
|
|
)
|
|
|
|
if let attribute = item.message.richText {
|
|
let webpage = TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(
|
|
url: "",
|
|
displayUrl: "",
|
|
hash: 0,
|
|
type: nil,
|
|
websiteName: nil,
|
|
title: nil,
|
|
text: nil,
|
|
embedUrl: nil,
|
|
embedType: nil,
|
|
embedSize: nil,
|
|
duration: nil,
|
|
author: nil,
|
|
isMediaLargeByDefault: nil,
|
|
imageIsVideoCover: false,
|
|
image: nil,
|
|
file: nil,
|
|
story: nil,
|
|
attributes: [],
|
|
instantPage: attribute.instantPage
|
|
)))
|
|
pageWebpage = webpage
|
|
|
|
let presentationThemeIdentity = ObjectIdentifier(item.presentationData.theme.theme)
|
|
if let current = currentPageLayout,
|
|
current.boundingWidth == suggestedBoundingWidth,
|
|
current.presentationThemeIdentity == presentationThemeIdentity,
|
|
current.expandedDetails == currentExpandedDetails {
|
|
pageLayout = current.layout
|
|
} else {
|
|
pageLayout = layoutInstantPageV2(
|
|
webpage: webpage,
|
|
instantPage: attribute.instantPage,
|
|
userLocation: .other,
|
|
boundingWidth: suggestedBoundingWidth - 2.0,
|
|
horizontalInset: 10.0,
|
|
theme: pageTheme,
|
|
strings: item.presentationData.strings,
|
|
dateTimeFormat: item.presentationData.dateTimeFormat,
|
|
cachedMessageSyntaxHighlight: nil,
|
|
expandedDetails: currentExpandedDetails,
|
|
fitToWidth: true
|
|
)
|
|
}
|
|
}
|
|
|
|
if let pageLayout {
|
|
boundingSize.width = pageLayout.contentSize.width
|
|
boundingSize.height = pageLayout.contentSize.height + 2.0
|
|
}
|
|
|
|
let message = item.message
|
|
let incoming = isIncoming
|
|
|
|
var edited = false
|
|
if item.attributes.updatingMedia != nil {
|
|
edited = true
|
|
}
|
|
var viewCount: Int?
|
|
var dateReplies = 0
|
|
var starsCount: Int64?
|
|
var dateReactionsAndPeers = mergedMessageReactionsAndPeers(accountPeerId: item.context.account.peerId, 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)
|
|
}
|
|
} else if let attribute = attribute as? PaidStarsMessageAttribute, item.message.id.peerId.namespace == Namespaces.Peer.CloudChannel {
|
|
starsCount = attribute.stars.value
|
|
}
|
|
}
|
|
|
|
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: EngineMessage(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 case let .customChatContents(contents) = item.associatedData.subject {
|
|
if case .hashTagSearch = contents.kind {
|
|
displayStatus = true
|
|
} else {
|
|
displayStatus = false
|
|
}
|
|
} else if !item.presentationData.chatBubbleCorners.hasTails {
|
|
displayStatus = false
|
|
} else if case let .messageOptions(_, _, info) = item.associatedData.subject, case let .link(link) = info, link.isCentered {
|
|
displayStatus = false
|
|
}
|
|
|
|
if "".isEmpty {
|
|
displayStatus = false
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
let lastTextLineFrame: CGRect? = pageLayout.flatMap(InstantPageUI.lastTextLineFrame(in:))
|
|
|
|
var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode))?
|
|
if let statusType = statusType {
|
|
var isReplyThread = false
|
|
if case .replyThread = item.chatLocation {
|
|
isReplyThread = true
|
|
}
|
|
|
|
let trailingWidthToMeasure: CGFloat = lastTextLineFrame?.width ?? 10000.0
|
|
|
|
let dateLayoutInput: ChatMessageDateAndStatusNode.LayoutInput = .trailingContent(contentWidth: trailingWidthToMeasure, reactionSettings: ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: shouldDisplayInlineDateReactions(message: EngineMessage(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: CGSize(width: suggestedBoundingWidth, height: .greatestFiniteMagnitude),
|
|
availableReactions: item.associatedData.availableReactions,
|
|
savedMessageTags: item.associatedData.savedMessageTags,
|
|
reactions: item.presentationData.isPreview ? [] : dateReactionsAndPeers.reactions,
|
|
reactionPeers: dateReactionsAndPeers.peers,
|
|
displayAllReactionPeers: item.message.id.peerId.namespace == Namespaces.Peer.CloudUser,
|
|
areReactionsTags: item.topMessage.areReactionsTags(accountPeerId: item.context.account.peerId),
|
|
areStarReactionsEnabled: item.associatedData.areStarReactionsEnabled,
|
|
messageEffect: item.topMessage.messageEffect(availableMessageEffects: item.associatedData.availableMessageEffects),
|
|
replyCount: dateReplies,
|
|
starsCount: starsCount,
|
|
isPinned: item.message.tags.contains(.pinned) && (!item.associatedData.isInPinnedListMode || isReplyThread),
|
|
hasAutoremove: item.message.isSelfExpiring,
|
|
canViewReactionList: canViewMessageReactionList(message: EngineMessage(item.topMessage)),
|
|
animationCache: item.controllerInteraction.presentationContext.animationCache,
|
|
animationRenderer: item.controllerInteraction.presentationContext.animationRenderer
|
|
))
|
|
}
|
|
|
|
if let statusSuggestedWidthAndContinue {
|
|
let statusLeftEdgeInBubble: CGFloat
|
|
if let lastTextLineFrame {
|
|
statusLeftEdgeInBubble = 1.0 + lastTextLineFrame.minX
|
|
} else {
|
|
statusLeftEdgeInBubble = 1.0
|
|
}
|
|
boundingSize.width = max(boundingSize.width, statusLeftEdgeInBubble * 2.0 + statusSuggestedWidthAndContinue.0)
|
|
}
|
|
|
|
return (boundingSize.width, { boundingWidth in
|
|
let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth)
|
|
if let statusSizeAndApply {
|
|
boundingSize.height += statusSizeAndApply.0.height
|
|
}
|
|
|
|
return (boundingSize, { animation, _, _ in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.item = item
|
|
|
|
self.containerNode.frame = CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: CGSize(width: boundingWidth - 2.0, height: boundingSize.height - 2.0))
|
|
|
|
if let statusSizeAndApply {
|
|
let statusFrameX: CGFloat
|
|
let statusFrameY: CGFloat
|
|
if let lastTextLineFrame {
|
|
statusFrameX = 1.0 + lastTextLineFrame.minX
|
|
statusFrameY = 1.0 + lastTextLineFrame.maxY
|
|
} else if let pageLayout {
|
|
statusFrameX = 1.0
|
|
statusFrameY = 1.0 + pageLayout.contentSize.height
|
|
} else {
|
|
statusFrameX = 1.0
|
|
statusFrameY = 1.0
|
|
}
|
|
let statusFrame = CGRect(origin: CGPoint(x: statusFrameX, y: statusFrameY), size: statusSizeAndApply.0)
|
|
let statusNode = statusSizeAndApply.1(self.statusNode == nil ? .None : animation)
|
|
|
|
if self.statusNode !== statusNode {
|
|
self.statusNode?.removeFromSupernode()
|
|
self.statusNode = statusNode
|
|
|
|
self.addSubnode(statusNode)
|
|
|
|
statusNode.reactionSelected = { [weak self] _, value, sourceView in
|
|
guard let self, let item = self.item else {
|
|
return
|
|
}
|
|
item.controllerInteraction.updateMessageReaction(item.topMessage, .reaction(value), false, sourceView)
|
|
}
|
|
statusNode.openReactionPreview = { [weak self] gesture, sourceNode, value in
|
|
guard let self, let item = self.item else {
|
|
gesture?.cancel()
|
|
return
|
|
}
|
|
item.controllerInteraction.openMessageReactionContextMenu(item.topMessage, sourceNode, gesture, value)
|
|
}
|
|
statusNode.frame = statusFrame
|
|
} else {
|
|
animation.animator.updatePosition(layer: statusNode.layer, position: statusFrame.center, completion: nil)
|
|
animation.animator.updateBounds(layer: statusNode.layer, bounds: CGRect(origin: .zero, size: statusFrame.size), completion: nil)
|
|
}
|
|
} else if let statusNode = self.statusNode {
|
|
self.statusNode = nil
|
|
statusNode.removeFromSupernode()
|
|
}
|
|
|
|
if let forwardInfo = item.message.forwardInfo, forwardInfo.flags.contains(.isImported), let statusNode = self.statusNode {
|
|
statusNode.pressed = { [weak self] in
|
|
guard let self, let statusNode = self.statusNode, let item = self.item else {
|
|
return
|
|
}
|
|
item.controllerInteraction.displayImportedMessageTooltip(statusNode)
|
|
}
|
|
} else {
|
|
self.statusNode?.pressed = nil
|
|
}
|
|
|
|
if let pageLayout, let pageWebpage, let _ = item.message.richText {
|
|
self.currentPageLayout = (
|
|
suggestedBoundingWidth,
|
|
ObjectIdentifier(item.presentationData.theme.theme),
|
|
self.currentExpandedDetails,
|
|
pageLayout
|
|
)
|
|
let pageView = self.ensurePageView(item: item, webpage: pageWebpage)
|
|
pageView.update(layout: pageLayout, theme: pageTheme, animation: animation)
|
|
pageView.frame = CGRect(
|
|
origin: CGPoint(x: -1.0, y: 0.0),
|
|
size: pageLayout.contentSize
|
|
)
|
|
} else {
|
|
self.currentPageLayout = nil
|
|
self.pageView?.update(
|
|
layout: InstantPageV2Layout(contentSize: .zero, items: [], detailsIndices: []),
|
|
theme: pageTheme,
|
|
animation: animation
|
|
)
|
|
self.pageViewMessageKey = nil
|
|
}
|
|
})
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
override public func animateInsertion(_ currentTimestamp: Double, duration: Double) {
|
|
if let statusNode = self.statusNode, statusNode.alpha != 0.0 {
|
|
statusNode.layer.animateAlpha(from: 0.0, to: statusNode.alpha, duration: 0.2)
|
|
}
|
|
}
|
|
|
|
override public func animateAdded(_ currentTimestamp: Double, duration: Double) {
|
|
if let statusNode = self.statusNode, statusNode.alpha != 0.0 {
|
|
statusNode.layer.animateAlpha(from: 0.0, to: statusNode.alpha, duration: 0.2)
|
|
}
|
|
}
|
|
|
|
override public func animateRemoved(_ currentTimestamp: Double, duration: Double) {
|
|
if let statusNode = self.statusNode, statusNode.alpha != 0.0 {
|
|
statusNode.layer.animateAlpha(from: statusNode.alpha, 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)
|
|
}
|
|
}
|
|
|
|
guard let urlHit = self.urlForTapLocation(point) else {
|
|
return ChatMessageBubbleContentTapAction(content: .none)
|
|
}
|
|
|
|
let split = self.splitAnchor(urlHit.urlItem.url)
|
|
if let webpage = self.currentLoadedWebpage(), webpage.content.url == split.base, let anchor = split.anchor {
|
|
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
|
|
self?.scrollToAnchor(anchor)
|
|
}))
|
|
}
|
|
|
|
// Default to concealed=true: InstantPageTextItem does not expose a clean
|
|
// "attribute substring with displayed range" API, so we cannot compare
|
|
// displayed text to the resolved URL the way the chat text bubble does.
|
|
// The chat URL handler will show a confirmation when concealed is true
|
|
// and the visible text differs from the destination — safer default.
|
|
let concealed = true
|
|
let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed, allowInlineWebpageResolution: urlHit.urlItem.webpageId != nil)
|
|
let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
|
|
|
|
if let webpageId = urlHit.urlItem.webpageId {
|
|
let split = self.splitAnchor(url.url)
|
|
return ChatMessageBubbleContentTapAction(
|
|
content: .externalInstantPage(url: url, webpageId: webpageId, anchor: split.anchor),
|
|
rects: rects,
|
|
activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
|
|
)
|
|
} else {
|
|
return ChatMessageBubbleContentTapAction(
|
|
content: .url(url),
|
|
rects: rects,
|
|
activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func textItemAtLocation(_ location: CGPoint) -> (item: InstantPageTextItem, parentOffset: CGPoint)? {
|
|
guard let pageView = self.pageView else { return nil }
|
|
let local = self.view.convert(location, to: pageView)
|
|
return pageView.textItemAt(point: local)
|
|
}
|
|
|
|
private func urlForTapLocation(_ point: CGPoint) -> (item: InstantPageTextItem, urlItem: InstantPageUrlItem, parentOffset: CGPoint, localPoint: CGPoint)? {
|
|
guard let pageView = self.pageView else { return nil }
|
|
let local = self.view.convert(point, to: pageView)
|
|
return pageView.urlItemAt(point: local).map {
|
|
(item: $0.item, urlItem: $0.urlItem, parentOffset: $0.parentOffset, localPoint: $0.localPoint)
|
|
}
|
|
}
|
|
|
|
/// Bridges an InstantPageUrlItem (used by the gallery's caption URL handler) to the
|
|
/// chat layer's URL handler. `concealed: true` matches `tapActionAtPoint` for the same
|
|
/// reason: V2 cannot reliably compare displayed link text to the resolved URL.
|
|
private func openInstantPageUrl(_ url: InstantPageUrlItem) {
|
|
guard let item = self.item else { return }
|
|
item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl(
|
|
url: url.url,
|
|
concealed: true,
|
|
allowInlineWebpageResolution: url.webpageId != nil
|
|
))
|
|
}
|
|
|
|
private func computeHighlightRects(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> [CGRect] {
|
|
// Text item returns rects in its local coords; translate back into containerNode-local coords.
|
|
// containerNode is offset by (1, 1) from the bubble-content-node, but the highlight overlay lives
|
|
// *inside* containerNode, so we use layout-coords (= containerNode-local) for the rects.
|
|
let originX = parentOffset.x
|
|
let originY = parentOffset.y
|
|
return item.linkSelectionRects(at: localPoint).map { rect in
|
|
rect.offsetBy(dx: originX, dy: originY)
|
|
}
|
|
}
|
|
|
|
private func makeActivate(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> (() -> Promise<Bool>?)? {
|
|
return { [weak self, weak item] in
|
|
guard let self else {
|
|
return nil
|
|
}
|
|
let promise = Promise<Bool>()
|
|
self.linkProgressDisposable?.dispose()
|
|
if self.linkProgressRects != nil {
|
|
self.linkProgressRects = nil
|
|
self.updateLinkProgressState()
|
|
}
|
|
self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let updated: [CGRect]?
|
|
if value, let item {
|
|
updated = self.computeHighlightRects(item: item, parentOffset: parentOffset, localPoint: localPoint)
|
|
} else {
|
|
updated = nil
|
|
}
|
|
let changed: Bool
|
|
if let lhs = self.linkProgressRects, let rhs = updated {
|
|
changed = lhs != rhs
|
|
} else {
|
|
changed = (self.linkProgressRects == nil) != (updated == nil)
|
|
}
|
|
if changed {
|
|
self.linkProgressRects = updated
|
|
self.updateLinkProgressState()
|
|
}
|
|
})
|
|
return promise
|
|
}
|
|
}
|
|
|
|
private func updateLinkProgressState() {
|
|
guard let messageItem = self.item else {
|
|
return
|
|
}
|
|
if let rects = self.linkProgressRects, !rects.isEmpty {
|
|
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.containerNode.bounds
|
|
|
|
let progressColor: UIColor = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId)
|
|
? messageItem.presentationData.theme.theme.chat.message.incoming.linkHighlightColor
|
|
: messageItem.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor
|
|
|
|
linkProgressView.update(color: progressColor, size: self.containerNode.bounds.size, rects: rects)
|
|
} 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()
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
override public func updateTouchesAtPoint(_ point: CGPoint?) {
|
|
guard let messageItem = self.item else {
|
|
return
|
|
}
|
|
|
|
var rects: [CGRect]?
|
|
if let point, let urlHit = self.urlForTapLocation(point) {
|
|
rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
|
|
}
|
|
|
|
if let rects, !rects.isEmpty {
|
|
let highlightingNode: LinkHighlightingNode
|
|
if let current = self.linkHighlightingNode {
|
|
highlightingNode = current
|
|
} else {
|
|
let color: UIColor = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId)
|
|
? messageItem.presentationData.theme.theme.chat.message.incoming.linkHighlightColor
|
|
: messageItem.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor
|
|
highlightingNode = LinkHighlightingNode(color: color)
|
|
highlightingNode.useModernPathCalculation = true
|
|
self.linkHighlightingNode = highlightingNode
|
|
self.containerNode.insertSubnode(highlightingNode, at: 0)
|
|
}
|
|
highlightingNode.frame = self.containerNode.bounds
|
|
highlightingNode.updateRects(rects)
|
|
} else if let highlightingNode = self.linkHighlightingNode {
|
|
self.linkHighlightingNode = nil
|
|
highlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak highlightingNode] _ in
|
|
highlightingNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
}
|
|
|
|
override public func updateSearchTextHighlightState(text: String?, messages: [EngineMessage.Index]?) {
|
|
}
|
|
|
|
override public func willUpdateIsExtractedToContextPreview(_ value: Bool) {
|
|
if !value, let textSelectionNode = self.textSelectionNode {
|
|
self.textSelectionNode = nil
|
|
self.textSelectionAdapter = 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) {
|
|
guard value, self.textSelectionNode == nil, let messageItem = self.item, self.currentPageLayout?.layout != nil, let pageView = self.pageView, let rootNode = messageItem.controllerInteraction.chatControllerNode() else {
|
|
return
|
|
}
|
|
|
|
// pageView sits at (-1, 0) inside containerNode; the adapter is placed at
|
|
// containerNode.bounds, so shift each item's page-space origin into
|
|
// containerNode-local coords for the adapter to operate in.
|
|
let pageOrigin = pageView.frame.origin
|
|
let entries = pageView.selectableTextItems()
|
|
.filter { $0.item.selectable && !$0.item.attributedString.string.isEmpty }
|
|
.map { entry in
|
|
InstantPageMultiTextAdapter.Entry(
|
|
item: entry.item,
|
|
frameOrigin: CGPoint(
|
|
x: entry.parentOffset.x + pageOrigin.x,
|
|
y: entry.parentOffset.y + pageOrigin.y
|
|
)
|
|
)
|
|
}
|
|
guard !entries.isEmpty else {
|
|
return
|
|
}
|
|
|
|
let adapter = InstantPageMultiTextAdapter(entries: entries)
|
|
adapter.frame = self.containerNode.bounds
|
|
self.textSelectionAdapter = adapter
|
|
self.containerNode.addSubnode(adapter)
|
|
|
|
let incoming = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId)
|
|
let theme = messageItem.presentationData.theme.theme
|
|
let selectionColor = incoming ? theme.chat.message.incoming.textSelectionColor : theme.chat.message.outgoing.textSelectionColor
|
|
let knobColor = incoming ? theme.chat.message.incoming.textSelectionKnobColor : theme.chat.message.outgoing.textSelectionKnobColor
|
|
|
|
let textSelectionNode = TextSelectionNode(
|
|
theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: theme.overallDarkAppearance),
|
|
strings: messageItem.presentationData.strings,
|
|
textNodeOrView: .node(adapter),
|
|
updateIsActive: { _ in },
|
|
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)
|
|
}
|
|
},
|
|
rootView: { [weak rootNode] in
|
|
return rootNode?.view
|
|
},
|
|
performAction: { [weak self] text, action in
|
|
guard let self, let item = self.item else {
|
|
return
|
|
}
|
|
item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action)
|
|
}
|
|
)
|
|
|
|
let enableCopy = (!messageItem.associatedData.isCopyProtectionEnabled && !messageItem.message.isCopyProtected()) || messageItem.message.id.peerId.isVerificationCodes
|
|
textSelectionNode.enableCopy = enableCopy
|
|
|
|
var enableOtherActions = true
|
|
if let subject = messageItem.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info {
|
|
enableOtherActions = false
|
|
}
|
|
|
|
textSelectionNode.enableQuote = false
|
|
textSelectionNode.enableTranslate = enableOtherActions
|
|
textSelectionNode.enableShare = enableOtherActions && enableCopy
|
|
textSelectionNode.enableLookup = true
|
|
textSelectionNode.menuSkipCoordnateConversion = !enableOtherActions
|
|
|
|
textSelectionNode.frame = self.containerNode.bounds
|
|
textSelectionNode.highlightAreaNode.frame = self.containerNode.bounds
|
|
self.containerNode.insertSubnode(textSelectionNode.highlightAreaNode, at: 0)
|
|
self.containerNode.addSubnode(textSelectionNode)
|
|
self.textSelectionNode = textSelectionNode
|
|
}
|
|
|
|
override public func transitionNode(messageId: EngineMessage.Id, media: EngineRawMedia, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
|
|
// V2 V0: media items render as gray placeholders; no transition node is exposed.
|
|
return nil
|
|
}
|
|
|
|
override public func updateHiddenMedia(_ media: [EngineRawMedia]?) -> Bool {
|
|
// V2 V0: media items render as gray placeholders; nothing to hide.
|
|
return false
|
|
}
|
|
|
|
override public func getAnchorRect(anchor: String) -> CGRect? {
|
|
// V2 V0: anchor resolution lives in the V2 view (text-item anchors). Not yet wired through.
|
|
let _ = anchor
|
|
return nil
|
|
}
|
|
|
|
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 messageEffectTargetView() -> UIView? {
|
|
if let statusNode = self.statusNode, !statusNode.isHidden {
|
|
return statusNode.messageEffectTargetView()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
override public func getStatusNode() -> ASDisplayNode? {
|
|
return self.statusNode
|
|
}
|
|
|
|
private func splitAnchor(_ url: String) -> (base: String, anchor: String?) {
|
|
if let anchorRange = url.range(of: "#") {
|
|
let anchor = String(url[anchorRange.upperBound...]).removingPercentEncoding
|
|
let base = String(url[..<anchorRange.lowerBound])
|
|
return (base, anchor)
|
|
}
|
|
return (url, nil)
|
|
}
|
|
|
|
private func currentLoadedWebpage() -> TelegramMediaWebpage? {
|
|
return nil // V2 V0: media items are placeholders; no inline webpage resolution.
|
|
}
|
|
|
|
private func scrollToAnchor(_ anchor: String) {
|
|
guard let item = self.item else {
|
|
return
|
|
}
|
|
if anchor.isEmpty {
|
|
item.controllerInteraction.scrollToMessageId(item.message.index, 0.0)
|
|
} else {
|
|
item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor)
|
|
}
|
|
}
|
|
}
|