Files
Swiftgram/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift
Ilya Laktyushin f12fb93a3e [WIP] Polls
2026-03-20 14:52:19 +01:00

3727 lines
199 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import Postbox
import TextFormat
import UrlEscaping
import SwiftSignalKit
import AccountContext
import AvatarNode
import TelegramPresentationData
import PhotoResources
import LocationResources
import ChatMessageBackground
import ChatMessageDateAndStatusNode
import ChatMessageBubbleContentNode
import ChatMessageItemCommon
import PollBubbleTimerNode
import MergedAvatarsNode
import TextNodeWithEntities
import ShimmeringLinkNode
import EmojiTextAttachmentView
import ChatControllerInteraction
import RadialStatusNode
import ComposePollScreen
import ComponentFlow
import TextFieldComponent
import PlainButtonComponent
import LottieComponent
private final class ChatMessagePollOptionRadioNodeParameters: NSObject {
let timestamp: Double
let isRectangle: Bool
let staticColor: UIColor
let animatedColor: UIColor
let fillColor: UIColor
let foregroundColor: UIColor
let offset: Double?
let isChecked: Bool?
let checkTransition: ChatMessagePollOptionRadioNodeCheckTransition?
init(timestamp: Double, isRectangle: Bool, staticColor: UIColor, animatedColor: UIColor, fillColor: UIColor, foregroundColor: UIColor, offset: Double?, isChecked: Bool?, checkTransition: ChatMessagePollOptionRadioNodeCheckTransition?) {
self.timestamp = timestamp
self.isRectangle = isRectangle
self.staticColor = staticColor
self.animatedColor = animatedColor
self.fillColor = fillColor
self.foregroundColor = foregroundColor
self.offset = offset
self.isChecked = isChecked
self.checkTransition = checkTransition
super.init()
}
}
private final class ChatMessagePollOptionRadioNodeCheckTransition {
let startTime: Double
let duration: Double
let previousValue: Bool
let updatedValue: Bool
init(startTime: Double, duration: Double, previousValue: Bool, updatedValue: Bool) {
self.startTime = startTime
self.duration = duration
self.previousValue = previousValue
self.updatedValue = updatedValue
}
}
private final class ChatMessagePollOptionRadioNode: ASDisplayNode {
private(set) var isRectangle = false
private(set) var staticColor: UIColor?
private(set) var animatedColor: UIColor?
private(set) var fillColor: UIColor?
private(set) var foregroundColor: UIColor?
private var isInHierarchyValue: Bool = false
private(set) var isAnimating: Bool = false
private var startTime: Double?
private var checkTransition: ChatMessagePollOptionRadioNodeCheckTransition?
private(set) var isChecked: Bool?
private var displayLink: ConstantDisplayLinkAnimator?
private var shouldBeAnimating: Bool {
return self.isInHierarchyValue && (self.isAnimating || self.checkTransition != nil)
}
func updateIsChecked(_ value: Bool, animated: Bool) {
if let previousValue = self.isChecked, previousValue != value {
if animated {
self.checkTransition = ChatMessagePollOptionRadioNodeCheckTransition(startTime: CACurrentMediaTime(), duration: 0.15, previousValue: previousValue, updatedValue: value)
}
self.isChecked = value
if animated {
self.updateAnimating()
}
self.setNeedsDisplay()
}
}
override init() {
super.init()
self.isUserInteractionEnabled = false
self.isOpaque = false
}
deinit {
self.displayLink?.isPaused = true
}
override func willEnterHierarchy() {
super.willEnterHierarchy()
let previous = self.shouldBeAnimating
self.isInHierarchyValue = true
let updated = self.shouldBeAnimating
if previous != updated {
self.updateAnimating()
}
}
override func didExitHierarchy() {
super.didExitHierarchy()
let previous = self.shouldBeAnimating
self.isInHierarchyValue = false
let updated = self.shouldBeAnimating
if previous != updated {
self.updateAnimating()
}
}
func update(isRectangle: Bool, staticColor: UIColor, animatedColor: UIColor, fillColor: UIColor, foregroundColor: UIColor, isSelectable: Bool, isAnimating: Bool) {
var updated = false
let shouldHaveBeenAnimating = self.shouldBeAnimating
if self.isRectangle != isRectangle {
self.isRectangle = isRectangle
updated = true
}
if !staticColor.isEqual(self.staticColor) {
self.staticColor = staticColor
updated = true
}
if !animatedColor.isEqual(self.animatedColor) {
self.animatedColor = animatedColor
updated = true
}
if !fillColor.isEqual(self.fillColor) {
self.fillColor = fillColor
updated = true
}
if !foregroundColor.isEqual(self.foregroundColor) {
self.foregroundColor = foregroundColor
updated = true
}
if isSelectable != (self.isChecked != nil) {
if isSelectable {
self.isChecked = false
} else {
self.isChecked = nil
self.checkTransition = nil
}
updated = true
}
if isAnimating != self.isAnimating {
self.isAnimating = isAnimating
let updated = self.shouldBeAnimating
if shouldHaveBeenAnimating != updated {
self.updateAnimating()
}
}
if updated {
self.setNeedsDisplay()
}
}
private func updateAnimating() {
let timestamp = CACurrentMediaTime()
if let checkTransition = self.checkTransition {
if checkTransition.startTime + checkTransition.duration <= timestamp {
self.checkTransition = nil
}
}
if self.shouldBeAnimating {
if self.isAnimating && self.startTime == nil {
self.startTime = timestamp
}
if self.displayLink == nil {
self.displayLink = ConstantDisplayLinkAnimator(update: { [weak self] in
self?.updateAnimating()
self?.setNeedsDisplay()
})
self.displayLink?.isPaused = false
self.setNeedsDisplay()
}
} else if let displayLink = self.displayLink {
self.startTime = nil
displayLink.invalidate()
self.displayLink = nil
self.setNeedsDisplay()
}
}
override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
if let staticColor = self.staticColor, let animatedColor = self.animatedColor, let fillColor = self.fillColor, let foregroundColor = self.foregroundColor {
let timestamp = CACurrentMediaTime()
var offset: Double?
if let startTime = self.startTime {
offset = CACurrentMediaTime() - startTime
}
return ChatMessagePollOptionRadioNodeParameters(timestamp: timestamp, isRectangle: self.isRectangle, staticColor: staticColor, animatedColor: animatedColor, fillColor: fillColor, foregroundColor: foregroundColor, offset: offset, isChecked: self.isChecked, checkTransition: self.checkTransition)
} else {
return nil
}
}
@objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
if isCancelled() {
return
}
guard let parameters = parameters as? ChatMessagePollOptionRadioNodeParameters else {
return
}
let context = UIGraphicsGetCurrentContext()!
if let offset = parameters.offset {
let t = max(0.0, offset)
let colorFadeInDuration = 0.2
let color: UIColor
if t < colorFadeInDuration {
color = parameters.staticColor.mixedWith(parameters.animatedColor, alpha: CGFloat(t / colorFadeInDuration))
} else {
color = parameters.animatedColor
}
context.setStrokeColor(color.cgColor)
let rotationDuration = 1.15
let rotationProgress = CGFloat(offset.truncatingRemainder(dividingBy: rotationDuration) / rotationDuration)
context.translateBy(x: bounds.midX, y: bounds.midY)
context.rotate(by: rotationProgress * 2.0 * CGFloat.pi)
context.translateBy(x: -bounds.midX, y: -bounds.midY)
let fillDuration = 1.0
if offset < fillDuration {
let fillT = CGFloat(offset.truncatingRemainder(dividingBy: fillDuration) / fillDuration)
let startAngle = fillT * 2.0 * CGFloat.pi - CGFloat.pi / 2.0
let endAngle = -CGFloat.pi / 2.0
let path = UIBezierPath(arcCenter: CGPoint(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0), radius: (bounds.size.width - 1.0) / 2.0, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.lineWidth = 1.0
path.lineCapStyle = .round
path.stroke()
} else {
let halfProgress: CGFloat = 0.7
let fillPhase = 0.6
let keepPhase = 0.0
let finishPhase = 0.6
let totalDuration = fillPhase + keepPhase + finishPhase
let localOffset = (offset - fillDuration).truncatingRemainder(dividingBy: totalDuration)
let angleOffsetT: CGFloat = -CGFloat(floor((offset - fillDuration) / totalDuration))
let angleOffset = (angleOffsetT * (1.0 - halfProgress) * 2.0 * CGFloat.pi).truncatingRemainder(dividingBy: 2.0 * CGFloat.pi)
context.translateBy(x: bounds.midX, y: bounds.midY)
context.rotate(by: angleOffset)
context.translateBy(x: -bounds.midX, y: -bounds.midY)
if localOffset < fillPhase + keepPhase {
let fillT = CGFloat(min(1.0, localOffset / fillPhase))
let startAngle = -CGFloat.pi / 2.0
let endAngle = (fillT * halfProgress) * 2.0 * CGFloat.pi - CGFloat.pi / 2.0
let path = UIBezierPath(arcCenter: CGPoint(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0), radius: (bounds.size.width - 1.0) / 2.0, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.lineWidth = 1.0
path.lineCapStyle = .round
path.stroke()
} else {
let finishT = CGFloat((localOffset - (fillPhase + keepPhase)) / finishPhase)
let endAngle = halfProgress * 2.0 * CGFloat.pi - CGFloat.pi / 2.0
let startAngle = -CGFloat.pi / 2.0 * (1.0 - finishT) + endAngle * finishT
let path = UIBezierPath(arcCenter: CGPoint(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0), radius: (bounds.size.width - 1.0) / 2.0, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.lineWidth = 1.0
path.lineCapStyle = .round
path.stroke()
}
}
} else {
if let isChecked = parameters.isChecked {
let checkedT: CGFloat
let fromValue: CGFloat
let toValue: CGFloat
let fromAlpha: CGFloat
let toAlpha: CGFloat
if let checkTransition = parameters.checkTransition {
checkedT = CGFloat(max(0.0, min(1.0, (parameters.timestamp - checkTransition.startTime) / checkTransition.duration)))
fromValue = checkTransition.previousValue ? bounds.width : 0.0
fromAlpha = checkTransition.previousValue ? 1.0 : 0.0
toValue = checkTransition.updatedValue ? bounds.width : 0.0
toAlpha = checkTransition.updatedValue ? 1.0 : 0.0
} else {
checkedT = 1.0
fromValue = isChecked ? bounds.width : 0.0
fromAlpha = isChecked ? 1.0 : 0.0
toValue = isChecked ? bounds.width : 0.0
toAlpha = isChecked ? 1.0 : 0.0
}
let diameter = fromValue * (1.0 - checkedT) + toValue * checkedT
let alpha = fromAlpha * (1.0 - checkedT) + toAlpha * checkedT
if abs(diameter - 1.0) > CGFloat.ulpOfOne {
context.setStrokeColor(parameters.staticColor.cgColor)
if parameters.isRectangle{
context.addPath(UIBezierPath(roundedRect: CGRect(origin: .zero, size: bounds.size).insetBy(dx: 0.5, dy: 0.5), cornerRadius: 6.0).cgPath)
context.strokePath()
} else {
context.strokeEllipse(in: CGRect(origin: .zero, size: bounds.size).insetBy(dx: 0.5, dy: 0.5))
}
}
if !diameter.isZero {
context.setFillColor(parameters.fillColor.withAlphaComponent(alpha).cgColor)
if parameters.isRectangle{
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: (bounds.width - diameter) / 2.0, y: (bounds.width - diameter) / 2.0), size: CGSize(width: diameter, height: diameter)), cornerRadius: 6.0).cgPath)
context.fillPath()
} else {
context.fillEllipse(in: CGRect(origin: CGPoint(x: (bounds.width - diameter) / 2.0, y: (bounds.width - diameter) / 2.0), size: CGSize(width: diameter, height: diameter)))
}
context.setLineWidth(1.5)
context.setLineJoin(.round)
context.setLineCap(.round)
context.setStrokeColor(parameters.foregroundColor.withAlphaComponent(alpha).cgColor)
if parameters.foregroundColor.alpha.isZero {
context.setBlendMode(.clear)
}
let startPoint = CGPoint(x: 6.0, y: 12.13)
let centerPoint = CGPoint(x: 9.28, y: 15.37)
let endPoint = CGPoint(x: 16.0, y: 8.0)
let pathStartT: CGFloat = 0.15
let pathT = max(0.0, (alpha - pathStartT) / (1.0 - pathStartT))
let pathMiddleT: CGFloat = 0.4
context.move(to: startPoint)
if pathT >= pathMiddleT {
context.addLine(to: centerPoint)
let pathEndT = (pathT - pathMiddleT) / (1.0 - pathMiddleT)
if pathEndT >= 1.0 {
context.addLine(to: endPoint)
} else {
context.addLine(to: CGPoint(x: (1.0 - pathEndT) * centerPoint.x + pathEndT * endPoint.x, y: (1.0 - pathEndT) * centerPoint.y + pathEndT * endPoint.y))
}
} else {
context.addLine(to: CGPoint(x: (1.0 - pathT) * startPoint.x + pathT * centerPoint.x, y: (1.0 - pathT) * startPoint.y + pathT * centerPoint.y))
}
context.strokePath()
context.setBlendMode(.normal)
}
} else {
context.setStrokeColor(parameters.staticColor.cgColor)
if parameters.isRectangle {
context.addPath(UIBezierPath(roundedRect: CGRect(origin: .zero, size: bounds.size).insetBy(dx: 0.5, dy: 0.5), cornerRadius: 6.0).cgPath)
context.strokePath()
} else {
context.strokeEllipse(in: CGRect(origin: .zero, size: bounds.size).insetBy(dx: 0.5, dy: 0.5))
}
}
}
}
}
private let percentageFont = Font.bold(14.5)
private let percentageSmallFont = Font.bold(12.5)
private let countFont = Font.regular(12.5)
private func generatePercentageImage(presentationData: ChatPresentationData, incoming: Bool, value: Int, targetValue: Int) -> UIImage {
return generateImage(CGSize(width: 42.0, height: 20.0), rotatedContext: { size, context in
UIGraphicsPushContext(context)
context.clear(CGRect(origin: CGPoint(), size: size))
let font: UIFont
if targetValue == 100 {
font = percentageSmallFont
} else {
font = percentageFont
}
let string = NSAttributedString(string: "\(value)%", font: font, textColor: incoming ? presentationData.theme.theme.chat.message.incoming.primaryTextColor : presentationData.theme.theme.chat.message.outgoing.primaryTextColor, paragraphAlignment: .right)
string.draw(in: CGRect(origin: CGPoint(x: 0.0, y: targetValue == 100 ? 3.0 : 2.0), size: size))
UIGraphicsPopContext()
})!
}
private func generatePercentageAnimationImages(presentationData: ChatPresentationData, incoming: Bool, from fromValue: Int, to toValue: Int, duration: Double) -> [UIImage] {
let minimumFrameDuration = 1.0 / 40.0
let numberOfFrames = max(1, Int(duration / minimumFrameDuration))
var images: [UIImage] = []
for i in 0 ..< numberOfFrames {
let t = CGFloat(i) / CGFloat(numberOfFrames)
images.append(generatePercentageImage(presentationData: presentationData, incoming: incoming, value: Int((1.0 - t) * CGFloat(fromValue) + t * CGFloat(toValue)), targetValue: toValue))
}
return images
}
private func generateCountImage(presentationData: ChatPresentationData, incoming: Bool, value: Int32, targetValue: Int32) -> UIImage {
let targetString = "\(targetValue)"
let targetSize = (targetString as NSString).size(withAttributes: [.font: countFont])
let imageSize = CGSize(width: ceil(targetSize.width) + 2.0, height: 20.0)
return generateImage(imageSize, rotatedContext: { size, context in
UIGraphicsPushContext(context)
context.clear(CGRect(origin: CGPoint(), size: size))
let string = NSAttributedString(
string: value == 0 ? "" : compactNumericCountString(Int(value), decimalSeparator: presentationData.dateTimeFormat.decimalSeparator),
font: countFont,
textColor: incoming ? presentationData.theme.theme.chat.message.incoming.primaryTextColor : presentationData.theme.theme.chat.message.outgoing.primaryTextColor,
paragraphAlignment: .right
)
string.draw(in: CGRect(origin: CGPoint(x: 0.0, y: 2.0), size: size))
UIGraphicsPopContext()
})!
}
private func generateCountAnimationImages(presentationData: ChatPresentationData, incoming: Bool, from fromValue: Int32, to toValue: Int32, duration: Double) -> [UIImage] {
let minimumFrameDuration = 1.0 / 40.0
let numberOfFrames = max(1, Int(duration / minimumFrameDuration))
var images: [UIImage] = []
for i in 0 ..< numberOfFrames {
let t = CGFloat(i) / CGFloat(numberOfFrames)
images.append(generateCountImage(presentationData: presentationData, incoming: incoming, value: Int32((1.0 - t) * CGFloat(fromValue) + t * CGFloat(toValue)), targetValue: toValue))
}
return images
}
private struct ChatMessagePollOptionResult: Equatable {
let normalized: CGFloat
let percent: Int
let count: Int32
let recentVoterPeerIds: [PeerId]
}
private struct ChatMessagePollOptionSelection: Equatable {
var isSelected: Bool
var isCorrect: Bool
}
private final class ChatMessagePollOptionNode: ASDisplayNode {
private static let mediaSize = CGSize(width: 40.0, height: 40.0)
private static let mediaSpacing: CGFloat = 2.0
private static let mediaRightInset: CGFloat = 10.0
private static let avatarsSize = CGSize(width: MergedAvatarsNode.defaultMergedImageSize + MergedAvatarsNode.defaultMergedImageSpacing, height: MergedAvatarsNode.defaultMergedImageSize)
private static let avatarsSpacing: CGFloat = 6.0
private static let countSpacing: CGFloat = 6.0
private var backgroundWallpaperNode: ChatMessageBubbleBackdrop?
private var backgroundNode: ChatMessageBackground?
private var extractedContainerView: UIView?
fileprivate let contextSourceNode: ContextExtractedContentContainingNode
fileprivate let containerNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private(set) var radioNode: ChatMessagePollOptionRadioNode?
private let percentageNode: ASDisplayNode
private var percentageImage: UIImage?
private let countNode: ASDisplayNode
private var countImage: UIImage?
private let avatarsNode: MergedAvatarsNode
fileprivate var titleNode: TextNodeWithEntities?
private let buttonNode: HighlightTrackingButtonNode
let separatorNode: ASDisplayNode
private let resultBarBackgroundNode: ASImageNode
private let resultBarNode: ASImageNode
private let resultBarIconNode: ASImageNode
private var mediaNode: TransformImageNode?
private var mediaVideoIconNode: ASImageNode?
private var stickerMediaLayer: InlineStickerItemLayer?
private var mediaHidden = false
private(set) var mediaFrame: CGRect?
private var currentMedia: Media?
private var currentRecentVoterPeerIds: [PeerId] = []
private var fetchDisposable = MetaDisposable()
var option: TelegramMediaPollOption?
var forceSelected: Bool?
private(set) var currentResult: ChatMessagePollOptionResult?
private(set) var currentSelection: ChatMessagePollOptionSelection?
var pressed: (() -> Void)?
var selectionUpdated: (() -> Void)?
var longTapped: (() -> Void)?
var context: AccountContext?
var message: Message?
private var theme: PresentationTheme?
private var presentationData: ChatPresentationData?
private var presentationContext: ChatPresentationContext?
weak var previousOptionNode: ChatMessagePollOptionNode?
private var ignoreNextTap = false
var visibilityRect: CGRect? {
didSet {
if self.visibilityRect != oldValue {
if let titleNode = self.titleNode {
if let visibilityRect = self.visibilityRect {
titleNode.visibilityRect = visibilityRect.offsetBy(dx: 0.0, dy: titleNode.textNode.frame.minY)
} else {
titleNode.visibilityRect = nil
}
}
}
}
}
override init() {
self.contextSourceNode = ContextExtractedContentContainingNode()
self.containerNode = ASDisplayNode()
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.alpha = 0.0
self.highlightedBackgroundNode.isUserInteractionEnabled = false
self.buttonNode = HighlightTrackingButtonNode()
self.separatorNode = ASDisplayNode()
self.resultBarBackgroundNode = ASImageNode()
self.resultBarBackgroundNode.isLayerBacked = true
self.resultBarBackgroundNode.alpha = 0.0
self.resultBarNode = ASImageNode()
self.resultBarNode.isLayerBacked = true
self.resultBarNode.alpha = 0.0
self.resultBarIconNode = ASImageNode()
self.resultBarIconNode.isLayerBacked = true
self.percentageNode = ASDisplayNode()
self.percentageNode.alpha = 0.0
self.percentageNode.isLayerBacked = true
self.countNode = ASDisplayNode()
self.countNode.alpha = 0.0
self.countNode.isLayerBacked = true
self.avatarsNode = MergedAvatarsNode()
self.avatarsNode.alpha = 0.0
self.avatarsNode.isUserInteractionEnabled = false
super.init()
self.addSubnode(self.highlightedBackgroundNode)
self.addSubnode(self.contextSourceNode)
self.addSubnode(self.containerNode)
self.addSubnode(self.separatorNode)
self.addSubnode(self.buttonNode)
self.containerNode.addSubnode(self.resultBarBackgroundNode)
self.containerNode.addSubnode(self.resultBarNode)
self.containerNode.addSubnode(self.resultBarIconNode)
self.containerNode.addSubnode(self.percentageNode)
self.containerNode.addSubnode(self.countNode)
self.containerNode.addSubnode(self.avatarsNode)
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.containerNode.view.tag = 0x2bad
if let theme = strongSelf.theme, theme.overallDarkAppearance, let contentNode = strongSelf.supernode as? ChatMessagePollBubbleContentNode, let backdropNode = contentNode.bubbleBackgroundNode?.backdropNode {
strongSelf.highlightedBackgroundNode.layer.compositingFilter = "overlayBlendMode"
strongSelf.highlightedBackgroundNode.frame = strongSelf.view.convert(strongSelf.highlightedBackgroundNode.frame, to: backdropNode.view)
backdropNode.addSubnode(strongSelf.highlightedBackgroundNode)
} else {
strongSelf.insertSubnode(strongSelf.highlightedBackgroundNode, at: 0)
}
strongSelf.highlightedBackgroundNode.layer.removeAnimation(forKey: "opacity")
strongSelf.highlightedBackgroundNode.alpha = 1.0
strongSelf.separatorNode.layer.removeAnimation(forKey: "opacity")
strongSelf.separatorNode.alpha = 0.0
strongSelf.previousOptionNode?.separatorNode.layer.removeAnimation(forKey: "opacity")
strongSelf.previousOptionNode?.separatorNode.alpha = 0.0
Queue.mainQueue().after(0.5) {
if strongSelf.highlightedBackgroundNode.alpha == 1.0 {
strongSelf.ignoreNextTap = true
strongSelf.longTapped?()
}
}
} else {
strongSelf.containerNode.view.tag = 0
strongSelf.highlightedBackgroundNode.alpha = 0.0
strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, completion: { finished in
if finished && strongSelf.highlightedBackgroundNode.supernode != strongSelf {
strongSelf.highlightedBackgroundNode.layer.compositingFilter = nil
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: strongSelf.highlightedBackgroundNode.frame.size)
strongSelf.insertSubnode(strongSelf.highlightedBackgroundNode, at: 0)
}
})
let separatorAlpha: CGFloat = strongSelf.currentResult == nil ? 1.0 : 0.0
strongSelf.separatorNode.alpha = separatorAlpha
strongSelf.separatorNode.layer.animateAlpha(from: 0.0, to: separatorAlpha, duration: 0.3)
if let previousOptionNode = strongSelf.previousOptionNode {
let previousSeparatorAlpha: CGFloat = previousOptionNode.currentResult == nil ? 1.0 : 0.0
previousOptionNode.separatorNode.alpha = previousSeparatorAlpha
previousOptionNode.separatorNode.layer.animateAlpha(from: 0.0, to: previousSeparatorAlpha, duration: 0.3)
}
}
}
}
self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtractedToContextPreview, transition in
guard let self else {
return
}
if isExtractedToContextPreview {
self.buttonNode.highligthedChanged(false)
var offset: CGFloat = 0.0
var inset: CGFloat = 0.0
var type: ChatMessageBackgroundType
var incoming = false
if let context = self.context, let message = self.message {
incoming = message.effectivelyIncoming(context.account.peerId)
}
if incoming {
type = .incoming(.Extracted)
offset = -5.0
inset = 5.0
} else {
type = .outgoing(.Extracted)
inset = 5.0
}
if let _ = self.backgroundNode {
} else if let presentationData = self.presentationData, let presentationContext = self.presentationContext {
let graphics = PresentationResourcesChat.principalGraphics(theme: presentationData.theme.theme, wallpaper: presentationData.theme.wallpaper, bubbleCorners: presentationData.chatBubbleCorners)
let backgroundWallpaperNode = ChatMessageBubbleBackdrop()
backgroundWallpaperNode.alpha = 0.0
let backgroundNode = ChatMessageBackground()
backgroundNode.alpha = 0.0
self.contextSourceNode.contentNode.insertSubnode(backgroundNode, at: 0)
self.contextSourceNode.contentNode.insertSubnode(backgroundWallpaperNode, at: 0)
self.backgroundWallpaperNode = backgroundWallpaperNode
self.backgroundNode = backgroundNode
transition.updateAlpha(node: backgroundNode, alpha: 1.0)
transition.updateAlpha(node: backgroundWallpaperNode, alpha: 1.0)
backgroundNode.setType(type: type, highlighted: false, graphics: graphics, maskMode: true, hasWallpaper: presentationData.theme.wallpaper.hasWallpaper, transition: .immediate, backgroundNode: presentationContext.backgroundNode)
backgroundWallpaperNode.setType(type: type, theme: presentationData.theme, essentialGraphics: graphics, maskMode: true, backgroundNode: presentationContext.backgroundNode)
}
let backgroundFrame = CGRect(x: offset, y: 0.0, width: self.bounds.width + inset, height: self.bounds.height)
self.backgroundNode?.updateLayout(size: backgroundFrame.size, transition: .immediate)
self.backgroundNode?.frame = backgroundFrame
self.backgroundWallpaperNode?.frame = backgroundFrame
self.extractedContainerView?.removeFromSuperview()
self.extractedContainerView = nil
if let extractedContainerView = self.containerNode.view.snapshotContentTree(unhide: true) {
extractedContainerView.frame = self.containerNode.bounds
self.contextSourceNode.contentNode.view.addSubview(extractedContainerView)
self.extractedContainerView = extractedContainerView
}
} else {
if let backgroundNode = self.backgroundNode {
self.backgroundNode = nil
transition.updateAlpha(node: backgroundNode, alpha: 0.0, completion: { [weak backgroundNode] _ in
backgroundNode?.removeFromSupernode()
})
}
if let backgroundWallpaperNode = self.backgroundWallpaperNode {
self.backgroundWallpaperNode = nil
transition.updateAlpha(node: backgroundWallpaperNode, alpha: 0.0, completion: { [weak backgroundWallpaperNode] _ in
backgroundWallpaperNode?.removeFromSupernode()
})
}
}
}
self.contextSourceNode.isExtractedToContextPreviewUpdated = { [weak self] isExtracted in
guard let self else {
return
}
if !isExtracted {
self.extractedContainerView?.removeFromSuperview()
self.extractedContainerView = nil
}
}
}
deinit {
self.fetchDisposable.dispose()
}
override func didLoad() {
super.didLoad()
self.highlightedBackgroundNode.view.tag = 0x1bad
self.separatorNode.view.tag = 0x3bad
}
@objc private func buttonPressed() {
guard !self.ignoreNextTap else {
self.ignoreNextTap = false
return
}
guard self.forceSelected == nil && self.currentResult == nil else {
return
}
if let radioNode = self.radioNode, let isChecked = radioNode.isChecked {
radioNode.updateIsChecked(!isChecked, animated: true)
self.selectionUpdated?()
} else {
self.pressed?()
}
}
private func updateMediaVisibility() {
let alpha: CGFloat = self.mediaHidden ? 0.0 : 1.0
self.mediaNode?.alpha = alpha
self.mediaVideoIconNode?.alpha = alpha
self.stickerMediaLayer?.opacity = Float(alpha)
}
func setMediaHidden(_ hidden: Bool) {
if self.mediaHidden != hidden {
self.mediaHidden = hidden
self.updateMediaVisibility()
}
}
func transitionNode(media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
guard let currentMedia = self.currentMedia, currentMedia.isEqual(to: media), let mediaNode = self.mediaNode, !mediaNode.isHidden else {
return nil
}
return (mediaNode, mediaNode.bounds, { [weak mediaNode] in
return (mediaNode?.view.snapshotContentTree(unhide: true), nil)
})
}
static func asyncLayout(_ maybeNode: ChatMessagePollOptionNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ presentationContext: ChatPresentationContext, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ translation: TranslationMessageAttribute.Additional?, _ optionResult: ChatMessagePollOptionResult?, _ forceSelected: Bool?, _ hasAnyMedia: Bool, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode))) {
let makeTitleLayout = TextNodeWithEntities.asyncLayout(maybeNode?.titleNode)
let currentResult = maybeNode?.currentResult
let currentSelection = maybeNode?.currentSelection
let currentTheme = maybeNode?.theme
return { context, presentationData, presentationContext, message, poll, option, translation, optionResult, forceSelected, hasAnyMedia, constrainedWidth in
let leftInset: CGFloat = 50.0
let media = option.media
let mediaInset: CGFloat
if hasAnyMedia {
mediaInset = ChatMessagePollOptionNode.mediaSize.width + ChatMessagePollOptionNode.mediaSpacing + ChatMessagePollOptionNode.mediaRightInset
} else {
mediaInset = 0.0
}
let incoming = message.effectivelyIncoming(context.account.peerId)
var optionText = option.text
var optionEntities = option.entities
if let translation {
optionText = translation.text
optionEntities = translation.entities
}
let optionTextColor: UIColor = incoming ? presentationData.theme.theme.chat.message.incoming.primaryTextColor : presentationData.theme.theme.chat.message.outgoing.primaryTextColor
let optionAttributedText = stringWithAppliedEntities(
optionText,
entities: optionEntities,
baseColor: optionTextColor,
linkColor: optionTextColor,
baseFont: presentationData.messageFont,
linkFont: presentationData.messageFont,
boldFont: presentationData.messageFont,
italicFont: presentationData.messageFont,
boldItalicFont: presentationData.messageFont,
fixedFont: presentationData.messageFont,
blockQuoteFont: presentationData.messageFont,
message: message
)
let shouldHaveRadioNode = optionResult == nil
let isSelectable: Bool
if shouldHaveRadioNode, poll.kind.multipleAnswers, forceSelected == nil, !Namespaces.Message.allNonRegular.contains(message.id.namespace) {
isSelectable = true
} else {
isSelectable = false
}
let themeUpdated = presentationData.theme.theme !== currentTheme
var updatedPercentageImage: UIImage?
if currentResult != optionResult || themeUpdated {
let value = optionResult?.percent ?? 0
updatedPercentageImage = generatePercentageImage(presentationData: presentationData, incoming: incoming, value: value, targetValue: value)
}
let displayCount = optionResult != nil
var updatedCountImage: UIImage?
if displayCount && (currentResult != optionResult || themeUpdated) {
let value = optionResult?.count ?? 0
updatedCountImage = generateCountImage(presentationData: presentationData, incoming: incoming, value: value, targetValue: value)
}
// let countInset: CGFloat
// if displayCount {
// let countImage = updatedCountImage ?? maybeNode?.countImage ?? generateCountImage(presentationData: presentationData, incoming: incoming, value: optionResult?.count ?? 0, targetValue: optionResult?.count ?? 0)
// countInset = countImage.size.width + ChatMessagePollOptionNode.countSpacing
// } else {
// countInset = 0.0
// }
let rightInset: CGFloat = 10.0 + mediaInset
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: optionAttributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: max(1.0, constrainedWidth - leftInset - rightInset), height: CGFloat.greatestFiniteMagnitude), alignment: .left, cutout: nil, insets: UIEdgeInsets(top: 1.0, left: 0.0, bottom: 1.0, right: 0.0)))
let contentLayoutHeight: CGFloat = max(52.0, titleLayout.size.height + 28.0)
let contentHeight: CGFloat = contentLayoutHeight + (optionResult != nil ? 7.0 : 0.0)
var resultIcon: UIImage?
var updatedResultIcon = false
var selection: ChatMessagePollOptionSelection?
if optionResult != nil {
if let voters = poll.results.voters {
for voter in voters {
if voter.opaqueIdentifier == option.opaqueIdentifier {
if voter.selected || voter.isCorrect {
selection = ChatMessagePollOptionSelection(isSelected: voter.selected, isCorrect: voter.isCorrect)
}
break
}
}
}
}
if selection != currentSelection || themeUpdated {
updatedResultIcon = true
if let selection = selection {
var isQuiz = false
if case .quiz = poll.kind {
isQuiz = true
}
resultIcon = generateImage(CGSize(width: 16.0, height: 16.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
var isIncorrect = false
let fillColor: UIColor
if selection.isSelected {
if isQuiz {
if selection.isCorrect {
fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.barPositive : presentationData.theme.theme.chat.message.outgoing.polls.barPositive
} else {
fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.barNegative : presentationData.theme.theme.chat.message.outgoing.polls.barNegative
isIncorrect = true
}
} else {
fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar
}
} else if isQuiz && selection.isCorrect {
fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar
} else {
fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar
}
context.setFillColor(fillColor.cgColor)
if poll.kind.multipleAnswers {
context.addPath(UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 4.0).cgPath)
context.fillPath()
} else {
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
}
let strokeColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.barIconForeground : presentationData.theme.theme.chat.message.outgoing.polls.barIconForeground
if strokeColor.alpha.isZero {
context.setBlendMode(.copy)
}
context.setStrokeColor(strokeColor.cgColor)
context.setLineWidth(1.5)
context.setLineJoin(.round)
context.setLineCap(.round)
if isIncorrect {
context.translateBy(x: 5.0, y: 5.0)
context.move(to: CGPoint(x: 0.0, y: 6.0))
context.addLine(to: CGPoint(x: 6.0, y: 0.0))
context.strokePath()
context.move(to: CGPoint(x: 0.0, y: 0.0))
context.addLine(to: CGPoint(x: 6.0, y: 6.0))
context.strokePath()
} else {
let _ = try? drawSvgPath(context, path: "M4,8.5 L6.44778395,10.9477839 C6.47662208,10.9766221 6.52452135,10.9754786 6.54754782,10.9524522 L12,5.5 S ")
}
})
}
}
return (titleLayout.size.width + leftInset + rightInset, { width in
return (CGSize(width: width, height: contentHeight), { animated, inProgress, attemptSynchronous in
let node: ChatMessagePollOptionNode
if let maybeNode = maybeNode {
node = maybeNode
} else {
node = ChatMessagePollOptionNode()
}
node.option = option
node.forceSelected = forceSelected
node.context = context
node.message = message
let previousMedia = node.currentMedia
node.currentMedia = media
node.currentRecentVoterPeerIds = optionResult?.recentVoterPeerIds ?? []
let previousResult = node.currentResult
node.currentResult = optionResult
node.currentSelection = selection
node.theme = presentationData.theme.theme
node.presentationData = presentationData
node.presentationContext = presentationContext
node.highlightedBackgroundNode.backgroundColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.highlight : presentationData.theme.theme.chat.message.outgoing.polls.highlight
node.buttonNode.accessibilityLabel = option.text
if animated {
if let titleNode = node.titleNode, let cachedLayout = titleNode.textNode.cachedLayout {
if !cachedLayout.areLinesEqual(to: titleLayout) {
if let textContents = titleNode.textNode.contents {
let fadeNode = ASDisplayNode()
fadeNode.displaysAsynchronously = false
fadeNode.contents = textContents
fadeNode.frame = titleNode.textNode.frame
fadeNode.isLayerBacked = true
node.containerNode.addSubnode(fadeNode)
fadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak fadeNode] _ in
fadeNode?.removeFromSupernode()
})
titleNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
}
}
}
let titleNode = titleApply(TextNodeWithEntities.Arguments(
context: context,
cache: context.animationCache,
renderer: context.animationRenderer,
placeholderColor: incoming ? presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor,
attemptSynchronous: attemptSynchronous
))
let titleNodeFrame: CGRect
if titleLayout.hasRTL {
titleNodeFrame = CGRect(origin: CGPoint(x: width - rightInset - titleLayout.size.width, y: 15.0), size: titleLayout.size)
} else {
titleNodeFrame = CGRect(origin: CGPoint(x: leftInset, y: 15.0), size: titleLayout.size)
}
if node.titleNode !== titleNode {
node.titleNode = titleNode
node.containerNode.addSubnode(titleNode.textNode)
titleNode.textNode.isUserInteractionEnabled = false
if let visibilityRect = node.visibilityRect {
titleNode.visibilityRect = visibilityRect.offsetBy(dx: 0.0, dy: titleNodeFrame.minY)
}
}
titleNode.textNode.frame = titleNodeFrame
if shouldHaveRadioNode {
let radioNode: ChatMessagePollOptionRadioNode
if let current = node.radioNode {
radioNode = current
} else {
radioNode = ChatMessagePollOptionRadioNode()
node.containerNode.addSubnode(radioNode)
node.radioNode = radioNode
if animated {
radioNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
}
let radioSize: CGFloat = 22.0
radioNode.frame = CGRect(origin: CGPoint(x: 12.0, y: 15.0), size: CGSize(width: radioSize, height: radioSize))
radioNode.update(isRectangle: poll.kind.multipleAnswers, staticColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.radioButton : presentationData.theme.theme.chat.message.outgoing.polls.radioButton, animatedColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.radioProgress : presentationData.theme.theme.chat.message.outgoing.polls.radioProgress, fillColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar, foregroundColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.barIconForeground : presentationData.theme.theme.chat.message.outgoing.polls.barIconForeground, isSelectable: isSelectable || forceSelected != nil, isAnimating: inProgress)
if let forceSelected {
radioNode.updateIsChecked(forceSelected, animated: false)
radioNode.isUserInteractionEnabled = false
} else {
radioNode.isUserInteractionEnabled = true
}
} else if let radioNode = node.radioNode {
node.radioNode = nil
if animated {
radioNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak radioNode] _ in
radioNode?.removeFromSupernode()
})
} else {
radioNode.removeFromSupernode()
}
}
if let updatedPercentageImage = updatedPercentageImage {
node.percentageNode.contents = updatedPercentageImage.cgImage
node.percentageImage = updatedPercentageImage
}
if let image = node.percentageImage {
node.percentageNode.frame = CGRect(origin: CGPoint(x: leftInset - 7.0 - image.size.width, y: 15.0), size: image.size)
if animated && previousResult?.percent != optionResult?.percent {
let percentageDuration = 0.27
let images = generatePercentageAnimationImages(presentationData: presentationData, incoming: incoming, from: previousResult?.percent ?? 0, to: optionResult?.percent ?? 0, duration: percentageDuration)
if !images.isEmpty {
let animation = CAKeyframeAnimation(keyPath: "contents")
animation.values = images.map { $0.cgImage! }
animation.duration = percentageDuration * UIView.animationDurationFactor()
animation.calculationMode = .discrete
node.percentageNode.layer.add(animation, forKey: "image")
}
}
}
if let updatedCountImage = updatedCountImage {
node.countNode.contents = updatedCountImage.cgImage
node.countImage = updatedCountImage
}
let recentVoterPeers: [Peer]
if let optionResult {
recentVoterPeers = optionResult.recentVoterPeerIds.compactMap { message.peers[$0] }
} else {
recentVoterPeers = []
}
var trailingOriginX = width - rightInset
let mediaFrame: CGRect?
if let _ = media {
mediaFrame = CGRect(origin: CGPoint(x: width - ChatMessagePollOptionNode.mediaRightInset - ChatMessagePollOptionNode.mediaSize.width, y: floor((contentLayoutHeight - ChatMessagePollOptionNode.mediaSize.height) * 0.5)), size: ChatMessagePollOptionNode.mediaSize)
trailingOriginX = mediaFrame!.minX - 12.0
} else {
mediaFrame = nil
}
node.mediaFrame = mediaFrame
if !recentVoterPeers.isEmpty {
let avatarsFrame = CGRect(origin: CGPoint(x: trailingOriginX + 15.0 - ChatMessagePollOptionNode.avatarsSize.width, y: floor((contentLayoutHeight - ChatMessagePollOptionNode.avatarsSize.height) * 0.5)), size: ChatMessagePollOptionNode.avatarsSize)
node.avatarsNode.frame = avatarsFrame
node.avatarsNode.updateLayout(size: avatarsFrame.size)
node.avatarsNode.update(context: context, peers: recentVoterPeers, synchronousLoad: attemptSynchronous, imageSize: MergedAvatarsNode.defaultMergedImageSize, imageSpacing: MergedAvatarsNode.defaultMergedImageSpacing, borderWidth: MergedAvatarsNode.defaultBorderWidth)
node.avatarsNode.alpha = 1.0
trailingOriginX = avatarsFrame.minX - ChatMessagePollOptionNode.avatarsSpacing
} else {
node.avatarsNode.update(context: context, peers: [], synchronousLoad: attemptSynchronous, imageSize: MergedAvatarsNode.defaultMergedImageSize, imageSpacing: MergedAvatarsNode.defaultMergedImageSpacing, borderWidth: MergedAvatarsNode.defaultBorderWidth)
node.avatarsNode.alpha = 0.0
}
if let image = node.countImage, displayCount {
let countFrame = CGRect(origin: CGPoint(x: trailingOriginX - image.size.width, y: floor((contentLayoutHeight - image.size.height) * 0.5)), size: image.size)
node.countNode.frame = countFrame
if animated && previousResult?.count != optionResult?.count {
let countDuration = 0.27
let images = generateCountAnimationImages(presentationData: presentationData, incoming: incoming, from: previousResult?.count ?? 0, to: optionResult?.count ?? 0, duration: countDuration)
if !images.isEmpty {
let animation = CAKeyframeAnimation(keyPath: "contents")
animation.values = images.compactMap { $0.cgImage }
animation.duration = countDuration * UIView.animationDurationFactor()
animation.calculationMode = .discrete
node.countNode.layer.add(animation, forKey: "image")
}
}
}
var isSticker = false
if let media, var mediaFrame, let file = media as? TelegramMediaFile, file.isSticker || file.isCustomEmoji {
isSticker = true
if let dimensions = file.dimensions {
let mediaSize = dimensions.cgSize.aspectFitted(mediaFrame.size)
mediaFrame = CGRect(origin: CGPoint(x: mediaFrame.midX - mediaSize.width * 0.5, y: mediaFrame.midY - mediaSize.height * 0.5), size: mediaSize)
}
let stickerLayer: InlineStickerItemLayer
if let current = node.stickerMediaLayer, previousMedia?.isEqual(to: media) == true {
stickerLayer = current
} else {
node.stickerMediaLayer?.removeFromSuperlayer()
let emoji = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file, custom: nil, enableAnimation: true)
stickerLayer = InlineStickerItemLayer(
context: context,
userLocation: .other,
attemptSynchronousLoad: attemptSynchronous,
emoji: emoji,
file: file,
cache: context.animationCache,
renderer: context.animationRenderer,
unique: false,
placeholderColor: incoming ? presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor,
pointSize: CGSize(width: mediaFrame.width * 2.0, height: mediaFrame.height * 2.0),
dynamicColor: nil,
loopCount: nil
)
node.containerNode.layer.addSublayer(stickerLayer)
node.stickerMediaLayer = stickerLayer
}
stickerLayer.frame = mediaFrame
stickerLayer.isVisibleForAnimations = true
node.mediaNode?.removeFromSupernode()
node.mediaNode = nil
} else {
if let stickerMediaLayer = node.stickerMediaLayer {
stickerMediaLayer.removeFromSuperlayer()
node.stickerMediaLayer = nil
}
}
var updatedFetchSignal: Signal<Void, NoError>?
if let media, let mediaFrame, !isSticker {
let mediaNode: TransformImageNode
if let current = node.mediaNode {
mediaNode = current
} else {
let current = TransformImageNode()
current.contentAnimations = [.subsequentUpdates]
node.mediaNode = current
node.containerNode.addSubnode(current)
mediaNode = current
}
mediaNode.isHidden = false
mediaNode.frame = mediaFrame
let mediaReference = AnyMediaReference.message(message: MessageReference(message), media: media)
var imageSize = ChatMessagePollOptionNode.mediaSize
var isVideo = false
if let image = media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations) {
imageSize = largest.dimensions.cgSize.aspectFilled(ChatMessagePollOptionNode.mediaSize)
if previousMedia?.isEqual(to: media) != true, let photoReference = mediaReference.concrete(TelegramMediaImage.self) {
mediaNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: .peer(message.id.peerId), photoReference: photoReference))
updatedFetchSignal = messageMediaImageInteractiveFetched(context: context, message: message, image: image, resource: largest.resource, storeToDownloadsPeerId: nil)
}
} else if let file = media as? TelegramMediaFile {
if let dimensions = file.dimensions {
imageSize = dimensions.cgSize.aspectFilled(ChatMessagePollOptionNode.mediaSize)
}
if let fileReference = mediaReference.concrete(TelegramMediaFile.self), previousMedia?.isEqual(to: media) != true {
if file.mimeType.hasPrefix("image/") {
mediaNode.setSignal(instantPageImageFile(account: context.account, userLocation: .peer(message.id.peerId), fileReference: fileReference, fetched: true))
} else {
mediaNode.setSignal(mediaGridMessageVideo(postbox: context.account.postbox, userLocation: .peer(message.id.peerId), videoReference: fileReference, autoFetchFullSizeThumbnail: true))
}
}
isVideo = file.isVideo
} else if let map = media as? TelegramMediaMap {
if previousMedia?.isEqual(to: media) != true {
let resource = MapSnapshotMediaResource(latitude: map.latitude, longitude: map.longitude, width: Int32(ChatMessagePollOptionNode.mediaSize.width), height: Int32(ChatMessagePollOptionNode.mediaSize.height))
mediaNode.setSignal(chatMapSnapshotImage(engine: context.engine, resource: resource))
}
}
let makeLayout = mediaNode.asyncLayout()
let apply = makeLayout(TransformImageArguments(
corners: ImageCorners(radius: 10.0),
imageSize: imageSize,
boundingSize: ChatMessagePollOptionNode.mediaSize,
intrinsicInsets: UIEdgeInsets(),
emptyColor: incoming ? presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor
))
apply()
if isVideo {
let mediaVideoIconNode: ASImageNode
if let current = node.mediaVideoIconNode {
mediaVideoIconNode = current
} else {
let current = ASImageNode()
current.displaysAsynchronously = false
current.image = generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/PlayButton"), color: .white)
node.mediaVideoIconNode = current
node.containerNode.addSubnode(current)
mediaVideoIconNode = current
}
mediaVideoIconNode.frame = CGRect(origin: CGPoint(x: mediaFrame.midX - 15.0, y: mediaFrame.midY - 15.0), size: CGSize(width: 30.0, height: 30.0))
mediaVideoIconNode.isHidden = false
} else if let mediaVideoIconNode = node.mediaVideoIconNode {
mediaVideoIconNode.removeFromSupernode()
node.mediaVideoIconNode = nil
}
} else if let mediaNode = node.mediaNode {
mediaNode.removeFromSupernode()
node.mediaNode = nil
if let mediaVideoIconNode = node.mediaVideoIconNode {
mediaVideoIconNode.removeFromSupernode()
node.mediaVideoIconNode = nil
}
} else if let mediaVideoIconNode = node.mediaVideoIconNode {
mediaVideoIconNode.removeFromSupernode()
node.mediaVideoIconNode = nil
}
node.setMediaHidden(node.mediaHidden)
if let updatedFetchSignal {
node.fetchDisposable.set(updatedFetchSignal.start())
}
node.buttonNode.frame = CGRect(origin: CGPoint(x: 1.0, y: 0.0), size: CGSize(width: width - 2.0, height: contentHeight))
if node.highlightedBackgroundNode.supernode == node {
node.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: contentHeight + UIScreenPixel))
}
node.separatorNode.backgroundColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.separator : presentationData.theme.theme.chat.message.outgoing.polls.separator
node.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentLayoutHeight - UIScreenPixel), size: CGSize(width: width - leftInset - 10.0, height: UIScreenPixel))
node.containerNode.frame = CGRect(origin: .zero, size: CGSize(width: width, height: contentHeight))
node.contextSourceNode.frame = CGRect(origin: .zero, size: CGSize(width: width, height: contentHeight))
node.contextSourceNode.contentRect = CGRect(origin: .zero, size: CGSize(width: width, height: contentHeight))
if node.resultBarBackgroundNode.image == nil || themeUpdated {
node.resultBarBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 4.0, color: optionTextColor.withMultipliedAlpha(0.07))
}
if node.resultBarNode.image == nil || updatedResultIcon {
var isQuiz = false
if case .quiz = poll.kind {
isQuiz = true
}
let fillColor: UIColor
if let selection = selection {
if selection.isSelected {
if isQuiz {
if selection.isCorrect {
fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.barPositive : presentationData.theme.theme.chat.message.outgoing.polls.barPositive
} else {
fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.barNegative : presentationData.theme.theme.chat.message.outgoing.polls.barNegative
}
} else {
fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar
}
} else if isQuiz && selection.isCorrect {
fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar
} else {
fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar
}
} else {
fillColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar
}
node.resultBarNode.image = generateStretchableFilledCircleImage(diameter: 4.0, color: fillColor)
}
if updatedResultIcon {
node.resultBarIconNode.image = resultIcon
}
let minBarWidth: CGFloat = 6.0
let maxBarWidth = width - leftInset - rightInset
let resultBarWidth = minBarWidth + floor((width - leftInset - rightInset - minBarWidth) * (optionResult?.normalized ?? 0.0))
let barFrame = CGRect(origin: CGPoint(x: leftInset, y: contentLayoutHeight - 6.0 - 1.0), size: CGSize(width: resultBarWidth, height: 4.0))
node.resultBarBackgroundNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentLayoutHeight - 6.0 - 1.0), size: CGSize(width: maxBarWidth, height: 4.0))
node.resultBarNode.frame = barFrame
node.resultBarIconNode.frame = CGRect(origin: CGPoint(x: barFrame.minX - 6.0 - 16.0, y: barFrame.minY + floor((barFrame.height - 16.0) / 2.0)), size: CGSize(width: 16.0, height: 16.0))
node.resultBarBackgroundNode.alpha = optionResult != nil ? 1.0 : 0.0
node.resultBarNode.alpha = optionResult != nil ? 1.0 : 0.0
node.percentageNode.alpha = optionResult != nil ? 1.0 : 0.0
node.countNode.alpha = optionResult != nil ? 1.0 : 0.0
node.separatorNode.alpha = optionResult == nil ? 1.0 : 0.0
node.resultBarIconNode.alpha = optionResult != nil ? 1.0 : 0.0
if animated, currentResult != optionResult {
if (currentResult != nil) != (optionResult != nil) {
if optionResult != nil {
node.resultBarBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
node.resultBarNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
node.percentageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
node.countNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
if !recentVoterPeers.isEmpty {
node.avatarsNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
node.separatorNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.08)
node.resultBarIconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
} else {
node.resultBarBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4)
node.resultBarNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4)
node.percentageNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
node.countNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
node.avatarsNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
node.separatorNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
node.resultBarIconNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
}
node.buttonNode.isAccessibilityElement = shouldHaveRadioNode
let previousResultBarWidth = minBarWidth + floor((width - leftInset - rightInset - minBarWidth) * (currentResult?.normalized ?? 0.0))
let previousFrame = CGRect(origin: CGPoint(x: leftInset, y: contentHeight - 6.0 - 1.0), size: CGSize(width: previousResultBarWidth, height: 6.0))
node.resultBarNode.layer.animateSpring(from: NSValue(cgPoint: previousFrame.center), to: NSValue(cgPoint: node.resultBarNode.frame.center), keyPath: "position", duration: 0.6, damping: 110.0)
node.resultBarNode.layer.animateSpring(from: NSValue(cgRect: CGRect(origin: CGPoint(), size: previousFrame.size)), to: NSValue(cgRect: CGRect(origin: CGPoint(), size: node.resultBarNode.frame.size)), keyPath: "bounds", duration: 0.6, damping: 110.0)
}
return node
})
})
}
}
}
private let labelsFont = Font.regular(14.0)
private final class ChatMessagePollAddOptionNode: ASDisplayNode {
struct Attachment: Equatable {
let media: AnyMediaReference?
let progress: CGFloat?
}
private final class StateBridge: ComponentState {
}
private let textField = ComponentView<Empty>()
private let textFieldState = StateBridge()
private let leftAccessoryButton: HighlightableButtonNode
private let addIconNode: ASImageNode
let separatorNode: ASDisplayNode
private let imageButton: HighlightTrackingButtonNode
private var attachButton: HighlightableButtonNode?
private var modeSelector: ComponentView<Empty>?
private var imageNode: TransformImageNode?
private var animationLayer: InlineStickerItemLayer?
private var statusNode: RadialStatusNode?
private var videoIconView: UIImageView?
private var appliedMedia: AnyMediaReference?
private var currentFont: UIFont?
private var currentTextColor: UIColor?
private var currentSecondaryTextColor: UIColor?
private var currentPlaceholderColor: UIColor?
private var currentTintColor: UIColor?
private var currentMeasuredHeight: CGFloat?
private var currentTextWidth: CGFloat = 0.0
private var currentSize: CGSize = .zero
private var currentIsEditing = false
private var currentTextValue = NSAttributedString()
private var currentAttachment: Attachment?
private var currentContext: AccountContext?
private var currentStrings: PresentationStrings?
private var currentTheme: PresentationTheme?
private var currentIncoming = false
private var currentFocusedTextInputIsMedia = false
private var currentModeSelectorAnimationName: String?
var textUpdated: ((NSAttributedString) -> Void)?
var heightUpdated: (() -> Void)?
var focusUpdated: ((Bool) -> Void)?
var attachPressed: (() -> Void)?
var mediaPressed: (() -> Void)?
var modeSelectorPressed: (() -> Void)?
static let characterLimit = 100
private static let leftInset: CGFloat = 50.0
private static let rightInset: CGFloat = 10.0
private static let verticalInset: CGFloat = 15.0
private static let minHeight: CGFloat = 52.0
private static let attachmentInset: CGFloat = 52.0
override init() {
self.leftAccessoryButton = HighlightableButtonNode()
self.addIconNode = ASImageNode()
self.addIconNode.displaysAsynchronously = false
self.addIconNode.isUserInteractionEnabled = false
self.separatorNode = ASDisplayNode()
self.imageButton = HighlightTrackingButtonNode()
super.init()
self.addSubnode(self.leftAccessoryButton)
self.addSubnode(self.addIconNode)
self.addSubnode(self.separatorNode)
self.leftAccessoryButton.addTarget(self, action: #selector(self.leftAccessoryPressed), forControlEvents: .touchUpInside)
self.imageButton.isExclusiveTouch = true
self.imageButton.addTarget(self, action: #selector(self.imageButtonPressed), forControlEvents: .touchUpInside)
self.textField.parentState = self.textFieldState
self.textFieldState._updated = { [weak self] transition, _ in
self?.handleTextFieldStateUpdated(transition: transition)
}
}
var text: NSAttributedString {
return self.currentTextValue
}
func setText(_ text: NSAttributedString) {
self.currentTextValue = text
if let textFieldView = self.textFieldView {
textFieldView.updateText(text, selectionRange: text.length ..< text.length)
} else {
self._textFieldExternalState.initialText = text
}
}
func resignInput() {
self.textFieldView?.deactivateInput()
}
fileprivate func inputTextFieldView() -> TextFieldComponent.View? {
return self.textFieldView
}
fileprivate func inputTextView() -> UITextView? {
return self.textFieldView?.inputTextView
}
private let _textFieldExternalState = TextFieldComponent.ExternalState()
private var textFieldView: TextFieldComponent.View? {
return self.textField.view as? TextFieldComponent.View
}
private func makeTextFieldComponent() -> TextFieldComponent? {
guard let context = self.currentContext, let theme = self.currentTheme, let strings = self.currentStrings, let currentTextColor = self.currentTextColor, let currentPlaceholderColor = self.currentPlaceholderColor, let currentTintColor = self.currentTintColor, let font = self.currentFont else {
return nil
}
return TextFieldComponent(
context: context,
theme: theme,
strings: strings,
externalState: self._textFieldExternalState,
fontSize: font.pointSize,
textColor: currentTextColor,
accentColor: currentTintColor,
insets: UIEdgeInsets(top: ChatMessagePollAddOptionNode.verticalInset, left: 0.0, bottom: ChatMessagePollAddOptionNode.verticalInset, right: 0.0),
hideKeyboard: self.currentFocusedTextInputIsMedia,
customInputView: nil,
placeholder: NSAttributedString(string: strings.CreatePoll_AddOption, font: font, textColor: currentPlaceholderColor),
resetText: nil,
isOneLineWhenUnfocused: false,
characterLimit: ChatMessagePollAddOptionNode.characterLimit,
enableInlineAnimations: true,
emptyLineHandling: .allowed,
formatMenuAvailability: .none,
lockedFormatAction: {
},
present: { _ in
},
paste: { _ in
}
)
}
@discardableResult
private func updateTextFieldLayout(size: CGSize, forceUpdate: Bool) -> CGSize {
guard let component = self.makeTextFieldComponent() else {
return CGSize(width: self.currentTextWidth, height: max(ChatMessagePollAddOptionNode.minHeight, size.height))
}
let textFieldSize = self.textField.update(
transition: .immediate,
component: AnyComponent(component),
environment: {},
forceUpdate: forceUpdate,
containerSize: CGSize(width: self.currentTextWidth, height: 1000.0)
)
if let textFieldView = self.textField.view {
if textFieldView.superview == nil {
self.view.insertSubview(textFieldView, belowSubview: self.leftAccessoryButton.view)
}
textFieldView.frame = CGRect(origin: CGPoint(x: ChatMessagePollAddOptionNode.leftInset - 8.0, y: 0.0), size: CGSize(width: textFieldSize.width, height: size.height))
}
return textFieldSize
}
private func handleTextFieldStateUpdated(transition: ComponentTransition) {
guard let secondaryTextColor = self.currentSecondaryTextColor, !self.currentSize.width.isZero else {
return
}
let previousText = self.currentTextValue
let previousIsEditing = self.currentIsEditing
let previousMeasuredHeight = self.currentMeasuredHeight ?? self.currentSize.height
let textFieldSize = self.updateTextFieldLayout(size: self.currentSize, forceUpdate: true)
let updatedText = self._textFieldExternalState.text
let updatedIsEditing = self.textFieldView?.isActive ?? self._textFieldExternalState.isEditing
let updatedMeasuredHeight = max(ChatMessagePollAddOptionNode.minHeight, textFieldSize.height)
self.currentTextValue = updatedText
self.currentIsEditing = updatedIsEditing
self.currentMeasuredHeight = updatedMeasuredHeight
if previousText != updatedText {
self.textUpdated?(updatedText)
}
if abs(previousMeasuredHeight - updatedMeasuredHeight) > 0.1 {
self.heightUpdated?()
}
if previousIsEditing != updatedIsEditing {
self.updateModeSelectorLayout(size: self.currentSize, theme: self.currentTheme, animated: !transition.animation.isImmediate)
self.focusUpdated?(updatedIsEditing)
}
self.updateAttachmentLayout(size: self.currentSize, tintColor: secondaryTextColor)
}
private static func measureContentHeight(text: String, font: UIFont, textWidth: CGFloat) -> CGFloat {
var measureText = text
if measureText.hasSuffix("\n") || measureText.isEmpty {
measureText += "|"
}
let measureTextNode = ImmediateTextNode()
measureTextNode.maximumNumberOfLines = 0
measureTextNode.attributedText = NSAttributedString(string: measureText, font: font, textColor: .black)
let measureSize = measureTextNode.updateLayout(CGSize(width: textWidth, height: .greatestFiniteMagnitude))
return max(ChatMessagePollAddOptionNode.minHeight, measureSize.height + ChatMessagePollAddOptionNode.verticalInset * 2.0)
}
static func asyncLayout(_ maybeNode: ChatMessagePollAddOptionNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ strings: PresentationStrings, _ incoming: Bool, _ focusedTextInputIsMedia: Bool, _ text: NSAttributedString, _ attachment: Attachment?, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool) -> ChatMessagePollAddOptionNode))) {
return { context, presentationData, strings, incoming, focusedTextInputIsMedia, text, attachment, constrainedWidth in
let font = presentationData.messageFont
let textColor = incoming ? presentationData.theme.theme.chat.message.incoming.primaryTextColor : presentationData.theme.theme.chat.message.outgoing.primaryTextColor
let secondaryTextColor = incoming ? presentationData.theme.theme.chat.message.incoming.secondaryTextColor : presentationData.theme.theme.chat.message.outgoing.secondaryTextColor
let placeholderColor = (incoming ? presentationData.theme.theme.chat.message.incoming.secondaryTextColor : presentationData.theme.theme.chat.message.outgoing.secondaryTextColor).withMultipliedAlpha(0.7)
let tintColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar
let hasAttachmentPreview = attachment?.media != nil
let displayAttachButton = !text.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !hasAttachmentPreview
let constrainedWidth = min(268.0, constrainedWidth)
let textWidth = max(1.0, constrainedWidth - ChatMessagePollAddOptionNode.leftInset - ChatMessagePollAddOptionNode.rightInset - (hasAttachmentPreview || displayAttachButton ? ChatMessagePollAddOptionNode.attachmentInset : 0.0))
let contentHeight = ChatMessagePollAddOptionNode.measureContentHeight(text: text.string, font: font, textWidth: textWidth)
return (constrainedWidth, { width in
let size = CGSize(width: width, height: contentHeight)
return (size, { _, _ in
let node = maybeNode ?? ChatMessagePollAddOptionNode()
node.currentFont = font
node.currentTextColor = textColor
node.currentSecondaryTextColor = secondaryTextColor
node.currentPlaceholderColor = placeholderColor
node.currentTintColor = tintColor
node.currentStrings = strings
if node.currentTheme !== presentationData.theme.theme || node.addIconNode.image == nil {
node.addIconNode.image = generateTintedImage(image: PresentationResourcesChat.chatPollAddIcon(presentationData.theme.theme), color: secondaryTextColor.withMultipliedAlpha(0.7))
}
node.currentTextWidth = textWidth
node.currentSize = size
node.currentAttachment = attachment
node.currentContext = context
node.currentTheme = presentationData.theme.theme
node.currentIncoming = incoming
node.currentFocusedTextInputIsMedia = focusedTextInputIsMedia
let textFieldSize = node.updateTextFieldLayout(size: size, forceUpdate: false)
node.currentMeasuredHeight = max(ChatMessagePollAddOptionNode.minHeight, textFieldSize.height)
node.currentTextValue = node._textFieldExternalState.text
node.currentIsEditing = node.textFieldView?.isActive ?? node._textFieldExternalState.isEditing
node.leftAccessoryButton.frame = CGRect(origin: .zero, size: CGSize(width: ChatMessagePollAddOptionNode.leftInset, height: contentHeight))
node.separatorNode.frame = CGRect(origin: CGPoint(x: ChatMessagePollAddOptionNode.leftInset, y: contentHeight - UIScreenPixel), size: CGSize(width: width - ChatMessagePollAddOptionNode.leftInset - 10.0, height: UIScreenPixel))
node.separatorNode.backgroundColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.separator : presentationData.theme.theme.chat.message.outgoing.polls.separator
node.updateModeSelectorLayout(size: size, theme: presentationData.theme.theme, animated: false)
node.updateAttachmentLayout(size: size, tintColor: secondaryTextColor)
return node
})
})
}
}
@objc private func leftAccessoryPressed() {
self.modeSelectorPressed?()
}
@objc private func imageButtonPressed() {
self.mediaPressed?()
}
@objc private func attachButtonPressed() {
self.attachPressed?()
}
private func updateModeSelectorLayout(size: CGSize, theme: PresentationTheme?, animated: Bool) {
guard !size.width.isZero, !size.height.isZero, let theme = self.currentTheme else {
return
}
let secondaryTextColor = self.currentIncoming ? theme.chat.message.incoming.secondaryTextColor : theme.chat.message.outgoing.secondaryTextColor
let addIconSize = self.addIconNode.image?.size ?? .zero
self.addIconNode.frame = CGRect(origin: CGPoint(x: floor((ChatMessagePollAddOptionNode.leftInset - addIconSize.width) * 0.5) - 2.0, y: floor((size.height - addIconSize.height) * 0.5)), size: addIconSize)
let displaySelector = self.currentIsEditing
self.addIconNode.isHidden = displaySelector
if displaySelector {
var playAnimation = false
let modeSelectorSize = CGSize(width: 32.0, height: 32.0)
var modeSelectorFrame = CGRect(origin: CGPoint(x: floor((ChatMessagePollAddOptionNode.leftInset - modeSelectorSize.width) * 0.5) - 2.0, y: floor((size.height - modeSelectorSize.height) * 0.5)), size: modeSelectorSize)
let animationName = self.currentFocusedTextInputIsMedia ? "input_anim_smileToKey" : "input_anim_keyToSmile"
if let currentModeSelectorAnimationName = self.currentModeSelectorAnimationName, currentModeSelectorAnimationName != animationName {
playAnimation = true
}
self.currentModeSelectorAnimationName = animationName
if self.currentFocusedTextInputIsMedia {
modeSelectorFrame = modeSelectorFrame.offsetBy(dx: 3.0, dy: 0.0)
}
let modeSelector: ComponentView<Empty>
if let current = self.modeSelector {
modeSelector = current
} else {
modeSelector = ComponentView()
self.modeSelector = modeSelector
}
let _ = modeSelector.update(
transition: animated ? .easeInOut(duration: 0.2) : .immediate,
component: AnyComponent(PlainButtonComponent(
content: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: animationName),
color: secondaryTextColor,
size: modeSelectorSize
)),
effectAlignment: .center,
action: { [weak self] in
self?.leftAccessoryPressed()
},
animateScale: false
)),
environment: {},
containerSize: modeSelectorSize
)
if let modeSelectorView = modeSelector.view as? PlainButtonComponent.View {
if modeSelectorView.superview == nil {
self.view.addSubview(modeSelectorView)
}
if playAnimation {
let transition = ComponentTransition(animation: .curve(duration: animationName == "input_anim_smileToKey" ? 0.32 : 0.26, curve: .easeInOut))
transition.setFrame(view: modeSelectorView, frame: modeSelectorFrame)
} else {
modeSelectorView.frame = modeSelectorFrame
}
modeSelectorView.alpha = 1.0
modeSelectorView.transform = .identity
if let animationView = modeSelectorView.contentView as? LottieComponent.View {
if playAnimation {
animationView.playOnce()
}
}
}
} else if let modeSelector = self.modeSelector {
self.modeSelector = nil
modeSelector.view?.removeFromSuperview()
}
}
private func updateAttachmentLayout(size: CGSize, tintColor: UIColor) {
let imageNodeSize = CGSize(width: 40.0, height: 40.0)
let imageNodeFrame = CGRect(origin: CGPoint(x: size.width - 10.0 - imageNodeSize.width, y: size.height - ChatMessagePollAddOptionNode.minHeight + floor((ChatMessagePollAddOptionNode.minHeight - imageNodeSize.height) * 0.5)), size: imageNodeSize)
let shouldShowAttachButton = self.currentAttachment?.media == nil && self.text.length != 0
if shouldShowAttachButton {
let attachButton: HighlightableButtonNode
if let current = self.attachButton {
attachButton = current
} else {
let current = HighlightableButtonNode()
current.addTarget(self, action: #selector(self.attachButtonPressed), forControlEvents: .touchUpInside)
current.setImage(generateTintedImage(image: UIImage(bundleImageName: "Item List/AttachIcon"), color: tintColor), for: .normal)
self.attachButton = current
self.addSubnode(current)
attachButton = current
}
attachButton.frame = CGRect(origin: CGPoint(x: size.width - 15.0 - 24.0, y: size.height - ChatMessagePollAddOptionNode.minHeight + floor((ChatMessagePollAddOptionNode.minHeight - 24.0) * 0.5)), size: CGSize(width: 24.0, height: 24.0))
attachButton.isHidden = false
} else if let attachButton = self.attachButton {
self.attachButton = nil
attachButton.removeFromSupernode()
}
var isSticker = false
if let attachment = self.currentAttachment, let file = attachment.media?.media as? TelegramMediaFile, (file.isSticker || file.isCustomEmoji), let context = self.currentContext, let theme = self.currentTheme {
isSticker = true
let animationLayer: InlineStickerItemLayer
if let current = self.animationLayer {
animationLayer = current
} else {
let current = InlineStickerItemLayer(
context: context,
userLocation: .other,
attemptSynchronousLoad: true,
emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file, custom: nil, enableAnimation: true),
file: file,
cache: context.animationCache,
renderer: context.animationRenderer,
unique: false,
placeholderColor: theme.list.mediaPlaceholderColor,
pointSize: CGSize(width: imageNodeSize.width * 2.0, height: imageNodeSize.height * 2.0),
dynamicColor: nil,
loopCount: nil
)
current.isVisibleForAnimations = true
self.animationLayer = current
self.layer.addSublayer(current)
animationLayer = current
}
animationLayer.frame = imageNodeFrame
self.appliedMedia = attachment.media
if self.imageButton.supernode == nil {
self.addSubnode(self.imageButton)
}
self.imageButton.frame = imageNodeFrame
self.imageButton.isHidden = false
} else if let animationLayer = self.animationLayer {
self.animationLayer = nil
animationLayer.removeFromSuperlayer()
}
if let attachment = self.currentAttachment, let media = attachment.media, !isSticker, let context = self.currentContext, let theme = self.currentTheme {
let imageNode: TransformImageNode
if let current = self.imageNode {
imageNode = current
} else {
let current = TransformImageNode()
current.isUserInteractionEnabled = false
self.imageNode = current
self.addSubnode(current)
imageNode = current
}
imageNode.frame = imageNodeFrame
var imageSize = imageNodeSize
let updateMedia = self.appliedMedia != media
if updateMedia {
self.appliedMedia = media
}
var isVideo = false
if let image = media.media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations), let photoReference = media.concrete(TelegramMediaImage.self) {
imageSize = largest.dimensions.cgSize.aspectFilled(imageNodeSize)
if updateMedia {
imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: .other, photoReference: photoReference))
}
} else if let file = media.media as? TelegramMediaFile, let fileReference = media.concrete(TelegramMediaFile.self) {
if let dimensions = file.dimensions {
imageSize = dimensions.cgSize.aspectFilled(imageNodeSize)
}
if file.mimeType.hasPrefix("image/") {
if updateMedia {
imageNode.setSignal(instantPageImageFile(account: context.account, userLocation: .other, fileReference: fileReference, fetched: true))
}
} else if file.isVideo {
if updateMedia {
imageNode.setSignal(chatMessageVideo(postbox: context.account.postbox, userLocation: .other, videoReference: fileReference))
}
isVideo = true
}
} else if let map = media.media as? TelegramMediaMap {
imageSize = imageNodeSize
if updateMedia {
let resource = MapSnapshotMediaResource(latitude: map.latitude, longitude: map.longitude, width: Int32(imageSize.width), height: Int32(imageSize.height))
imageNode.setSignal(chatMapSnapshotImage(engine: context.engine, resource: resource))
}
}
let apply = imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(radius: 10.0), imageSize: imageSize, boundingSize: imageNodeSize, intrinsicInsets: UIEdgeInsets(), emptyColor: theme.list.mediaPlaceholderColor))
apply()
if self.imageButton.supernode == nil {
self.addSubnode(self.imageButton)
}
self.imageButton.frame = imageNodeFrame
self.imageButton.isHidden = false
if let progress = attachment.progress {
let statusNode: RadialStatusNode
if let current = self.statusNode {
statusNode = current
} else {
let current = RadialStatusNode(backgroundNodeColor: UIColor(rgb: 0x000000, alpha: 0.5))
self.statusNode = current
self.addSubnode(current)
statusNode = current
}
statusNode.frame = imageNodeFrame.insetBy(dx: 4.0, dy: 4.0)
statusNode.transitionToState(.progress(color: .white, lineWidth: 2.0, value: max(0.027, min(1.0, progress)), cancelEnabled: true, animateRotation: false))
isVideo = false
} else if let statusNode = self.statusNode {
self.statusNode = nil
statusNode.removeFromSupernode()
}
if isVideo {
let videoIconView: UIImageView
if let current = self.videoIconView {
videoIconView = current
} else {
let current = UIImageView(image: UIImage(bundleImageName: "Media Gallery/PlayButton")?.withRenderingMode(.alwaysTemplate))
current.tintColor = .white
self.view.addSubview(current)
self.videoIconView = current
videoIconView = current
}
videoIconView.frame = CGRect(origin: CGPoint(x: imageNodeFrame.midX - 15.0, y: imageNodeFrame.midY - 15.0), size: CGSize(width: 30.0, height: 30.0))
videoIconView.isHidden = false
} else if let videoIconView = self.videoIconView {
self.videoIconView = nil
videoIconView.removeFromSuperview()
}
} else {
self.appliedMedia = nil
if let imageNode = self.imageNode {
self.imageNode = nil
imageNode.removeFromSupernode()
}
if let statusNode = self.statusNode {
self.statusNode = nil
statusNode.removeFromSupernode()
}
if let videoIconView = self.videoIconView {
self.videoIconView = nil
videoIconView.removeFromSuperview()
}
self.imageButton.removeFromSupernode()
}
}
}
private final class SolutionButtonNode: HighlightableButtonNode {
private let pressed: () -> Void
let iconNode: ASImageNode
private var theme: PresentationTheme?
private var incoming: Bool?
init(pressed: @escaping () -> Void) {
self.pressed = pressed
self.iconNode = ASImageNode()
self.iconNode.displaysAsynchronously = false
super.init()
self.addSubnode(self.iconNode)
self.addTarget(self, action: #selector(self.pressedEvent), forControlEvents: .touchUpInside)
}
@objc private func pressedEvent() {
self.pressed()
}
func update(size: CGSize, theme: PresentationTheme, incoming: Bool) {
if self.theme !== theme || self.incoming != incoming {
self.theme = theme
self.incoming = incoming
self.iconNode.image = PresentationResourcesChat.chatBubbleLamp(theme, incoming: incoming)
}
if let image = self.iconNode.image {
self.iconNode.frame = CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((size.height - image.size.height) / 2.0)), size: image.size)
}
}
}
public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode {
private final class AttachedMedia {
var media: AnyMediaReference
var progress: CGFloat?
var uploadDisposable: Disposable?
init(media: AnyMediaReference) {
self.media = media
}
var requiresUpload: Bool {
if let image = self.media.media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations), !(largest.resource is CloudPhotoSizeMediaResource) {
return true
}
if let file = self.media.media as? TelegramMediaFile, !(file.resource is CloudDocumentMediaResource) {
return true
}
return false
}
}
private let textNode: TextNodeWithEntities
private let typeNode: TextNode
private var timerNode: PollBubbleTimerNode?
private let solutionButtonNode: SolutionButtonNode
private let avatarsNode: MergedAvatarsNode
private let votersNode: TextNode
private var deadlineTimerNode: DeadlineTimerNode?
private let buttonSubmitInactiveTextNode: TextNode
private let buttonSubmitActiveTextNode: TextNode
private let buttonSaveTextNode: TextNode
private let buttonViewResultsTextNode: TextNode
private let buttonNode: HighlightableButtonNode
private let statusNode: ChatMessageDateAndStatusNode
private var optionNodes: [ChatMessagePollOptionNode] = []
private var addOptionNode: ChatMessagePollAddOptionNode?
private var shimmeringNodes: [ShimmeringLinkNode] = []
private let temporaryHiddenMediaDisposable = MetaDisposable()
private var poll: TelegramMediaPoll?
private var currentNewOptionText = NSAttributedString()
private var currentNewOptionMedia: AttachedMedia?
private var pendingNewOptionSubmissionText: String?
private var pendingNewOptionOptionCount: Int?
private var newOptionIsFocused = false
private var isPreviewingResults = false
public var solutionTipSourceNode: ASDisplayNode {
return self.solutionButtonNode
}
override public var visibility: ListViewItemNodeVisibility {
didSet {
if oldValue != self.visibility {
switch self.visibility {
case .none:
self.textNode.visibilityRect = nil
for optionNode in self.optionNodes {
optionNode.visibilityRect = nil
}
case let .visible(_, subRect):
var subRect = subRect
subRect.origin.x = 0.0
subRect.size.width = 10000.0
self.textNode.visibilityRect = subRect.offsetBy(dx: 0.0, dy: -self.textNode.textNode.frame.minY)
for optionNode in self.optionNodes {
optionNode.visibilityRect = subRect.offsetBy(dx: 0.0, dy: -optionNode.frame.minY)
}
}
}
}
}
required public init() {
self.textNode = TextNodeWithEntities()
self.textNode.textNode.isUserInteractionEnabled = false
self.textNode.textNode.contentMode = .topLeft
self.textNode.textNode.contentsScale = UIScreenScale
self.textNode.textNode.displaysAsynchronously = false
self.typeNode = TextNode()
self.typeNode.isUserInteractionEnabled = false
self.typeNode.contentMode = .topLeft
self.typeNode.contentsScale = UIScreenScale
self.typeNode.displaysAsynchronously = false
self.avatarsNode = MergedAvatarsNode()
self.votersNode = TextNode()
self.votersNode.isUserInteractionEnabled = false
self.votersNode.contentMode = .topLeft
self.votersNode.contentsScale = UIScreenScale
self.votersNode.displaysAsynchronously = false
self.votersNode.clipsToBounds = true
var displaySolution: (() -> Void)?
self.solutionButtonNode = SolutionButtonNode(pressed: {
displaySolution?()
})
self.solutionButtonNode.alpha = 0.0
self.buttonSubmitInactiveTextNode = TextNode()
self.buttonSubmitInactiveTextNode.isUserInteractionEnabled = false
self.buttonSubmitInactiveTextNode.contentMode = .topLeft
self.buttonSubmitInactiveTextNode.contentsScale = UIScreenScale
self.buttonSubmitInactiveTextNode.displaysAsynchronously = false
self.buttonSubmitActiveTextNode = TextNode()
self.buttonSubmitActiveTextNode.isUserInteractionEnabled = false
self.buttonSubmitActiveTextNode.contentMode = .topLeft
self.buttonSubmitActiveTextNode.contentsScale = UIScreenScale
self.buttonSubmitActiveTextNode.displaysAsynchronously = false
self.buttonSaveTextNode = TextNode()
self.buttonSaveTextNode.isUserInteractionEnabled = false
self.buttonSaveTextNode.contentMode = .topLeft
self.buttonSaveTextNode.contentsScale = UIScreenScale
self.buttonSaveTextNode.displaysAsynchronously = false
self.buttonViewResultsTextNode = TextNode()
self.buttonViewResultsTextNode.isUserInteractionEnabled = false
self.buttonViewResultsTextNode.contentMode = .topLeft
self.buttonViewResultsTextNode.contentsScale = UIScreenScale
self.buttonViewResultsTextNode.displaysAsynchronously = false
self.buttonNode = HighlightableButtonNode()
self.statusNode = ChatMessageDateAndStatusNode()
super.init()
self.addSubnode(self.textNode.textNode)
self.addSubnode(self.typeNode)
self.addSubnode(self.avatarsNode)
self.addSubnode(self.votersNode)
self.addSubnode(self.solutionButtonNode)
self.addSubnode(self.buttonSubmitInactiveTextNode)
self.addSubnode(self.buttonSubmitActiveTextNode)
self.addSubnode(self.buttonSaveTextNode)
self.addSubnode(self.buttonViewResultsTextNode)
self.addSubnode(self.buttonNode)
displaySolution = { [weak self] in
guard let strongSelf = self, let item = strongSelf.item, let poll = strongSelf.poll, let solution = poll.results.solution else {
return
}
item.controllerInteraction.displayPollSolution(solution, strongSelf.solutionButtonNode)
}
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.buttonSubmitActiveTextNode.layer.removeAnimation(forKey: "opacity")
strongSelf.buttonSubmitActiveTextNode.alpha = 0.6
strongSelf.buttonSaveTextNode.layer.removeAnimation(forKey: "opacity")
strongSelf.buttonSaveTextNode.alpha = 0.6
strongSelf.buttonViewResultsTextNode.layer.removeAnimation(forKey: "opacity")
strongSelf.buttonViewResultsTextNode.alpha = 0.6
} else {
strongSelf.buttonSubmitActiveTextNode.alpha = 1.0
strongSelf.buttonSubmitActiveTextNode.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.3)
strongSelf.buttonSaveTextNode.alpha = 1.0
strongSelf.buttonSaveTextNode.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.3)
strongSelf.buttonViewResultsTextNode.alpha = 1.0
strongSelf.buttonViewResultsTextNode.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.3)
}
}
}
self.avatarsNode.pressed = { [weak self] in
self?.buttonPressed()
}
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.currentNewOptionMedia?.uploadDisposable?.dispose()
self.temporaryHiddenMediaDisposable.dispose()
}
private func optionNodeForMedia(_ media: Media) -> ChatMessagePollOptionNode? {
for optionNode in self.optionNodes {
if let optionMedia = optionNode.option?.media, optionMedia.isEqual(to: media) {
return optionNode
}
}
return nil
}
private func updateNewOptionText(_ text: NSAttributedString) {
self.currentNewOptionText = text
self.requestNewOptionLayoutUpdate()
self.updateSelection()
}
private func updatePollOptionsInteraction(animated: Bool) {
guard let item = self.item else {
return
}
let arePollOptionsDisabled = self.newOptionIsFocused
let isPollActionInProgress = item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] != nil
for optionNode in self.optionNodes {
let alpha: CGFloat = arePollOptionsDisabled ? 0.5 : 1.0
if animated && abs(optionNode.alpha - alpha) > 0.001 {
optionNode.layer.animateAlpha(from: optionNode.alpha, to: alpha, duration: 0.2)
}
optionNode.alpha = alpha
optionNode.isUserInteractionEnabled = !arePollOptionsDisabled && !isPollActionInProgress
}
}
private func requestNewOptionLayoutUpdate() {
guard let item = self.item else {
return
}
item.controllerInteraction.requestMessageUpdate(item.message.id, false)
}
private func updatePollAddOptionFocused(_ focus: Bool) {
guard let item = self.item else {
return
}
self.newOptionIsFocused = focus
if !focus {
item.controllerInteraction.focusedTextInputIsMedia = false
}
item.controllerInteraction.updatePresentationState { state in
if focus {
if state.focusedPollAddOptionMessageId == item.message.id {
return state
}
return state.updatedFocusedPollAddOptionMessageId(item.message.id)
} else {
if state.focusedPollAddOptionMessageId != item.message.id {
return state
}
return state.updatedFocusedPollAddOptionMessageId(nil)
}
}
self.updatePollOptionsInteraction(animated: true)
}
private func clearNewOptionInput() {
self.updatePollAddOptionFocused(false)
self.currentNewOptionText = NSAttributedString()
self.currentNewOptionMedia?.uploadDisposable?.dispose()
self.currentNewOptionMedia = nil
self.pendingNewOptionSubmissionText = nil
self.pendingNewOptionOptionCount = nil
self.addOptionNode?.setText(NSAttributedString())
self.addOptionNode?.resignInput()
self.requestNewOptionLayoutUpdate()
}
private func toggleNewOptionInputMode() {
guard let item = self.item else {
return
}
item.controllerInteraction.updatePresentationState { [weak item] state in
var focusedTextInputIsMedia = false
let updatedState = state.updatedInputMode({ inputMode in
if case .media = inputMode {
return .text
} else {
focusedTextInputIsMedia = true
return .media(mode: .other, expanded: .none, focused: true)
}
})
item?.controllerInteraction.focusedTextInputIsMedia = focusedTextInputIsMedia
return updatedState
}
self.requestNewOptionLayoutUpdate()
}
private func openNewOptionAttachment() {
guard let item = self.item else {
return
}
if let media = self.currentNewOptionMedia {
media.uploadDisposable?.dispose()
self.currentNewOptionMedia = nil
self.requestNewOptionLayoutUpdate()
self.updateSelection()
return
}
item.controllerInteraction.dismissTextInput()
presentPollAttachmentScreen(
context: item.context,
updatedPresentationData: item.controllerInteraction.updatedPresentationData,
availableButtons: [.gallery, .sticker, .emoji, .location],
present: { [weak item] controller in
item?.controllerInteraction.navigationController()?.pushViewController(controller)
},
completion: { [weak self] media in
guard let self else {
return
}
let attachedMedia = AttachedMedia(media: media)
self.currentNewOptionMedia = attachedMedia
self.uploadAttachedMediaIfNeeded(attachedMedia)
self.requestNewOptionLayoutUpdate()
self.updateSelection()
}
)
}
private func uploadAttachedMediaIfNeeded(_ media: AttachedMedia) {
guard let item = self.item, media.requiresUpload, media.uploadDisposable == nil else {
return
}
media.progress = 0.0
if let image = media.media.media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations) {
media.uploadDisposable = (standaloneUploadedImage(
postbox: item.context.account.postbox,
network: item.context.account.network,
peerId: item.message.id.peerId,
text: "",
source: .resource(media.media.resourceReference(largest.resource)),
dimensions: largest.dimensions
)
|> deliverOnMainQueue).start(next: { [weak self] value in
guard let self else {
return
}
switch value {
case let .progress(progress):
media.progress = CGFloat(progress)
case let .result(result):
if case let .media(resultMedia) = result {
if let resultImage = resultMedia.media as? TelegramMediaImage, let resultLargest = largestImageRepresentation(resultImage.representations) {
item.context.account.postbox.mediaBox.moveResourceData(from: largest.resource.id, to: resultLargest.resource.id, synchronous: true)
}
media.media = resultMedia
media.progress = nil
media.uploadDisposable?.dispose()
media.uploadDisposable = nil
}
}
self.requestNewOptionLayoutUpdate()
self.updateSelection()
})
} else if let file = media.media.media as? TelegramMediaFile {
media.uploadDisposable = (standaloneUploadedFile(
postbox: item.context.account.postbox,
network: item.context.account.network,
peerId: item.message.id.peerId,
text: "",
source: .resource(media.media.resourceReference(file.resource)),
thumbnailData: file.immediateThumbnailData,
mimeType: file.mimeType,
attributes: file.attributes,
hintFileIsLarge: false
)
|> deliverOnMainQueue).start(next: { [weak self] value in
guard let self else {
return
}
switch value {
case let .progress(progress):
media.progress = CGFloat(progress)
case let .result(result):
if case let .media(resultMedia) = result {
if let resultFile = resultMedia.media as? TelegramMediaFile {
item.context.account.postbox.mediaBox.moveResourceData(from: file.resource.id, to: resultFile.resource.id, synchronous: true)
}
media.media = resultMedia
media.progress = nil
media.uploadDisposable?.dispose()
media.uploadDisposable = nil
}
}
self.requestNewOptionLayoutUpdate()
self.updateSelection()
})
}
}
@objc private func buttonPressed() {
guard let item = self.item, let poll = self.poll, let pollId = poll.id else {
return
}
let trimmedNewOptionText = self.currentNewOptionText.string.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedNewOptionText.isEmpty {
if let media = self.currentNewOptionMedia, media.requiresUpload {
return
}
self.pendingNewOptionSubmissionText = trimmedNewOptionText
self.pendingNewOptionOptionCount = poll.options.count
let entities = generateChatInputTextEntities(self.currentNewOptionText)
let optionData = "\(poll.options.count)".data(using: .utf8)!
item.controllerInteraction.requestAddMessagePollOption(item.message.id, trimmedNewOptionText, entities, optionData, self.currentNewOptionMedia?.media)
return
}
var hasSelection = false
var selectedOpaqueIdentifiers: [Data] = []
for optionNode in self.optionNodes {
if let option = optionNode.option {
if let isChecked = optionNode.radioNode?.isChecked {
hasSelection = true
if isChecked {
selectedOpaqueIdentifiers.append(option.opaqueIdentifier)
}
}
}
}
var canAlwaysViewResults = false
if let peer = item.message.peers[item.message.id.peerId] {
if let group = peer as? TelegramGroup {
switch group.role {
case .creator, .admin:
canAlwaysViewResults = true
default:
break
}
} else if let channel = peer as? TelegramChannel, let _ = channel.adminRights {
canAlwaysViewResults = true
}
}
if !hasSelection || (canAlwaysViewResults && selectedOpaqueIdentifiers.isEmpty) {
if !Namespaces.Message.allNonRegular.contains(item.message.id.namespace) {
switch poll.publicity {
case .public:
item.controllerInteraction.requestOpenMessagePollResults(item.message.id, pollId)
case .anonymous:
self.isPreviewingResults = !self.isPreviewingResults
item.controllerInteraction.requestMessageUpdate(item.message.id, false)
}
}
} else if !selectedOpaqueIdentifiers.isEmpty {
item.controllerInteraction.requestSelectMessagePollOptions(item.message.id, selectedOpaqueIdentifiers)
}
}
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 makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode)
let makeTypeLayout = TextNode.asyncLayout(self.typeNode)
let makeVotersLayout = TextNode.asyncLayout(self.votersNode)
let makeSubmitInactiveTextLayout = TextNode.asyncLayout(self.buttonSubmitInactiveTextNode)
let makeSubmitActiveTextLayout = TextNode.asyncLayout(self.buttonSubmitActiveTextNode)
let makeSaveTextLayout = TextNode.asyncLayout(self.buttonSaveTextNode)
let makeViewResultsTextLayout = TextNode.asyncLayout(self.buttonViewResultsTextNode)
let statusLayout = self.statusNode.asyncLayout()
let makeAddOptionLayout = ChatMessagePollAddOptionNode.asyncLayout(self.addOptionNode)
var previousPoll: TelegramMediaPoll?
let currentNewOptionText = self.currentNewOptionText
let currentNewOptionAttachment = ChatMessagePollAddOptionNode.Attachment(media: self.currentNewOptionMedia?.media, progress: self.currentNewOptionMedia?.progress)
let pendingNewOptionSubmissionText = self.pendingNewOptionSubmissionText
if let item = self.item {
for media in item.message.media {
if let media = media as? TelegramMediaPoll {
previousPoll = media
}
}
}
var previousOptionNodeLayouts: [Data: (_ contet: AccountContext, _ presentationData: ChatPresentationData, _ presentationContext: ChatPresentationContext, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ translation: TranslationMessageAttribute.Additional?, _ optionResult: ChatMessagePollOptionResult?, _ forceSelected: Bool?, _ hasAnyMedia: Bool, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode)))] = [:]
for optionNode in self.optionNodes {
if let option = optionNode.option {
previousOptionNodeLayouts[option.opaqueIdentifier] = ChatMessagePollOptionNode.asyncLayout(optionNode)
}
}
let isPreviewingResults = self.isPreviewingResults
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
let message = item.message
let incoming = item.message.effectivelyIncoming(item.context.account.peerId)
var isBotChat: Bool = false
if let peer = item.message.peers[item.message.id.peerId] as? TelegramUser, peer.botInfo != nil {
isBotChat = true
}
let additionalTextRightInset: CGFloat = 24.0
let horizontalInset = layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right
let textConstrainedSize = CGSize(width: constrainedSize.width - horizontalInset - additionalTextRightInset, height: constrainedSize.height)
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.message)
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 {
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?
if case .customChatContents = item.associatedData.subject {
statusType = nil
} else {
switch position {
case .linear(_, .None), .linear(_, .Neighbour(true, _, _)):
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))
}
}
default:
statusType = nil
}
}
var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))?
if let statusType = statusType {
var isReplyThread = false
if case .replyThread = item.chatLocation {
isReplyThread = true
}
statusSuggestedWidthAndContinue = statusLayout(ChatMessageDateAndStatusNode.Arguments(
context: item.context,
presentationData: item.presentationData,
edited: edited,
impressionCount: viewCount,
dateText: dateText,
type: statusType,
layoutInput: .trailingContent(contentWidth: 1000.0, reactionSettings: shouldDisplayInlineDateReactions(message: item.message, isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions) ? ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: true, preferAdditionalInset: false) : nil),
constrainedSize: textConstrainedSize,
availableReactions: item.associatedData.availableReactions,
savedMessageTags: item.associatedData.savedMessageTags,
reactions: 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: item.topMessage),
animationCache: item.controllerInteraction.presentationContext.animationCache,
animationRenderer: item.controllerInteraction.presentationContext.animationRenderer
))
}
var poll: TelegramMediaPoll?
for media in item.message.media {
if let media = media as? TelegramMediaPoll {
poll = media
break
}
}
let messageTheme = incoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing
var pollTitleText = poll?.text ?? ""
var pollTitleEntities = poll?.textEntities ?? []
var pollOptions: [TranslationMessageAttribute.Additional] = []
var isTranslating = false
if let poll, let translateToLanguage = item.associatedData.translateToLanguage, !poll.text.isEmpty && incoming {
isTranslating = true
for attribute in item.message.attributes {
if let attribute = attribute as? TranslationMessageAttribute, !attribute.text.isEmpty, attribute.toLang == translateToLanguage {
pollTitleText = attribute.text
pollTitleEntities = attribute.entities
pollOptions = attribute.additional
isTranslating = false
break
}
}
}
let attributedText = stringWithAppliedEntities(
pollTitleText,
entities: pollTitleEntities,
baseColor: messageTheme.primaryTextColor,
linkColor: messageTheme.linkTextColor,
baseFont: item.presentationData.messageBoldFont,
linkFont: item.presentationData.messageBoldFont,
boldFont: item.presentationData.messageBoldFont,
italicFont: item.presentationData.messageBoldFont,
boldItalicFont: item.presentationData.messageBoldFont,
fixedFont: item.presentationData.messageBoldFont,
blockQuoteFont: item.presentationData.messageBoldFont,
message: message
)
let textInsets = UIEdgeInsets(top: 2.0, left: 0.0, bottom: 5.0, right: 0.0)
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets))
let typeText: String
var avatarPeers: [Peer] = []
if let poll = poll {
for peerId in poll.results.recentVoters {
if let peer = item.message.peers[peerId] {
avatarPeers.append(peer)
}
}
}
if let poll = poll, isPollEffectivelyClosed(message: message, poll: poll) {
typeText = item.presentationData.strings.MessagePoll_LabelClosed
} else if let poll = poll {
switch poll.kind {
case .poll:
switch poll.publicity {
case .anonymous:
typeText = item.presentationData.strings.MessagePoll_LabelAnonymous
case .public:
typeText = item.presentationData.strings.MessagePoll_LabelPoll
}
case .quiz:
switch poll.publicity {
case .anonymous:
typeText = item.presentationData.strings.MessagePoll_LabelAnonymousQuiz
case .public:
typeText = item.presentationData.strings.MessagePoll_LabelQuiz
}
}
} else {
typeText = item.presentationData.strings.MessagePoll_LabelAnonymous
}
let (typeLayout, typeApply) = makeTypeLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: typeText, font: labelsFont, textColor: messageTheme.secondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let votersString: String?
if isBotChat {
votersString = nil
} else if let poll = poll, let totalVoters = poll.results.totalVoters {
switch poll.kind {
case .poll:
if totalVoters == 0 {
votersString = item.presentationData.strings.MessagePoll_NoVotes
} else {
votersString = item.presentationData.strings.MessagePoll_VotedCount(totalVoters)
}
case .quiz:
if totalVoters == 0 {
votersString = item.presentationData.strings.MessagePoll_QuizNoUsers
} else {
votersString = item.presentationData.strings.MessagePoll_QuizCount(totalVoters)
}
}
} else {
votersString = " "
}
let (votersLayout, votersApply) = makeVotersLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: votersString ?? "", font: labelsFont, textColor: messageTheme.secondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets))
var hasVoted = false
if let poll, let voters = poll.results.voters {
for voter in voters {
if voter.selected {
hasVoted = true
break
}
}
}
let viewResultsString: String
if let poll, let totalVoters = poll.results.totalVoters {
//TODO:localize
if case .public = poll.publicity {
viewResultsString = "View Votes (\(totalVoters))"
} else {
if isPreviewingResults {
viewResultsString = "< Vote"
} else {
if totalVoters == 1 {
viewResultsString = "\(totalVoters) vote >"
} else {
viewResultsString = "\(totalVoters) votes >"
}
}
}
} else {
viewResultsString = item.presentationData.strings.MessagePoll_ViewResults
}
let viewResultsAttributedString = NSMutableAttributedString(string: viewResultsString, font: Font.regular(17.0), textColor: messageTheme.polls.bar)
if let range = viewResultsAttributedString.string.range(of: "<") {
let chevronImage = incoming ? PresentationResourcesChat.chatBubblePollChevronLeftIncomingIcon(item.presentationData.theme.theme) : PresentationResourcesChat.chatBubblePollChevronLeftOutgoingIcon(item.presentationData.theme.theme)
viewResultsAttributedString.addAttribute(.attachment, value: chevronImage!, range: NSRange(range, in: viewResultsAttributedString.string))
}
if let range = viewResultsAttributedString.string.range(of: ">") {
let chevronImage = incoming ? PresentationResourcesChat.chatBubblePollChevronRightIncomingIcon(item.presentationData.theme.theme) : PresentationResourcesChat.chatBubblePollChevronRightOutgoingIcon(item.presentationData.theme.theme)
viewResultsAttributedString.addAttribute(.attachment, value: chevronImage!, range: NSRange(range, in: viewResultsAttributedString.string))
}
let (buttonSubmitInactiveTextLayout, buttonSubmitInactiveTextApply) = makeSubmitInactiveTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.presentationData.strings.MessagePoll_SubmitVote, font: Font.regular(17.0), textColor: messageTheme.accentControlDisabledColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets))
let (buttonSubmitActiveTextLayout, buttonSubmitActiveTextApply) = makeSubmitActiveTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.presentationData.strings.MessagePoll_SubmitVote, font: Font.regular(17.0), textColor: messageTheme.polls.bar), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets))
let (buttonSaveTextLayout, buttonSaveTextApply) = makeSaveTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.presentationData.strings.Common_Save, font: Font.regular(17.0), textColor: messageTheme.polls.bar), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets))
let (buttonViewResultsTextLayout, buttonViewResultsTextApply) = makeViewResultsTextLayout(TextNodeLayoutArguments(attributedString: viewResultsAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets))
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: layoutConstants.text.bubbleInsets.top)
textFrameWithoutInsets = textFrameWithoutInsets.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: layoutConstants.text.bubbleInsets.top)
var boundingSize: CGSize = textFrameWithoutInsets.size
boundingSize.width += additionalTextRightInset
boundingSize.width = max(boundingSize.width, typeLayout.size.width)
boundingSize.width = max(boundingSize.width, votersLayout.size.width + 4.0/* + (statusSize?.width ?? 0.0)*/)
boundingSize.width = max(boundingSize.width, buttonSubmitInactiveTextLayout.size.width + 4.0/* + (statusSize?.width ?? 0.0)*/)
boundingSize.width = max(boundingSize.width, buttonSaveTextLayout.size.width + 4.0/* + (statusSize?.width ?? 0.0)*/)
boundingSize.width = max(boundingSize.width, buttonViewResultsTextLayout.size.width + 4.0/* + (statusSize?.width ?? 0.0)*/)
if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue {
boundingSize.width = max(boundingSize.width, statusSuggestedWidthAndContinue.0)
}
boundingSize.width += layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right
boundingSize.height += layoutConstants.text.bubbleInsets.top + layoutConstants.text.bubbleInsets.bottom
let isClosed: Bool
if let poll = poll {
isClosed = isPollEffectivelyClosed(message: message, poll: poll)
} else {
isClosed = false
}
var pollOptionsFinalizeLayouts: [(hasResult: Bool, layout: (CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode))] = []
var addOptionFinalizeLayout: ((CGFloat) -> (CGSize, (Bool, Bool) -> ChatMessagePollAddOptionNode))?
var orderedPollOptions: [(Int, TelegramMediaPollOption)] = []
if let poll = poll {
orderedPollOptions = resolvedOptionOrder(for: item)
var optionVoterCount: [Int: Int32] = [:]
var maxOptionVoterCount: Int32 = 0
var totalVoterCount: Int32 = 0
let voters: [TelegramMediaPollOptionVoters]?
if isClosed {
voters = poll.results.voters ?? []
} else {
voters = poll.results.voters
}
var votedFor = Set<Data>()
if let voters = voters, let totalVoters = poll.results.totalVoters {
var didVote = false
for voter in voters {
if voter.selected {
didVote = true
votedFor.insert(voter.opaqueIdentifier)
}
}
totalVoterCount = totalVoters
if didVote || isClosed || isPreviewingResults {
for i in 0 ..< poll.options.count {
inner: for optionVoters in voters {
if optionVoters.opaqueIdentifier == poll.options[i].opaqueIdentifier {
optionVoterCount[i] = optionVoters.count
//TODO:correct
maxOptionVoterCount = max(maxOptionVoterCount, optionVoters.count ?? 0)
break inner
}
}
}
}
}
var optionVoterCounts: [Int]
if totalVoterCount != 0 {
optionVoterCounts = countNicePercent(votes: (0 ..< poll.options.count).map({ Int(optionVoterCount[$0] ?? 0) }), total: Int(totalVoterCount))
} else {
optionVoterCounts = Array(repeating: 0, count: poll.options.count)
}
let hasAnyOptionMedia = orderedPollOptions.contains(where: { $0.1.media != nil })
for (i, option) in orderedPollOptions {
let makeLayout: (_ context: AccountContext, _ presentationData: ChatPresentationData, _ presentationContext: ChatPresentationContext, _ message: Message, _ poll: TelegramMediaPoll, _ option: TelegramMediaPollOption, _ translation: TranslationMessageAttribute.Additional?, _ optionResult: ChatMessagePollOptionResult?, _ forceSelected: Bool?, _ hasAnyMedia: Bool, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode)))
if let previous = previousOptionNodeLayouts[option.opaqueIdentifier] {
makeLayout = previous
} else {
makeLayout = ChatMessagePollOptionNode.asyncLayout(nil)
}
var optionResult: ChatMessagePollOptionResult?
var recentVoterPeerIds: [PeerId] = []
if case .public = poll.publicity, let voters = voters {
for optionVoters in voters where optionVoters.opaqueIdentifier == option.opaqueIdentifier {
recentVoterPeerIds = optionVoters.recentVoters
break
}
}
if let count = optionVoterCount[i] {
if maxOptionVoterCount != 0 && totalVoterCount != 0 {
optionResult = ChatMessagePollOptionResult(normalized: CGFloat(count) / CGFloat(maxOptionVoterCount), percent: optionVoterCounts[i], count: count, recentVoterPeerIds: recentVoterPeerIds)
} else if isClosed {
optionResult = ChatMessagePollOptionResult(normalized: 0, percent: 0, count: 0, recentVoterPeerIds: recentVoterPeerIds)
}
} else if isClosed {
optionResult = ChatMessagePollOptionResult(normalized: 0, percent: 0, count: 0, recentVoterPeerIds: recentVoterPeerIds)
}
var translation: TranslationMessageAttribute.Additional?
if !pollOptions.isEmpty && i < pollOptions.count {
translation = pollOptions[i]
}
var forceSelected: Bool?
if !votedFor.isEmpty && optionResult == nil {
forceSelected = votedFor.contains(option.opaqueIdentifier)
}
let result = makeLayout(item.context, item.presentationData, item.controllerInteraction.presentationContext, item.message, poll, option, translation, optionResult, forceSelected, hasAnyOptionMedia, constrainedSize.width - layoutConstants.bubble.borderInset * 2.0)
boundingSize.width = max(boundingSize.width, result.minimumWidth + layoutConstants.bubble.borderInset * 2.0)
pollOptionsFinalizeLayouts.append((optionResult != nil, result.1))
}
let displayAddOption = poll.openAnswers && !isClosed && !hasVoted && poll.pollId.namespace == Namespaces.Media.CloudPoll
if displayAddOption {
let addOptionResult = makeAddOptionLayout(item.context, item.presentationData, item.presentationData.strings, incoming, item.controllerInteraction.focusedTextInputIsMedia, currentNewOptionText, currentNewOptionAttachment, constrainedSize.width - layoutConstants.bubble.borderInset * 2.0)
boundingSize.width = max(boundingSize.width, addOptionResult.minimumWidth + layoutConstants.bubble.borderInset * 2.0)
addOptionFinalizeLayout = addOptionResult.layout
}
}
boundingSize.width = max(boundingSize.width, min(270.0, constrainedSize.width))
var canVote = false
if (item.message.id.namespace == Namespaces.Message.Cloud || Namespaces.Message.allNonRegular.contains(item.message.id.namespace)), let poll = poll, poll.pollId.namespace == Namespaces.Media.CloudPoll, !isClosed {
if !hasVoted {
canVote = true
}
}
let _ = canVote
return (boundingSize.width, { boundingWidth in
var resultSize = CGSize(width: max(boundingSize.width, boundingWidth), height: boundingSize.height)
let titleTypeSpacing: CGFloat = -4.0
let typeOptionsSpacing: CGFloat = 3.0
resultSize.height += titleTypeSpacing + typeLayout.size.height + typeOptionsSpacing
var optionNodesSizesAndApply: [(CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode)] = []
for finalizeLayout in pollOptionsFinalizeLayouts {
let result = finalizeLayout.layout(boundingWidth - layoutConstants.bubble.borderInset * 2.0)
resultSize.width = max(resultSize.width, result.0.width + layoutConstants.bubble.borderInset * 2.0)
if finalizeLayout.hasResult {
resultSize.height += result.0.height - 7.0
} else {
resultSize.height += result.0.height
}
optionNodesSizesAndApply.append(result)
}
var addOptionSizeAndApply: (CGSize, (Bool, Bool) -> ChatMessagePollAddOptionNode)?
if let addOptionFinalizeLayout {
let result = addOptionFinalizeLayout(boundingWidth - layoutConstants.bubble.borderInset * 2.0)
resultSize.width = max(resultSize.width, result.0.width + layoutConstants.bubble.borderInset * 2.0)
resultSize.height += result.0.height
addOptionSizeAndApply = result
}
let optionsVotersSpacing: CGFloat = 11.0
let optionsButtonSpacing: CGFloat = 9.0
let votersBottomSpacing: CGFloat = 11.0
if votersString != nil {
resultSize.height += optionsVotersSpacing + votersLayout.size.height + votersBottomSpacing
} else {
resultSize.height += 26.0
}
if let poll, case .poll = poll.kind, !poll.isClosed, let _ = poll.deadlineTimeout {
resultSize.height += 6.0
}
var statusSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> Void)?
if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue {
statusSizeAndApply = statusSuggestedWidthAndContinue.1(boundingWidth)
}
if let statusSizeAndApply = statusSizeAndApply {
resultSize.height += statusSizeAndApply.0.height - 6.0
}
let buttonSubmitInactiveTextFrame = CGRect(origin: CGPoint(x: floor((resultSize.width - buttonSubmitInactiveTextLayout.size.width) / 2.0), y: optionsButtonSpacing), size: buttonSubmitInactiveTextLayout.size)
let buttonSubmitActiveTextFrame = CGRect(origin: CGPoint(x: floor((resultSize.width - buttonSubmitActiveTextLayout.size.width) / 2.0), y: optionsButtonSpacing), size: buttonSubmitActiveTextLayout.size)
let buttonSaveTextFrame = CGRect(origin: CGPoint(x: floor((resultSize.width - buttonSaveTextLayout.size.width) / 2.0), y: optionsButtonSpacing), size: buttonSaveTextLayout.size)
let buttonViewResultsTextFrame = CGRect(origin: CGPoint(x: floor((resultSize.width - buttonViewResultsTextLayout.size.width) / 2.0), y: optionsButtonSpacing), size: buttonViewResultsTextLayout.size)
return (resultSize, { [weak self] animation, synchronousLoad, _ in
if let strongSelf = self {
strongSelf.item = item
strongSelf.poll = poll
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.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.context.animationCache,
renderer: item.context.animationRenderer,
placeholderColor: incoming ? item.presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : item.presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor,
attemptSynchronous: synchronousLoad)
)
let _ = typeApply()
var verticalOffset = textFrame.maxY + titleTypeSpacing + typeLayout.size.height + typeOptionsSpacing
var updatedOptionNodes: [ChatMessagePollOptionNode] = []
for i in 0 ..< optionNodesSizesAndApply.count {
let (size, apply) = optionNodesSizesAndApply[i]
var isRequesting = false
if i < orderedPollOptions.count {
if let inProgressOpaqueIds = item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] {
isRequesting = inProgressOpaqueIds.contains(orderedPollOptions[i].1.opaqueIdentifier)
}
}
let optionNode = apply(animation.isAnimated, isRequesting, synchronousLoad)
let optionNodeFrame = CGRect(origin: CGPoint(x: layoutConstants.bubble.borderInset, y: verticalOffset), size: size)
if optionNode.supernode !== strongSelf {
strongSelf.addSubnode(optionNode)
let option = optionNode.option
optionNode.pressed = { [weak self] in
guard let self, let item = self.item, let option else {
return
}
item.controllerInteraction.requestSelectMessagePollOptions(item.message.id, [option.opaqueIdentifier])
}
optionNode.selectionUpdated = { [weak self] in
guard let self else {
return
}
self.updateSelection()
}
optionNode.longTapped = { [weak self] in
guard let self, let item = self.item, let option else {
return
}
item.controllerInteraction.pollOptionLongTap(option.opaqueIdentifier, ChatControllerInteraction.LongTapParams(message: item.message, contentNode: optionNode.contextSourceNode, messageNode: strongSelf, progress: nil))
}
optionNode.frame = optionNodeFrame
} else {
animation.animator.updateFrame(layer: optionNode.layer, frame: optionNodeFrame, completion: nil)
}
if optionNode.currentResult != nil {
verticalOffset += size.height - 7.0
} else {
verticalOffset += size.height
}
updatedOptionNodes.append(optionNode)
optionNode.isUserInteractionEnabled = !strongSelf.newOptionIsFocused && item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] == nil
optionNode.alpha = strongSelf.newOptionIsFocused ? 0.5 : 1.0
if i > 0 {
optionNode.previousOptionNode = updatedOptionNodes[i - 1]
} else {
optionNode.previousOptionNode = nil
}
}
for optionNode in strongSelf.optionNodes {
if !updatedOptionNodes.contains(where: { $0 === optionNode }) {
optionNode.removeFromSupernode()
}
}
strongSelf.optionNodes = updatedOptionNodes
strongSelf.updatePollOptionsInteraction(animated: animation.isAnimated)
if let (size, apply) = addOptionSizeAndApply {
let isRequesting = item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] != nil
let addOptionNode = apply(animation.isAnimated, isRequesting)
let addOptionNodeFrame = CGRect(origin: CGPoint(x: layoutConstants.bubble.borderInset, y: verticalOffset), size: size)
if addOptionNode.supernode !== strongSelf {
strongSelf.addSubnode(addOptionNode)
} else {
animation.animator.updateFrame(layer: addOptionNode.layer, frame: addOptionNodeFrame, completion: nil)
}
addOptionNode.frame = addOptionNodeFrame
addOptionNode.isUserInteractionEnabled = !isRequesting
addOptionNode.textUpdated = { [weak self] text in
self?.updateNewOptionText(text)
}
addOptionNode.heightUpdated = { [weak self] in
self?.requestNewOptionLayoutUpdate()
}
addOptionNode.attachPressed = { [weak self] in
self?.openNewOptionAttachment()
}
addOptionNode.mediaPressed = { [weak self] in
self?.openNewOptionAttachment()
}
addOptionNode.modeSelectorPressed = { [weak self] in
self?.toggleNewOptionInputMode()
}
addOptionNode.focusUpdated = { [weak self] focused in
guard let self else {
return
}
self.updatePollAddOptionFocused(focused)
}
strongSelf.addOptionNode = addOptionNode
verticalOffset += size.height
} else if let addOptionNode = strongSelf.addOptionNode {
strongSelf.updatePollAddOptionFocused(false)
strongSelf.addOptionNode = nil
addOptionNode.removeFromSupernode()
}
if let poll = poll, let pendingNewOptionSubmissionText, let pendingNewOptionOptionCount = strongSelf.pendingNewOptionOptionCount, poll.options.count > pendingNewOptionOptionCount, poll.options.contains(where: { $0.text == pendingNewOptionSubmissionText }) {
strongSelf.clearNewOptionInput()
}
if textLayout.hasRTL {
strongSelf.textNode.textNode.frame = CGRect(origin: CGPoint(x: resultSize.width - textFrame.size.width - textInsets.left - layoutConstants.text.bubbleInsets.right - additionalTextRightInset, y: textFrame.origin.y), size: textFrame.size)
} else {
strongSelf.textNode.textNode.frame = textFrame
}
let typeFrame = CGRect(origin: CGPoint(x: textFrame.minX, y: textFrame.maxY + titleTypeSpacing), size: typeLayout.size)
animation.animator.updateFrame(layer: strongSelf.typeNode.layer, frame: typeFrame, completion: nil)
let deadlineTimeout = poll?.deadlineTimeout
var displayDeadlineTimer = true
var hasSelected = false
if let poll {
if let voters = poll.results.voters {
for voter in voters {
if voter.selected {
if case .quiz = poll.kind {
displayDeadlineTimer = false
} else {
displayDeadlineTimer = !poll.isClosed
}
hasSelected = true
break
}
}
}
}
var endDate: Int32?
if let deadlineTimeout, message.id.namespace == Namespaces.Message.Cloud {
let startDate: Int32
if let forwardInfo = message.forwardInfo {
startDate = forwardInfo.date
} else {
startDate = message.timestamp
}
endDate = startDate + deadlineTimeout
}
if let poll, case .quiz = poll.kind, let deadlineTimeout, !isClosed {
let timerNode: PollBubbleTimerNode
if let current = strongSelf.timerNode {
timerNode = current
let timerTransition: ContainedViewLayoutTransition
if animation.isAnimated {
timerTransition = .animated(duration: 0.25, curve: .easeInOut)
} else {
timerTransition = .immediate
}
if displayDeadlineTimer {
timerTransition.updateAlpha(node: timerNode, alpha: 1.0)
} else {
timerTransition.updateAlpha(node: timerNode, alpha: 0.0)
}
} else {
timerNode = PollBubbleTimerNode()
strongSelf.timerNode = timerNode
strongSelf.addSubnode(timerNode)
timerNode.reachedTimeout = {
guard let strongSelf = self, let _ = strongSelf.item else {
return
}
//item.controllerInteraction.requestMessageUpdate(item.message.id)
}
let timerTransition: ContainedViewLayoutTransition
if animation.isAnimated {
timerTransition = .animated(duration: 0.25, curve: .easeInOut)
} else {
timerTransition = .immediate
}
if displayDeadlineTimer {
timerNode.alpha = 0.0
timerTransition.updateAlpha(node: timerNode, alpha: 1.0)
} else {
timerNode.alpha = 0.0
}
}
timerNode.update(regularColor: messageTheme.secondaryTextColor, proximityColor: messageTheme.scamColor, timeout: deadlineTimeout, deadlineTimestamp: endDate)
timerNode.frame = CGRect(origin: CGPoint(x: resultSize.width - layoutConstants.text.bubbleInsets.right, y: typeFrame.minY), size: CGSize())
} else if let timerNode = strongSelf.timerNode {
strongSelf.timerNode = nil
let timerTransition: ContainedViewLayoutTransition
if animation.isAnimated {
timerTransition = .animated(duration: 0.25, curve: .easeInOut)
} else {
timerTransition = .immediate
}
timerTransition.updateAlpha(node: timerNode, alpha: 0.0, completion: { [weak timerNode] _ in
timerNode?.removeFromSupernode()
})
timerTransition.updateTransformScale(node: timerNode, scale: 0.1)
}
var statusOffset: CGFloat = 0.0
if let poll, case .poll = poll.kind, let endDate, !isClosed {
let timerNode: DeadlineTimerNode
if let current = strongSelf.deadlineTimerNode {
timerNode = current
let timerTransition: ContainedViewLayoutTransition
if animation.isAnimated {
timerTransition = .animated(duration: 0.25, curve: .easeInOut)
} else {
timerTransition = .immediate
}
if displayDeadlineTimer {
timerTransition.updateAlpha(node: timerNode, alpha: 1.0)
} else {
timerTransition.updateAlpha(node: timerNode, alpha: 0.0)
}
} else {
timerNode = DeadlineTimerNode()
strongSelf.deadlineTimerNode = timerNode
strongSelf.addSubnode(timerNode)
let timerTransition: ContainedViewLayoutTransition
if animation.isAnimated {
timerTransition = .animated(duration: 0.25, curve: .easeInOut)
} else {
timerTransition = .immediate
}
if displayDeadlineTimer {
timerNode.alpha = 0.0
timerTransition.updateAlpha(node: timerNode, alpha: 1.0)
} else {
timerNode.alpha = 0.0
}
}
timerNode.update(size: resultSize, color: messageTheme.secondaryTextColor, deadlineTimeout: endDate, resultsHidden: poll.hideResultsUntilClose)
timerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: verticalOffset + 31.0), size: CGSize(width: resultSize.width, height: 20.0))
statusOffset += 6.0
} else if let timerNode = strongSelf.deadlineTimerNode {
strongSelf.deadlineTimerNode = nil
let timerTransition: ContainedViewLayoutTransition
if animation.isAnimated {
timerTransition = .animated(duration: 0.25, curve: .easeInOut)
} else {
timerTransition = .immediate
}
timerTransition.updateAlpha(node: timerNode, alpha: 0.0, completion: { [weak timerNode] _ in
timerNode?.removeFromSupernode()
})
timerTransition.updateTransformScale(node: timerNode, scale: 0.1)
}
let solutionButtonSize = CGSize(width: 32.0, height: 32.0)
let solutionButtonFrame = CGRect(origin: CGPoint(x: resultSize.width - layoutConstants.text.bubbleInsets.right - solutionButtonSize.width + 5.0, y: typeFrame.minY - 16.0), size: solutionButtonSize)
strongSelf.solutionButtonNode.frame = solutionButtonFrame
if (strongSelf.timerNode == nil || !displayDeadlineTimer), let poll = poll, case .quiz = poll.kind, let _ = poll.results.solution, (isClosed || hasSelected) {
if strongSelf.solutionButtonNode.alpha.isZero {
let timerTransition: ContainedViewLayoutTransition
if animation.isAnimated {
timerTransition = .animated(duration: 0.25, curve: .easeInOut)
} else {
timerTransition = .immediate
}
timerTransition.updateAlpha(node: strongSelf.solutionButtonNode, alpha: 1.0)
}
strongSelf.solutionButtonNode.update(size: solutionButtonSize, theme: item.presentationData.theme.theme, incoming: incoming)
} else if !strongSelf.solutionButtonNode.alpha.isZero {
let timerTransition: ContainedViewLayoutTransition
if animation.isAnimated {
timerTransition = .animated(duration: 0.25, curve: .easeInOut)
} else {
timerTransition = .immediate
}
timerTransition.updateAlpha(node: strongSelf.solutionButtonNode, alpha: 0.0)
}
let avatarsFrame = CGRect(origin: CGPoint(x: typeFrame.maxX + 6.0, y: typeFrame.minY + floor((typeFrame.height - MergedAvatarsNode.defaultMergedImageSize) / 2.0)), size: CGSize(width: MergedAvatarsNode.defaultMergedImageSize + MergedAvatarsNode.defaultMergedImageSpacing * 2.0, height: MergedAvatarsNode.defaultMergedImageSize))
strongSelf.avatarsNode.frame = avatarsFrame
strongSelf.avatarsNode.updateLayout(size: avatarsFrame.size)
strongSelf.avatarsNode.update(context: item.context, peers: avatarPeers, synchronousLoad: synchronousLoad, imageSize: MergedAvatarsNode.defaultMergedImageSize, imageSpacing: MergedAvatarsNode.defaultMergedImageSpacing, borderWidth: MergedAvatarsNode.defaultBorderWidth)
strongSelf.avatarsNode.isHidden = isBotChat
let alphaTransition: ContainedViewLayoutTransition
if animation.isAnimated {
alphaTransition = .animated(duration: 0.25, curve: .easeInOut)
alphaTransition.updateAlpha(node: strongSelf.avatarsNode, alpha: avatarPeers.isEmpty ? 0.0 : 1.0)
} else {
alphaTransition = .immediate
}
let _ = votersApply()
let votersFrame = CGRect(origin: CGPoint(x: floor((resultSize.width - votersLayout.size.width) / 2.0), y: verticalOffset + optionsVotersSpacing), size: votersLayout.size)
animation.animator.updateFrame(layer: strongSelf.votersNode.layer, frame: votersFrame, completion: nil)
if animation.isAnimated, let previousPoll = previousPoll, let poll = poll {
if previousPoll.results.totalVoters == nil && poll.results.totalVoters != nil {
strongSelf.votersNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
}
}
if let statusSizeAndApply = statusSizeAndApply {
let statusFrame = CGRect(origin: CGPoint(x: resultSize.width - statusSizeAndApply.0.width - layoutConstants.text.bubbleInsets.right, y: votersFrame.maxY + statusOffset), size: statusSizeAndApply.0)
if strongSelf.statusNode.supernode == nil {
statusSizeAndApply.1(.None)
strongSelf.statusNode.frame = statusFrame
strongSelf.addSubnode(strongSelf.statusNode)
} else {
statusSizeAndApply.1(animation)
animation.animator.updateFrame(layer: strongSelf.statusNode.layer, frame: statusFrame, completion: nil)
}
} else if strongSelf.statusNode.supernode != nil {
strongSelf.statusNode.removeFromSupernode()
}
let _ = buttonSubmitInactiveTextApply()
strongSelf.buttonSubmitInactiveTextNode.frame = buttonSubmitInactiveTextFrame.offsetBy(dx: 0.0, dy: verticalOffset)
let _ = buttonSubmitActiveTextApply()
strongSelf.buttonSubmitActiveTextNode.frame = buttonSubmitActiveTextFrame.offsetBy(dx: 0.0, dy: verticalOffset)
let _ = buttonSaveTextApply()
strongSelf.buttonSaveTextNode.frame = buttonSaveTextFrame.offsetBy(dx: 0.0, dy: verticalOffset)
let _ = buttonViewResultsTextApply()
strongSelf.buttonViewResultsTextNode.frame = buttonViewResultsTextFrame.offsetBy(dx: 0.0, dy: verticalOffset)
strongSelf.buttonNode.frame = CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: CGSize(width: resultSize.width, height: 44.0))
strongSelf.updateSelection()
strongSelf.updatePollTooltipMessageState(animated: false)
strongSelf.updateIsTranslating(isTranslating)
}
})
})
})
}
}
private func updateIsTranslating(_ isTranslating: Bool) {
guard let item = self.item else {
return
}
var rects: [[CGRect]] = []
let titleRects = (self.textNode.textNode.rangeRects(in: NSRange(location: 0, length: self.textNode.textNode.cachedLayout?.attributedString?.length ?? 0))?.rects ?? []).map { self.textNode.textNode.view.convert($0, to: self.view) }
rects.append(titleRects)
for optionNode in self.optionNodes {
if let titleNode = optionNode.titleNode {
let optionRects = (titleNode.textNode.rangeRects(in: NSRange(location: 0, length: titleNode.textNode.cachedLayout?.attributedString?.length ?? 0))?.rects ?? []).map { titleNode.textNode.view.convert($0, to: self.view) }
rects.append(optionRects)
}
}
if isTranslating, !rects.isEmpty {
if self.shimmeringNodes.isEmpty {
let color: UIColor
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
if item.presentationData.theme.theme.overallDarkAppearance {
color = isIncoming ? item.presentationData.theme.theme.chat.message.incoming.primaryTextColor.withAlphaComponent(0.1) : item.presentationData.theme.theme.chat.message.outgoing.primaryTextColor.withAlphaComponent(0.1)
} else {
color = isIncoming ? item.presentationData.theme.theme.chat.message.incoming.accentTextColor.withAlphaComponent(0.1) : item.presentationData.theme.theme.chat.message.outgoing.secondaryTextColor.withAlphaComponent(0.1)
}
for rects in rects {
let shimmeringNode = ShimmeringLinkNode(color: color)
shimmeringNode.updateRects(rects)
shimmeringNode.frame = self.bounds
shimmeringNode.updateLayout(self.bounds.size)
shimmeringNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.shimmeringNodes.append(shimmeringNode)
self.insertSubnode(shimmeringNode, belowSubnode: self.textNode.textNode)
}
}
} else if !self.shimmeringNodes.isEmpty {
let shimmeringNodes = self.shimmeringNodes
self.shimmeringNodes = []
for shimmeringNode in shimmeringNodes {
shimmeringNode.alpha = 0.0
shimmeringNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak shimmeringNode] _ in
shimmeringNode?.removeFromSupernode()
})
}
}
}
private func updateSelection() {
guard let item = self.item, let poll = self.poll else {
return
}
var isBotChat: Bool = false
if let peer = item.message.peers[item.message.id.peerId] as? TelegramUser, peer.botInfo != nil {
isBotChat = true
}
let disableAllActions = false
let isPollActionInProgress = item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] != nil
var hasSelection = poll.kind.multipleAnswers
var hasSelectedOptions = false
for optionNode in self.optionNodes {
if let isChecked = optionNode.radioNode?.isChecked {
if isChecked {
hasSelectedOptions = true
}
}
}
var hasVoted = false
if let voters = poll.results.voters {
for voter in voters {
if voter.selected {
hasVoted = true
break
}
}
}
let isClosed = isPollEffectivelyClosed(message: item.message, poll: poll)
var canAlwaysViewResults = false
if !poll.hideResultsUntilClose, let peer = item.message.peers[item.message.id.peerId] {
if let group = peer as? TelegramGroup {
switch group.role {
case .creator, .admin:
canAlwaysViewResults = true
default:
break
}
} else if let channel = peer as? TelegramChannel, let _ = channel.adminRights {
canAlwaysViewResults = true
}
}
var hasAnyVotes = false
var hasResults = false
if isClosed {
hasResults = true
hasSelection = false
if let totalVoters = poll.results.totalVoters, totalVoters == 0 {
hasResults = false
}
} else {
if let totalVoters = poll.results.totalVoters, totalVoters != 0 {
hasAnyVotes = true
if let voters = poll.results.voters {
for voter in voters {
if voter.selected {
hasResults = voter.count != nil
break
}
}
}
}
}
if !disableAllActions && hasSelection && !hasResults && (!canAlwaysViewResults || hasSelectedOptions) && poll.pollId.namespace == Namespaces.Media.CloudPoll {
self.votersNode.isHidden = true
self.buttonViewResultsTextNode.isHidden = true
self.buttonSaveTextNode.isHidden = true
self.buttonSubmitInactiveTextNode.isHidden = hasSelectedOptions
self.buttonSubmitActiveTextNode.isHidden = !hasSelectedOptions
self.buttonNode.isHidden = !hasSelectedOptions
self.buttonNode.isUserInteractionEnabled = !isPollActionInProgress
} else {
let shouldShowViewResultsButton: Bool
switch poll.publicity {
case .public:
shouldShowViewResultsButton = hasResults || (canAlwaysViewResults && hasAnyVotes)
case .anonymous:
shouldShowViewResultsButton = canAlwaysViewResults && hasAnyVotes && !hasVoted
}
if shouldShowViewResultsButton, !disableAllActions {
self.votersNode.isHidden = true
if isBotChat {
self.buttonViewResultsTextNode.isHidden = true
self.buttonSaveTextNode.isHidden = true
self.buttonNode.isHidden = true
} else {
self.buttonViewResultsTextNode.isHidden = false
self.buttonSaveTextNode.isHidden = true
self.buttonNode.isHidden = false
}
if Namespaces.Message.allNonRegular.contains(item.message.id.namespace) {
self.buttonNode.isUserInteractionEnabled = false
} else {
self.buttonNode.isUserInteractionEnabled = !isPollActionInProgress
}
} else {
self.votersNode.isHidden = false
self.buttonViewResultsTextNode.isHidden = true
self.buttonSaveTextNode.isHidden = true
self.buttonNode.isHidden = true
self.buttonNode.isUserInteractionEnabled = !isPollActionInProgress
}
self.buttonSubmitInactiveTextNode.isHidden = true
self.buttonSubmitActiveTextNode.isHidden = true
}
let canDisplayNewOption = poll.openAnswers && !isClosed && !hasVoted && poll.pollId.namespace == Namespaces.Media.CloudPoll
let canSubmitNewOption = !self.currentNewOptionText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !(self.currentNewOptionMedia?.requiresUpload ?? false)
if canDisplayNewOption && canSubmitNewOption {
self.votersNode.isHidden = true
self.buttonSubmitInactiveTextNode.isHidden = true
self.buttonSubmitActiveTextNode.isHidden = true
self.buttonViewResultsTextNode.isHidden = true
self.buttonSaveTextNode.isHidden = false
self.buttonNode.isHidden = false
self.buttonNode.isUserInteractionEnabled = !isPollActionInProgress
} else {
self.buttonSaveTextNode.isHidden = true
}
self.avatarsNode.isUserInteractionEnabled = !self.buttonViewResultsTextNode.isHidden
}
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 {
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 url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String {
var concealed = true
if let (attributeText, fullText) = self.textNode.textNode.attributeSubstring(name: TelegramTextAttributes.URL, index: index) {
concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText)
}
return ChatMessageBubbleContentTapAction(content: .url(ChatMessageBubbleContentTapAction.Url(url: url, concealed: concealed)))
} 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 {
return ChatMessageBubbleContentTapAction(content: .textMention(peerName))
} 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 {
return ChatMessageBubbleContentTapAction(content: .none)
}
} else {
var isBotChat: Bool = false
if let item = self.item, let peer = item.message.peers[item.message.id.peerId] as? TelegramUser, peer.botInfo != nil {
isBotChat = true
}
for optionNode in self.optionNodes {
if optionNode.frame.contains(point), case .tap = gesture {
if self.newOptionIsFocused {
return ChatMessageBubbleContentTapAction(content: .none)
}
if let mediaFrame = optionNode.mediaFrame, mediaFrame.offsetBy(dx: optionNode.frame.minX, dy: optionNode.frame.minY).contains(point), let option = optionNode.option, let _ = option.media {
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
if let item = self?.item {
item.controllerInteraction.openPollMedia(item.message, .option(option))
}
}))
}
if optionNode.isUserInteractionEnabled {
return ChatMessageBubbleContentTapAction(content: .ignore)
} else if let item = self.item, !Namespaces.Message.allNonRegular.contains(item.message.id.namespace), let poll = self.poll, let option = optionNode.option, !isBotChat {
switch poll.publicity {
case .anonymous:
return ChatMessageBubbleContentTapAction(content: .none)
case .public:
var hasNonZeroVoters = false
if let voters = poll.results.voters {
for voter in voters {
if voter.count != 0 {
hasNonZeroVoters = true
break
}
}
}
if hasNonZeroVoters {
if !isEstimating {
return ChatMessageBubbleContentTapAction(content: .openPollResults(option.opaqueIdentifier))
}
return ChatMessageBubbleContentTapAction(content: .openMessage)
}
}
}
}
}
if let addOptionNode = self.addOptionNode, addOptionNode.frame.contains(point) {
return ChatMessageBubbleContentTapAction(content: .ignore)
}
if self.buttonNode.isUserInteractionEnabled, !self.buttonNode.isHidden, self.buttonNode.frame.contains(point) {
return ChatMessageBubbleContentTapAction(content: .ignore)
}
if self.avatarsNode.isUserInteractionEnabled, !self.avatarsNode.isHidden, self.avatarsNode.frame.contains(point) {
return ChatMessageBubbleContentTapAction(content: .ignore)
}
if self.solutionButtonNode.isUserInteractionEnabled, !self.solutionButtonNode.isHidden, !self.solutionButtonNode.alpha.isZero, self.solutionButtonNode.frame.contains(point) {
return ChatMessageBubbleContentTapAction(content: .ignore)
}
return ChatMessageBubbleContentTapAction(content: .none)
}
}
override public func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
guard let item = self.item, item.message.id == messageId, let optionNode = self.optionNodeForMedia(media), let transitionNode = optionNode.transitionNode(media: media) else {
return nil
}
return (transitionNode.0, transitionNode.1, transitionNode.2)
}
override public func updateHiddenMedia(_ media: [Media]?) -> Bool {
var updated = false
for optionNode in self.optionNodes {
let shouldHide = media?.contains(where: { hiddenMedia in
guard let optionMedia = optionNode.option?.media else {
return false
}
return optionMedia.isEqual(to: hiddenMedia)
}) ?? false
optionNode.setMediaHidden(shouldHide)
updated = updated || shouldHide
}
return updated
}
public func updatePollTooltipMessageState(animated: Bool) {
guard let item = self.item else {
return
}
let displaySolutionButton = item.message.id != item.controllerInteraction.currentPollMessageWithTooltip
if displaySolutionButton != !self.solutionButtonNode.iconNode.alpha.isZero {
let transition: ContainedViewLayoutTransition
if animated {
transition = .animated(duration: 0.25, curve: .easeInOut)
} else {
transition = .immediate
}
transition.updateAlpha(node: self.solutionButtonNode.iconNode, alpha: displaySolutionButton ? 1.0 : 0.0)
transition.updateSublayerTransformScale(node: self.solutionButtonNode, scale: displaySolutionButton ? 1.0 : 0.1)
}
}
public func newOptionInputTextView() -> UITextView? {
return self.addOptionNode?.inputTextView()
}
public func newOptionInputTextFieldView() -> TextFieldComponent.View? {
return self.addOptionNode?.inputTextFieldView()
}
override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? {
if !self.statusNode.isHidden {
return self.statusNode.reactionView(value: value)
}
return nil
}
override public func messageEffectTargetView() -> UIView? {
if !self.statusNode.isHidden {
return self.statusNode.messageEffectTargetView()
}
return nil
}
public func getOptionRect(id: Data?) -> CGRect? {
var rectsSet: [CGRect] = []
for node in self.optionNodes {
if node.option?.opaqueIdentifier == id {
rectsSet.append(node.frame.insetBy(dx: 3.0 - UIScreenPixel, dy: 2.0 - UIScreenPixel))
}
}
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
}
private var optionHighlightingNode: LinkHighlightingNode?
public func updateOptionHighlightState(id: Data?, color: UIColor, animated: Bool) {
var rectsSet: [CGRect] = []
for node in self.optionNodes {
if node.option?.opaqueIdentifier == id {
rectsSet.append(node.frame.insetBy(dx: 3.0 - UIScreenPixel, dy: 2.0 - UIScreenPixel))
}
}
if !rectsSet.isEmpty {
let rects = rectsSet
let optionHighlightingNode: LinkHighlightingNode
if let current = self.optionHighlightingNode {
optionHighlightingNode = current
} else {
optionHighlightingNode = LinkHighlightingNode(color: color)
optionHighlightingNode.innerRadius = 0.0
optionHighlightingNode.outerRadius = 0.0
self.optionHighlightingNode = optionHighlightingNode
self.insertSubnode(optionHighlightingNode, belowSubnode: self.buttonNode)
}
optionHighlightingNode.frame = self.bounds
optionHighlightingNode.updateRects(rects)
} else {
if let optionHighlightingNode = self.optionHighlightingNode {
self.optionHighlightingNode = nil
if animated {
optionHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak optionHighlightingNode] _ in
optionHighlightingNode?.removeFromSupernode()
})
} else {
optionHighlightingNode.removeFromSupernode()
}
}
}
}
public func animateOptionItemHighlightIn(id: Data, sourceFrame: CGRect, transition: ContainedViewLayoutTransition) -> CGRect? {
if let optionHighlightingNode = self.optionHighlightingNode {
var currentRect = CGRect()
for rect in optionHighlightingNode.rects {
if currentRect.isEmpty {
currentRect = rect
} else {
currentRect = currentRect.union(rect)
}
}
if !currentRect.isEmpty {
currentRect = currentRect.insetBy(dx: -optionHighlightingNode.inset, dy: -optionHighlightingNode.inset)
let innerRect = currentRect.offsetBy(dx: optionHighlightingNode.frame.minX, dy: optionHighlightingNode.frame.minY)
optionHighlightingNode.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, -optionHighlightingNode.bounds.width * 0.5 + currentRect.midX, -optionHighlightingNode.bounds.height * 0.5 + currentRect.midY, 0.0)
fromTransform = CATransform3DScale(fromTransform, fromScale.x, fromScale.y, 1.0)
fromTransform = CATransform3DTranslate(fromTransform, optionHighlightingNode.bounds.width * 0.5 - currentRect.midX, optionHighlightingNode.bounds.height * 0.5 - currentRect.midY, 0.0)
optionHighlightingNode.transform = fromTransform
transition.updateTransform(node: optionHighlightingNode, transform: CGAffineTransformIdentity)
return currentRect.offsetBy(dx: optionHighlightingNode.frame.minX, dy: optionHighlightingNode.frame.minY)
}
}
return nil
}
}
private struct ArbitraryRandomNumberGenerator : RandomNumberGenerator {
init(seed: Int) { srand48(seed) }
func next() -> UInt64 { return UInt64(drand48() * Double(UInt64.max)) }
}
private func stringForRemainingTime(_ duration: Int32) -> String {
let hours = duration / 3600
let minutes = duration / 60 % 60
let seconds = duration % 60
let durationString: String
if hours > 0 {
durationString = String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
durationString = String(format: "%02d:%02d", minutes, seconds)
}
return durationString
}
private class DeadlineTimerNode: ASDisplayNode {
private let textNode: ImmediateTextNode
private var timer: SwiftSignalKit.Timer?
private var params: (size: CGSize, color: UIColor, deadlineTimeout: Int32, resultsHidden: Bool)?
override init() {
self.textNode = ImmediateTextNode()
self.textNode.textAlignment = .center
super.init()
self.addSubnode(self.textNode)
}
deinit {
self.timer?.invalidate()
}
func update(size: CGSize, color: UIColor, deadlineTimeout: Int32, resultsHidden: Bool) {
self.params = (size, color, deadlineTimeout, resultsHidden)
//TODO:localize
let prefix: String = resultsHidden ? "results in" : "ends in"
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
let duration = max(0, deadlineTimeout - currentTime)
if duration > 0 && duration < 60 * 60 * 24 {
if self.timer == nil {
self.timer = SwiftSignalKit.Timer(timeout: 1.0, repeat: true, completion: { [weak self] in
guard let self, let params = self.params else {
return
}
self.update(size: params.size, color: params.color, deadlineTimeout: params.deadlineTimeout, resultsHidden: params.resultsHidden)
}, queue: Queue.mainQueue())
self.timer?.start()
}
} else {
self.timer?.invalidate()
self.timer = nil
}
let text: String
if duration == 0 {
text = ""
} else {
text = prefix + " " + stringForRemainingTime(duration)
}
self.textNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 11.0, traits: .monospacedNumbers), textColor: color)
let textSize = self.textNode.updateLayout(size)
self.textNode.frame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) / 2.0), y: 0.0), size: textSize)
}
}
private func resolvedOptionOrder(for item: ChatMessageBubbleContentItem) -> [(Int, TelegramMediaPollOption)] {
guard let poll = item.message.media.first(where: { $0 is TelegramMediaPoll }) as? TelegramMediaPoll else {
return []
}
let defaultOrderedOptions = Array(poll.options.enumerated()).map { ($0.offset, $0.element) }
guard poll.shuffleAnswers else {
return defaultOrderedOptions
}
let currentOpaqueIdentifiers = poll.options.map(\.opaqueIdentifier)
let messageSeed = UInt64(bitPattern: Int64(item.message.stableId))
let peerSeed = UInt64(bitPattern: item.context.account.peerId.toInt64())
let seed = Int(truncatingIfNeeded: (messageSeed &* 6364136223846793005) ^ peerSeed)
var resolvedOpaqueIdentifiers = currentOpaqueIdentifiers
var randomNumberGenerator = ArbitraryRandomNumberGenerator(seed: seed)
resolvedOpaqueIdentifiers.shuffle(using: &randomNumberGenerator)
let indicesByOpaqueIdentifier = Dictionary(uniqueKeysWithValues: poll.options.enumerated().map { ($0.element.opaqueIdentifier, $0.offset) })
let orderedOptions = resolvedOpaqueIdentifiers.compactMap { opaqueIdentifier -> (Int, TelegramMediaPollOption)? in
guard let index = indicesByOpaqueIdentifier[opaqueIdentifier] else {
return nil
}
return (index, poll.options[index])
}
if orderedOptions.count == poll.options.count {
return orderedOptions
} else {
return defaultOrderedOptions
}
}