mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
904 lines
48 KiB
Swift
904 lines
48 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Postbox
|
|
import Display
|
|
import TelegramCore
|
|
import SwiftSignalKit
|
|
import TelegramPresentationData
|
|
import AccountContext
|
|
import LocalizedPeerData
|
|
import PhotoResources
|
|
import TelegramStringFormatting
|
|
import TextFormat
|
|
import InvisibleInkDustNode
|
|
import TextNodeWithEntities
|
|
import AnimationCache
|
|
import MultiAnimationRenderer
|
|
import ChatMessageItemCommon
|
|
import MessageInlineBlockBackgroundView
|
|
|
|
public enum ChatMessageReplyInfoType {
|
|
case bubble(incoming: Bool)
|
|
case standalone
|
|
}
|
|
|
|
private let quoteIcon: UIImage = {
|
|
return UIImage(bundleImageName: "Chat/Message/ReplyQuoteIcon")!.precomposed().withRenderingMode(.alwaysTemplate)
|
|
}()
|
|
|
|
private let channelIcon: UIImage = {
|
|
let sourceImage = UIImage(bundleImageName: "Chat/Input/Accessory Panels/PanelTextChannelIcon")!
|
|
return generateImage(CGSize(width: sourceImage.size.width + 4.0, height: sourceImage.size.height + 4.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
UIGraphicsPushContext(context)
|
|
sourceImage.draw(at: CGPoint(x: 2.0, y: 1.0 + UIScreenPixel))
|
|
UIGraphicsPopContext()
|
|
})!.precomposed().withRenderingMode(.alwaysTemplate)
|
|
}()
|
|
|
|
private let groupIcon: UIImage = {
|
|
let sourceImage = UIImage(bundleImageName: "Chat/Input/Accessory Panels/PanelTextGroupIcon")!
|
|
return generateImage(CGSize(width: sourceImage.size.width + 3.0, height: sourceImage.size.height + 4.0), rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
UIGraphicsPushContext(context)
|
|
sourceImage.draw(at: CGPoint(x: 3.0, y: 1.0 - UIScreenPixel))
|
|
UIGraphicsPopContext()
|
|
})!.precomposed().withRenderingMode(.alwaysTemplate)
|
|
}()
|
|
|
|
public class ChatMessageReplyInfoNode: ASDisplayNode {
|
|
public final class TransitionReplyPanel {
|
|
public let titleNode: ASDisplayNode
|
|
public let textNode: ASDisplayNode
|
|
public let lineNode: ASDisplayNode
|
|
public let imageNode: ASDisplayNode
|
|
public let relativeSourceRect: CGRect
|
|
public let relativeTargetRect: CGRect
|
|
|
|
public init(titleNode: ASDisplayNode, textNode: ASDisplayNode, lineNode: ASDisplayNode, imageNode: ASDisplayNode, relativeSourceRect: CGRect, relativeTargetRect: CGRect) {
|
|
self.titleNode = titleNode
|
|
self.textNode = textNode
|
|
self.lineNode = lineNode
|
|
self.imageNode = imageNode
|
|
self.relativeSourceRect = relativeSourceRect
|
|
self.relativeTargetRect = relativeTargetRect
|
|
}
|
|
}
|
|
|
|
public class Arguments {
|
|
public let presentationData: ChatPresentationData
|
|
public let strings: PresentationStrings
|
|
public let context: AccountContext
|
|
public let type: ChatMessageReplyInfoType
|
|
public let message: Message?
|
|
public let replyForward: QuotedReplyMessageAttribute?
|
|
public let quote: EngineMessageReplyQuote?
|
|
public let story: StoryId?
|
|
public let parentMessage: Message
|
|
public let constrainedSize: CGSize
|
|
public let animationCache: AnimationCache?
|
|
public let animationRenderer: MultiAnimationRenderer?
|
|
public let associatedData: ChatMessageItemAssociatedData
|
|
|
|
public init(
|
|
presentationData: ChatPresentationData,
|
|
strings: PresentationStrings,
|
|
context: AccountContext,
|
|
type: ChatMessageReplyInfoType,
|
|
message: Message?,
|
|
replyForward: QuotedReplyMessageAttribute?,
|
|
quote: EngineMessageReplyQuote?,
|
|
story: StoryId?,
|
|
parentMessage: Message,
|
|
constrainedSize: CGSize,
|
|
animationCache: AnimationCache?,
|
|
animationRenderer: MultiAnimationRenderer?,
|
|
associatedData: ChatMessageItemAssociatedData
|
|
) {
|
|
self.presentationData = presentationData
|
|
self.strings = strings
|
|
self.context = context
|
|
self.type = type
|
|
self.message = message
|
|
self.replyForward = replyForward
|
|
self.quote = quote
|
|
self.story = story
|
|
self.parentMessage = parentMessage
|
|
self.constrainedSize = constrainedSize
|
|
self.animationCache = animationCache
|
|
self.animationRenderer = animationRenderer
|
|
self.associatedData = associatedData
|
|
}
|
|
}
|
|
|
|
public var visibility: Bool = false {
|
|
didSet {
|
|
if self.visibility != oldValue {
|
|
self.textNode?.visibilityRect = self.visibility ? CGRect.infinite : nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private let backgroundView: MessageInlineBlockBackgroundView
|
|
private var quoteIconView: UIImageView?
|
|
private let contentNode: ASDisplayNode
|
|
private var titleNode: TextNode?
|
|
private var textNode: TextNodeWithEntities?
|
|
private var dustNode: InvisibleInkDustNode?
|
|
private var imageNode: TransformImageNode?
|
|
private var previousMediaReference: AnyMediaReference?
|
|
private var expiredStoryIconView: UIImageView?
|
|
|
|
private var currentProgressDisposable: Disposable?
|
|
|
|
override public init() {
|
|
self.backgroundView = MessageInlineBlockBackgroundView(frame: CGRect())
|
|
|
|
self.contentNode = ASDisplayNode()
|
|
self.contentNode.isUserInteractionEnabled = false
|
|
self.contentNode.displaysAsynchronously = false
|
|
self.contentNode.contentMode = .left
|
|
self.contentNode.contentsScale = UIScreenScale
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.contentNode)
|
|
}
|
|
|
|
deinit {
|
|
self.currentProgressDisposable?.dispose()
|
|
}
|
|
|
|
public func makeProgress() -> Promise<Bool> {
|
|
let progress = Promise<Bool>()
|
|
self.currentProgressDisposable?.dispose()
|
|
self.currentProgressDisposable = (progress.get()
|
|
|> distinctUntilChanged
|
|
|> deliverOnMainQueue).start(next: { [weak self] hasProgress in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.backgroundView.displayProgress = hasProgress
|
|
})
|
|
return progress
|
|
}
|
|
|
|
public static func asyncLayout(_ maybeNode: ChatMessageReplyInfoNode?) -> (_ arguments: Arguments) -> (CGSize, (CGSize, Bool, ListViewItemUpdateAnimation) -> ChatMessageReplyInfoNode) {
|
|
let titleNodeLayout = TextNode.asyncLayout(maybeNode?.titleNode)
|
|
let textNodeLayout = TextNodeWithEntities.asyncLayout(maybeNode?.textNode)
|
|
let imageNodeLayout = TransformImageNode.asyncLayout(maybeNode?.imageNode)
|
|
let previousMediaReference = maybeNode?.previousMediaReference
|
|
|
|
return { arguments in
|
|
let fontSize = floor(arguments.presentationData.fontSize.baseDisplaySize * 14.0 / 17.0)
|
|
let titleFont = Font.semibold(fontSize)
|
|
let textFont = Font.regular(fontSize)
|
|
|
|
var titleString: NSAttributedString
|
|
var textString: NSAttributedString
|
|
let isMedia: Bool
|
|
let isText: Bool
|
|
var isExpiredStory: Bool = false
|
|
var isStory: Bool = false
|
|
|
|
let titleColor: UIColor
|
|
let mainColor: UIColor
|
|
let dustColor: UIColor
|
|
var secondaryColor: UIColor?
|
|
|
|
var authorNameColor: UIColor?
|
|
var dashSecondaryColor: UIColor?
|
|
|
|
let author = arguments.message?.effectiveAuthor
|
|
|
|
authorNameColor = author?.nameColor?.color
|
|
dashSecondaryColor = author?.nameColor?.dashColors.1
|
|
|
|
switch arguments.type {
|
|
case let .bubble(incoming):
|
|
titleColor = incoming ? (authorNameColor ?? arguments.presentationData.theme.theme.chat.message.incoming.accentTextColor) : arguments.presentationData.theme.theme.chat.message.outgoing.accentTextColor
|
|
if incoming {
|
|
if let authorNameColor {
|
|
mainColor = authorNameColor
|
|
secondaryColor = dashSecondaryColor
|
|
} else {
|
|
mainColor = arguments.presentationData.theme.theme.chat.message.incoming.accentTextColor
|
|
}
|
|
} else {
|
|
mainColor = arguments.presentationData.theme.theme.chat.message.outgoing.accentTextColor
|
|
if dashSecondaryColor != nil {
|
|
secondaryColor = .clear
|
|
}
|
|
}
|
|
dustColor = incoming ? arguments.presentationData.theme.theme.chat.message.incoming.secondaryTextColor : arguments.presentationData.theme.theme.chat.message.outgoing.secondaryTextColor
|
|
case .standalone:
|
|
let serviceColor = serviceMessageColorComponents(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper)
|
|
titleColor = serviceColor.primaryText
|
|
if dashSecondaryColor != nil {
|
|
secondaryColor = .clear
|
|
}
|
|
|
|
mainColor = serviceMessageColorComponents(chatTheme: arguments.presentationData.theme.theme.chat, wallpaper: arguments.presentationData.theme.wallpaper).primaryText
|
|
dustColor = titleColor
|
|
}
|
|
|
|
if let message = arguments.message {
|
|
let author = message.effectiveAuthor
|
|
let rawTitleString = author.flatMap(EnginePeer.init)?.displayTitle(strings: arguments.strings, displayOrder: arguments.presentationData.nameDisplayOrder) ?? arguments.strings.User_DeletedAccount
|
|
titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor)
|
|
|
|
if let forwardInfo = message.forwardInfo, forwardInfo.flags.contains(.isImported) || arguments.parentMessage.forwardInfo != nil {
|
|
if let author = forwardInfo.author {
|
|
let rawTitleString = EnginePeer(author).displayTitle(strings: arguments.strings, displayOrder: arguments.presentationData.nameDisplayOrder)
|
|
titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor)
|
|
} else if let authorSignature = forwardInfo.authorSignature {
|
|
let rawTitleString = authorSignature
|
|
titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor)
|
|
}
|
|
}
|
|
|
|
if message.id.peerId != arguments.parentMessage.id.peerId, let peer = message.peers[message.id.peerId], (peer is TelegramChannel || peer is TelegramGroup) {
|
|
final class RunDelegateData {
|
|
let ascent: CGFloat
|
|
let descent: CGFloat
|
|
let width: CGFloat
|
|
|
|
init(ascent: CGFloat, descent: CGFloat, width: CGFloat) {
|
|
self.ascent = ascent
|
|
self.descent = descent
|
|
self.width = width
|
|
}
|
|
}
|
|
let font = titleFont
|
|
let runDelegateData = RunDelegateData(
|
|
ascent: font.ascender,
|
|
descent: font.descender,
|
|
width: channelIcon.size.width
|
|
)
|
|
var callbacks = CTRunDelegateCallbacks(
|
|
version: kCTRunDelegateCurrentVersion,
|
|
dealloc: { dataRef in
|
|
Unmanaged<RunDelegateData>.fromOpaque(dataRef).release()
|
|
},
|
|
getAscent: { dataRef in
|
|
let data = Unmanaged<RunDelegateData>.fromOpaque(dataRef)
|
|
return data.takeUnretainedValue().ascent
|
|
},
|
|
getDescent: { dataRef in
|
|
let data = Unmanaged<RunDelegateData>.fromOpaque(dataRef)
|
|
return data.takeUnretainedValue().descent
|
|
},
|
|
getWidth: { dataRef in
|
|
let data = Unmanaged<RunDelegateData>.fromOpaque(dataRef)
|
|
return data.takeUnretainedValue().width
|
|
}
|
|
)
|
|
|
|
if let runDelegate = CTRunDelegateCreate(&callbacks, Unmanaged.passRetained(runDelegateData).toOpaque()) {
|
|
if let peer = peer as? TelegramChannel, case .broadcast = peer.info {
|
|
let rawTitleString = NSMutableAttributedString(attributedString: titleString)
|
|
rawTitleString.insert(NSAttributedString(string: ">", attributes: [
|
|
.attachment: channelIcon,
|
|
.foregroundColor: titleColor,
|
|
NSAttributedString.Key(rawValue: kCTRunDelegateAttributeName as String): runDelegate
|
|
]), at: 0)
|
|
titleString = rawTitleString
|
|
} else {
|
|
let rawTitleString = NSMutableAttributedString(attributedString: titleString)
|
|
rawTitleString.append(NSAttributedString(string: ">", attributes: [
|
|
.attachment: groupIcon,
|
|
.foregroundColor: titleColor,
|
|
NSAttributedString.Key(rawValue: kCTRunDelegateAttributeName as String): runDelegate
|
|
]))
|
|
rawTitleString.append(NSAttributedString(string: peer.debugDisplayTitle, font: titleFont, textColor: titleColor))
|
|
titleString = rawTitleString
|
|
}
|
|
}
|
|
}
|
|
|
|
let (textStringValue, isMediaValue, isTextValue) = descriptionStringForMessage(contentSettings: arguments.context.currentContentSettings.with { $0 }, message: EngineMessage(message), strings: arguments.strings, nameDisplayOrder: arguments.presentationData.nameDisplayOrder, dateTimeFormat: arguments.presentationData.dateTimeFormat, accountPeerId: arguments.context.account.peerId)
|
|
textString = textStringValue
|
|
isMedia = isMediaValue
|
|
isText = isTextValue
|
|
} else if let replyForward = arguments.replyForward {
|
|
if let replyAuthorId = replyForward.peerId, let replyAuthor = arguments.parentMessage.peers[replyAuthorId] {
|
|
let rawTitleString = EnginePeer(replyAuthor).displayTitle(strings: arguments.strings, displayOrder: arguments.presentationData.nameDisplayOrder)
|
|
titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor)
|
|
} else {
|
|
let rawTitleString = replyForward.authorName ?? " "
|
|
titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor)
|
|
}
|
|
|
|
//TODO:localize
|
|
textString = NSAttributedString(string: replyForward.quote?.text ?? "Message")
|
|
if let media = replyForward.quote?.media {
|
|
if let text = replyForward.quote?.text, !text.isEmpty {
|
|
} else {
|
|
if let contentKind = mediaContentKind(EngineMedia(media), message: nil, strings: arguments.strings, nameDisplayOrder: arguments.presentationData.nameDisplayOrder, dateTimeFormat: arguments.presentationData.dateTimeFormat, accountPeerId: arguments.context.account.peerId) {
|
|
let (string, _) = stringForMediaKind(contentKind, strings: arguments.strings)
|
|
textString = string
|
|
} else {
|
|
textString = NSAttributedString(string: "Message")
|
|
}
|
|
}
|
|
isMedia = true
|
|
} else {
|
|
isMedia = false
|
|
}
|
|
isText = replyForward.quote?.text != nil && replyForward.quote?.text != ""
|
|
} else if let story = arguments.story {
|
|
if let authorPeer = arguments.parentMessage.peers[story.peerId] {
|
|
let rawTitleString = EnginePeer(authorPeer).displayTitle(strings: arguments.strings, displayOrder: arguments.presentationData.nameDisplayOrder)
|
|
titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor)
|
|
} else {
|
|
let rawTitleString = arguments.strings.User_DeletedAccount
|
|
titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor)
|
|
}
|
|
isText = false
|
|
|
|
var hideStory = false
|
|
if let peer = arguments.parentMessage.peers[story.peerId] as? TelegramChannel, peer.username == nil, peer.usernames.isEmpty {
|
|
switch peer.participationStatus {
|
|
case .member:
|
|
break
|
|
case .kicked, .left:
|
|
hideStory = true
|
|
}
|
|
}
|
|
|
|
if let storyItem = arguments.parentMessage.associatedStories[story], storyItem.data.isEmpty {
|
|
isExpiredStory = true
|
|
textString = NSAttributedString(string: arguments.strings.Chat_ReplyExpiredStory)
|
|
isMedia = false
|
|
} else if hideStory {
|
|
isExpiredStory = true
|
|
textString = NSAttributedString(string: arguments.strings.Chat_ReplyStoryPrivateChannel)
|
|
isMedia = false
|
|
} else {
|
|
isStory = true
|
|
textString = NSAttributedString(string: arguments.strings.Chat_ReplyStory)
|
|
isMedia = true
|
|
}
|
|
} else {
|
|
titleString = NSAttributedString(string: " ", font: titleFont, textColor: titleColor)
|
|
textString = NSAttributedString(string: " ")
|
|
isMedia = true
|
|
isText = false
|
|
}
|
|
|
|
let isIncoming = arguments.parentMessage.effectivelyIncoming(arguments.context.account.peerId)
|
|
|
|
let placeholderColor: UIColor = isIncoming ? arguments.presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : arguments.presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor
|
|
|
|
let textColor: UIColor
|
|
|
|
switch arguments.type {
|
|
case let .bubble(incoming):
|
|
if isExpiredStory || isStory {
|
|
textColor = incoming ? arguments.presentationData.theme.theme.chat.message.incoming.accentTextColor : arguments.presentationData.theme.theme.chat.message.outgoing.accentTextColor
|
|
} else if isMedia {
|
|
textColor = incoming ? arguments.presentationData.theme.theme.chat.message.incoming.secondaryTextColor : arguments.presentationData.theme.theme.chat.message.outgoing.secondaryTextColor
|
|
} else {
|
|
textColor = incoming ? arguments.presentationData.theme.theme.chat.message.incoming.primaryTextColor : arguments.presentationData.theme.theme.chat.message.outgoing.primaryTextColor
|
|
}
|
|
case .standalone:
|
|
textColor = titleColor
|
|
}
|
|
|
|
let messageText: NSAttributedString
|
|
if isText, let message = arguments.message {
|
|
var text: String
|
|
var messageEntities: [MessageTextEntity]
|
|
|
|
if let quote = arguments.quote, !quote.text.isEmpty {
|
|
text = quote.text
|
|
messageEntities = quote.entities
|
|
} else {
|
|
text = foldLineBreaks(message.text)
|
|
messageEntities = message.textEntitiesAttribute?.entities ?? []
|
|
}
|
|
|
|
if let translateToLanguage = arguments.associatedData.translateToLanguage, !text.isEmpty {
|
|
for attribute in message.attributes {
|
|
if let attribute = attribute as? TranslationMessageAttribute, !attribute.text.isEmpty, attribute.toLang == translateToLanguage {
|
|
text = attribute.text
|
|
messageEntities = attribute.entities
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
let entities = messageEntities.filter { entity in
|
|
if case .Strikethrough = entity.type {
|
|
return true
|
|
} else if case .Spoiler = entity.type {
|
|
return true
|
|
} else if case .CustomEmoji = entity.type {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
if entities.count > 0 {
|
|
messageText = stringWithAppliedEntities(text, entities: entities, baseColor: textColor, linkColor: textColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: textFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: message)
|
|
} else {
|
|
messageText = NSAttributedString(string: text, font: textFont, textColor: textColor)
|
|
}
|
|
} else if isText, let replyForward = arguments.replyForward, let quote = replyForward.quote {
|
|
let entities = quote.entities.filter { entity in
|
|
if case .Strikethrough = entity.type {
|
|
return true
|
|
} else if case .Spoiler = entity.type {
|
|
return true
|
|
} else if case .CustomEmoji = entity.type {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
if entities.count > 0 {
|
|
messageText = stringWithAppliedEntities(quote.text, entities: entities, baseColor: textColor, linkColor: textColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: textFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: nil)
|
|
} else {
|
|
messageText = NSAttributedString(string: quote.text, font: textFont, textColor: textColor)
|
|
}
|
|
} else {
|
|
messageText = NSAttributedString(string: textString.string, font: textFont, textColor: textColor)
|
|
}
|
|
|
|
var leftInset: CGFloat = 11.0
|
|
let spacing: CGFloat = 2.0
|
|
|
|
var updatedMediaReference: AnyMediaReference?
|
|
var imageDimensions: CGSize?
|
|
var hasRoundImage = false
|
|
if let message = arguments.message, !message.containsSecretMedia {
|
|
for media in message.media {
|
|
if let image = media as? TelegramMediaImage {
|
|
updatedMediaReference = .message(message: MessageReference(message), media: image)
|
|
if let representation = largestRepresentationForPhoto(image) {
|
|
imageDimensions = representation.dimensions.cgSize
|
|
}
|
|
break
|
|
} else if let file = media as? TelegramMediaFile, !file.isVideoSticker {
|
|
updatedMediaReference = .message(message: MessageReference(message), media: file)
|
|
|
|
if let dimensions = file.dimensions {
|
|
imageDimensions = dimensions.cgSize
|
|
} else if let representation = largestImageRepresentation(file.previewRepresentations), !file.isSticker {
|
|
imageDimensions = representation.dimensions.cgSize
|
|
}
|
|
if file.isInstantVideo {
|
|
hasRoundImage = true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
} else if let story = arguments.story, let storyPeer = arguments.parentMessage.peers[story.peerId], let storyItem = arguments.parentMessage.associatedStories[story] {
|
|
if let itemValue = storyItem.get(Stories.StoredItem.self), case let .item(item) = itemValue, let peerReference = PeerReference(storyPeer) {
|
|
if let image = item.media as? TelegramMediaImage {
|
|
updatedMediaReference = .story(peer: peerReference, id: story.id, media: image)
|
|
if let representation = largestRepresentationForPhoto(image) {
|
|
imageDimensions = representation.dimensions.cgSize
|
|
}
|
|
} else if let file = item.media as? TelegramMediaFile, file.isVideo && !file.isVideoSticker {
|
|
updatedMediaReference = .story(peer: peerReference, id: story.id, media: file)
|
|
|
|
if let dimensions = file.dimensions {
|
|
imageDimensions = dimensions.cgSize
|
|
} else if let representation = largestImageRepresentation(file.previewRepresentations), !file.isSticker {
|
|
imageDimensions = representation.dimensions.cgSize
|
|
}
|
|
}
|
|
}
|
|
} else if let replyForward = arguments.replyForward, let media = replyForward.quote?.media {
|
|
if let image = media as? TelegramMediaImage {
|
|
updatedMediaReference = .message(message: MessageReference(arguments.parentMessage), media: image)
|
|
if let representation = largestRepresentationForPhoto(image) {
|
|
imageDimensions = representation.dimensions.cgSize
|
|
}
|
|
} else if let file = media as? TelegramMediaFile, file.isVideo && !file.isVideoSticker {
|
|
updatedMediaReference = .message(message: MessageReference(arguments.parentMessage), media: file)
|
|
|
|
if let dimensions = file.dimensions {
|
|
imageDimensions = dimensions.cgSize
|
|
} else if let representation = largestImageRepresentation(file.previewRepresentations), !file.isSticker {
|
|
imageDimensions = representation.dimensions.cgSize
|
|
}
|
|
if file.isInstantVideo {
|
|
hasRoundImage = true
|
|
}
|
|
}
|
|
}
|
|
|
|
var imageTextInset: CGFloat = 0.0
|
|
if let _ = imageDimensions {
|
|
imageTextInset += floor(arguments.presentationData.fontSize.baseDisplaySize * 32.0 / 17.0)
|
|
}
|
|
|
|
let maximumTextWidth = max(0.0, arguments.constrainedSize.width - 8.0 - imageTextInset)
|
|
|
|
var contrainedTextSize = CGSize(width: maximumTextWidth, height: arguments.constrainedSize.height)
|
|
|
|
let textInsets = UIEdgeInsets(top: 3.0, left: 0.0, bottom: 3.0, right: 0.0)
|
|
|
|
var additionalTitleWidth: CGFloat = 0.0
|
|
var maxTitleNumberOfLines = 1
|
|
var maxTextNumberOfLines = 1
|
|
var adjustedConstrainedTextSize = contrainedTextSize
|
|
var textCutout: TextNodeCutout?
|
|
var textCutoutWidth: CGFloat = 0.0
|
|
if arguments.quote != nil || arguments.replyForward?.quote != nil {
|
|
additionalTitleWidth += 10.0
|
|
maxTitleNumberOfLines = 2
|
|
maxTextNumberOfLines = 5
|
|
if imageTextInset != 0.0 {
|
|
adjustedConstrainedTextSize.width += imageTextInset
|
|
textCutout = TextNodeCutout(topLeft: CGSize(width: imageTextInset + 6.0, height: 10.0))
|
|
textCutoutWidth = imageTextInset + 6.0
|
|
}
|
|
}
|
|
|
|
let (titleLayout, titleApply) = titleNodeLayout(TextNodeLayoutArguments(attributedString: titleString, backgroundColor: nil, maximumNumberOfLines: maxTitleNumberOfLines, truncationType: .end, constrainedSize: CGSize(width: contrainedTextSize.width - additionalTitleWidth, height: contrainedTextSize.height), alignment: .natural, cutout: nil, insets: textInsets))
|
|
if isExpiredStory || isStory {
|
|
contrainedTextSize.width -= 26.0
|
|
}
|
|
|
|
if titleLayout.numberOfLines > 1 {
|
|
textCutout = nil
|
|
}
|
|
|
|
let (textLayout, textApply) = textNodeLayout(TextNodeLayoutArguments(attributedString: messageText, backgroundColor: nil, maximumNumberOfLines: maxTextNumberOfLines, truncationType: .end, constrainedSize: adjustedConstrainedTextSize, alignment: .natural, lineSpacing: 0.07, cutout: textCutout, insets: textInsets))
|
|
|
|
let imageSide: CGFloat
|
|
let titleLineHeight: CGFloat = titleLayout.linesRects().first?.height ?? 12.0
|
|
imageSide = titleLineHeight * 2.0
|
|
|
|
var applyImage: (() -> TransformImageNode)?
|
|
if let imageDimensions = imageDimensions {
|
|
let boundingSize = CGSize(width: imageSide, height: imageSide)
|
|
leftInset += imageSide + 6.0
|
|
var radius: CGFloat = 4.0
|
|
var imageSize = imageDimensions.aspectFilled(boundingSize)
|
|
if hasRoundImage {
|
|
radius = boundingSize.width / 2.0
|
|
imageSize.width += 2.0
|
|
imageSize.height += 2.0
|
|
}
|
|
if !isExpiredStory {
|
|
applyImage = imageNodeLayout(TransformImageArguments(corners: ImageCorners(radius: radius), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets(), emptyColor: placeholderColor))
|
|
}
|
|
}
|
|
|
|
var mediaUpdated = false
|
|
if let updatedMediaReference = updatedMediaReference, let previousMediaReference = previousMediaReference {
|
|
mediaUpdated = !updatedMediaReference.media.isEqual(to: previousMediaReference.media)
|
|
} else if (updatedMediaReference != nil) != (previousMediaReference != nil) {
|
|
mediaUpdated = true
|
|
}
|
|
|
|
let hasSpoiler: Bool
|
|
if let message = arguments.message {
|
|
hasSpoiler = message.attributes.contains(where: { $0 is MediaSpoilerMessageAttribute })
|
|
} else {
|
|
hasSpoiler = false
|
|
}
|
|
|
|
var updateImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
|
|
|
|
var mediaUserLocation: MediaResourceUserLocation = .other
|
|
if let message = arguments.message {
|
|
mediaUserLocation = .peer(message.id.peerId)
|
|
}
|
|
|
|
if let updatedMediaReference = updatedMediaReference, mediaUpdated && imageDimensions != nil {
|
|
if let imageReference = updatedMediaReference.concrete(TelegramMediaImage.self) {
|
|
updateImageSignal = chatMessagePhotoThumbnail(account: arguments.context.account, userLocation: mediaUserLocation, photoReference: imageReference, blurred: hasSpoiler)
|
|
} else if let fileReference = updatedMediaReference.concrete(TelegramMediaFile.self) {
|
|
if fileReference.media.isVideo {
|
|
updateImageSignal = chatMessageVideoThumbnail(account: arguments.context.account, userLocation: mediaUserLocation, fileReference: fileReference, blurred: hasSpoiler)
|
|
} else if let iconImageRepresentation = smallestImageRepresentation(fileReference.media.previewRepresentations) {
|
|
updateImageSignal = chatWebpageSnippetFile(account: arguments.context.account, userLocation: mediaUserLocation, mediaReference: fileReference.abstract, representation: iconImageRepresentation)
|
|
}
|
|
}
|
|
}
|
|
|
|
var size = CGSize(width: max(titleLayout.size.width + additionalTitleWidth - textInsets.left - textInsets.right, textLayout.size.width - textInsets.left - textInsets.right - textCutoutWidth) + leftInset + 6.0, height: titleLayout.size.height + textLayout.size.height - 2 * (textInsets.top + textInsets.bottom) + 2 * spacing)
|
|
if isExpiredStory || isStory {
|
|
size.width += 16.0
|
|
}
|
|
|
|
return (size, { realSize, attemptSynchronous, animation in
|
|
let node: ChatMessageReplyInfoNode
|
|
if let maybeNode = maybeNode {
|
|
node = maybeNode
|
|
} else {
|
|
node = ChatMessageReplyInfoNode()
|
|
}
|
|
|
|
node.previousMediaReference = updatedMediaReference
|
|
|
|
node.titleNode?.displaysAsynchronously = !arguments.presentationData.isPreview
|
|
node.textNode?.textNode.displaysAsynchronously = !arguments.presentationData.isPreview
|
|
|
|
let titleNode = titleApply()
|
|
var textArguments: TextNodeWithEntities.Arguments?
|
|
if let cache = arguments.animationCache, let renderer = arguments.animationRenderer {
|
|
textArguments = TextNodeWithEntities.Arguments(context: arguments.context, cache: cache, renderer: renderer, placeholderColor: placeholderColor, attemptSynchronous: attemptSynchronous)
|
|
}
|
|
let textNode = textApply(textArguments)
|
|
textNode.visibilityRect = node.visibility ? CGRect.infinite : nil
|
|
|
|
if node.titleNode == nil {
|
|
titleNode.isUserInteractionEnabled = false
|
|
node.titleNode = titleNode
|
|
node.contentNode.addSubnode(titleNode)
|
|
}
|
|
|
|
if node.textNode == nil {
|
|
textNode.textNode.isUserInteractionEnabled = false
|
|
node.textNode = textNode
|
|
node.contentNode.addSubnode(textNode.textNode)
|
|
}
|
|
|
|
if let applyImage = applyImage {
|
|
let imageNode = applyImage()
|
|
if node.imageNode == nil {
|
|
imageNode.isLayerBacked = false
|
|
node.addSubnode(imageNode)
|
|
node.imageNode = imageNode
|
|
}
|
|
imageNode.frame = CGRect(origin: CGPoint(x: 9.0, y: 3.0 + UIScreenPixel), size: CGSize(width: imageSide, height: imageSide))
|
|
|
|
if let updateImageSignal = updateImageSignal {
|
|
imageNode.setSignal(updateImageSignal)
|
|
}
|
|
} else if let imageNode = node.imageNode {
|
|
imageNode.removeFromSupernode()
|
|
node.imageNode = nil
|
|
}
|
|
if let message = arguments.message {
|
|
node.imageNode?.captureProtected = message.isCopyProtected()
|
|
}
|
|
|
|
titleNode.frame = CGRect(origin: CGPoint(x: leftInset - textInsets.left - 2.0, y: spacing - textInsets.top + 1.0), size: titleLayout.size)
|
|
|
|
let textFrame = CGRect(origin: CGPoint(x: leftInset - textInsets.left - 2.0 - textCutoutWidth, y: titleNode.frame.maxY - textInsets.bottom + spacing - textInsets.top - 2.0), size: textLayout.size)
|
|
textNode.textNode.frame = textFrame.offsetBy(dx: (isExpiredStory || isStory) ? 18.0 : 0.0, dy: 0.0)
|
|
|
|
if isExpiredStory || isStory {
|
|
let expiredStoryIconView: UIImageView
|
|
if let current = node.expiredStoryIconView {
|
|
expiredStoryIconView = current
|
|
} else {
|
|
expiredStoryIconView = UIImageView()
|
|
node.expiredStoryIconView = expiredStoryIconView
|
|
node.view.addSubview(expiredStoryIconView)
|
|
}
|
|
|
|
let imageType: ChatExpiredStoryIndicatorType
|
|
switch arguments.type {
|
|
case .standalone:
|
|
imageType = .free
|
|
case let .bubble(incoming):
|
|
imageType = incoming ? .incoming : .outgoing
|
|
}
|
|
|
|
if isExpiredStory {
|
|
expiredStoryIconView.image = PresentationResourcesChat.chatExpiredStoryIndicatorIcon(arguments.presentationData.theme.theme, type: imageType)
|
|
} else {
|
|
expiredStoryIconView.image = PresentationResourcesChat.chatReplyStoryIndicatorIcon(arguments.presentationData.theme.theme, type: imageType)
|
|
}
|
|
if let image = expiredStoryIconView.image {
|
|
let imageSize: CGSize
|
|
if isExpiredStory {
|
|
imageSize = CGSize(width: floor(image.size.width * 1.22), height: floor(image.size.height * 1.22))
|
|
expiredStoryIconView.frame = CGRect(origin: CGPoint(x: textFrame.minX - 2.0, y: textFrame.minY + 2.0), size: imageSize)
|
|
} else {
|
|
imageSize = image.size
|
|
expiredStoryIconView.frame = CGRect(origin: CGPoint(x: textFrame.minX - 1.0, y: textFrame.minY + 3.0 + UIScreenPixel), size: imageSize)
|
|
}
|
|
}
|
|
} else if let expiredStoryIconView = node.expiredStoryIconView {
|
|
expiredStoryIconView.removeFromSuperview()
|
|
}
|
|
|
|
if !textLayout.spoilers.isEmpty {
|
|
let dustNode: InvisibleInkDustNode
|
|
if let current = node.dustNode {
|
|
dustNode = current
|
|
} else {
|
|
dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: arguments.context.sharedContext.energyUsageSettings.fullTranslucency)
|
|
dustNode.isUserInteractionEnabled = false
|
|
node.dustNode = dustNode
|
|
node.contentNode.insertSubnode(dustNode, aboveSubnode: textNode.textNode)
|
|
}
|
|
dustNode.frame = textFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 3.0)
|
|
dustNode.update(size: dustNode.frame.size, color: dustColor, textColor: dustColor, rects: textLayout.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: textLayout.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) })
|
|
} else if let dustNode = node.dustNode {
|
|
dustNode.removeFromSupernode()
|
|
node.dustNode = nil
|
|
}
|
|
|
|
if node.backgroundView.superview == nil {
|
|
node.contentNode.view.insertSubview(node.backgroundView, at: 0)
|
|
}
|
|
|
|
let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: realSize.width, height: realSize.height))
|
|
|
|
node.backgroundView.frame = backgroundFrame
|
|
|
|
var pattern: MessageInlineBlockBackgroundView.Pattern?
|
|
if let backgroundEmojiId = author?.backgroundEmojiId {
|
|
pattern = MessageInlineBlockBackgroundView.Pattern(
|
|
context: arguments.context,
|
|
fileId: backgroundEmojiId,
|
|
file: arguments.parentMessage.associatedMedia[MediaId(
|
|
namespace: Namespaces.Media.CloudFile,
|
|
id: backgroundEmojiId
|
|
)] as? TelegramMediaFile
|
|
)
|
|
}
|
|
node.backgroundView.update(
|
|
size: backgroundFrame.size,
|
|
primaryColor: mainColor,
|
|
secondaryColor: secondaryColor,
|
|
pattern: pattern,
|
|
animation: animation
|
|
)
|
|
|
|
if arguments.quote != nil || arguments.replyForward?.quote != nil {
|
|
let quoteIconView: UIImageView
|
|
if let current = node.quoteIconView {
|
|
quoteIconView = current
|
|
} else {
|
|
quoteIconView = UIImageView(image: quoteIcon)
|
|
node.quoteIconView = quoteIconView
|
|
node.contentNode.view.addSubview(quoteIconView)
|
|
}
|
|
quoteIconView.tintColor = mainColor
|
|
quoteIconView.frame = CGRect(origin: CGPoint(x: backgroundFrame.maxX - 4.0 - quoteIcon.size.width, y: backgroundFrame.minY + 4.0), size: quoteIcon.size)
|
|
} else {
|
|
if let quoteIconView = node.quoteIconView {
|
|
node.quoteIconView = nil
|
|
quoteIconView.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
node.contentNode.frame = CGRect(origin: CGPoint(), size: size)
|
|
|
|
return node
|
|
})
|
|
}
|
|
}
|
|
|
|
public func updateTouchesAtPoint(_ point: CGPoint?) {
|
|
var isHighlighted = false
|
|
if point != nil {
|
|
isHighlighted = true
|
|
}
|
|
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: isHighlighted ? 0.3 : 0.2, curve: .easeInOut)
|
|
let scale: CGFloat = isHighlighted ? ((self.bounds.width - 5.0) / self.bounds.width) : 1.0
|
|
transition.updateSublayerTransformScale(node: self, scale: scale, beginWithCurrentState: true)
|
|
}
|
|
|
|
public func animateFromInputPanel(sourceReplyPanel: TransitionReplyPanel, unclippedTransitionNode: ASDisplayNode? = nil, localRect: CGRect, transition: CombinedTransition) -> CGPoint {
|
|
let sourceParentNode = ASDisplayNode()
|
|
|
|
let sourceParentOffset: CGPoint
|
|
|
|
if let unclippedTransitionNode = unclippedTransitionNode {
|
|
unclippedTransitionNode.addSubnode(sourceParentNode)
|
|
sourceParentNode.frame = sourceReplyPanel.relativeSourceRect
|
|
sourceParentOffset = self.view.convert(CGPoint(), to: sourceParentNode.view)
|
|
sourceParentNode.clipsToBounds = true
|
|
|
|
let panelOffset = sourceReplyPanel.relativeTargetRect.minY - sourceReplyPanel.relativeSourceRect.minY
|
|
|
|
sourceParentNode.frame = sourceParentNode.frame.offsetBy(dx: 0.0, dy: panelOffset)
|
|
sourceParentNode.bounds = sourceParentNode.bounds.offsetBy(dx: 0.0, dy: panelOffset)
|
|
transition.vertical.animatePositionAdditive(layer: sourceParentNode.layer, offset: CGPoint(x: 0.0, y: -panelOffset))
|
|
transition.vertical.animateOffsetAdditive(layer: sourceParentNode.layer, offset: -panelOffset)
|
|
} else {
|
|
self.addSubnode(sourceParentNode)
|
|
sourceParentOffset = CGPoint()
|
|
}
|
|
|
|
sourceParentNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak sourceParentNode] _ in
|
|
sourceParentNode?.removeFromSupernode()
|
|
})
|
|
|
|
if let titleNode = self.titleNode {
|
|
let offset = CGPoint(
|
|
x: localRect.minX + sourceReplyPanel.titleNode.frame.minX - titleNode.frame.minX,
|
|
y: localRect.minY + sourceReplyPanel.titleNode.frame.midY - titleNode.frame.midY
|
|
)
|
|
|
|
transition.horizontal.animatePositionAdditive(node: titleNode, offset: CGPoint(x: offset.x, y: 0.0))
|
|
transition.vertical.animatePositionAdditive(node: titleNode, offset: CGPoint(x: 0.0, y: offset.y))
|
|
|
|
sourceParentNode.addSubnode(sourceReplyPanel.titleNode)
|
|
|
|
titleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
|
|
|
|
sourceReplyPanel.titleNode.frame = sourceReplyPanel.titleNode.frame
|
|
.offsetBy(dx: sourceParentOffset.x, dy: sourceParentOffset.y)
|
|
.offsetBy(dx: localRect.minX - offset.x, dy: localRect.minY - offset.y)
|
|
transition.horizontal.animatePositionAdditive(node: sourceReplyPanel.titleNode, offset: CGPoint(x: offset.x, y: 0.0), removeOnCompletion: false)
|
|
transition.vertical.animatePositionAdditive(node: sourceReplyPanel.titleNode, offset: CGPoint(x: 0.0, y: offset.y), removeOnCompletion: false)
|
|
}
|
|
|
|
if let textNode = self.textNode {
|
|
let offset = CGPoint(
|
|
x: localRect.minX + sourceReplyPanel.textNode.frame.minX - textNode.textNode.frame.minX,
|
|
y: localRect.minY + sourceReplyPanel.textNode.frame.midY - textNode.textNode.frame.midY
|
|
)
|
|
|
|
transition.horizontal.animatePositionAdditive(node: textNode.textNode, offset: CGPoint(x: offset.x, y: 0.0))
|
|
transition.vertical.animatePositionAdditive(node: textNode.textNode, offset: CGPoint(x: 0.0, y: offset.y))
|
|
|
|
sourceParentNode.addSubnode(sourceReplyPanel.textNode)
|
|
|
|
textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
|
|
|
|
sourceReplyPanel.textNode.frame = sourceReplyPanel.textNode.frame
|
|
.offsetBy(dx: sourceParentOffset.x, dy: sourceParentOffset.y)
|
|
.offsetBy(dx: localRect.minX - offset.x, dy: localRect.minY - offset.y)
|
|
transition.horizontal.animatePositionAdditive(node: sourceReplyPanel.textNode, offset: CGPoint(x: offset.x, y: 0.0), removeOnCompletion: false)
|
|
transition.vertical.animatePositionAdditive(node: sourceReplyPanel.textNode, offset: CGPoint(x: 0.0, y: offset.y), removeOnCompletion: false)
|
|
}
|
|
|
|
if let imageNode = self.imageNode {
|
|
let offset = CGPoint(
|
|
x: localRect.minX + sourceReplyPanel.imageNode.frame.midX - imageNode.frame.midX,
|
|
y: localRect.minY + sourceReplyPanel.imageNode.frame.midY - imageNode.frame.midY
|
|
)
|
|
|
|
transition.horizontal.animatePositionAdditive(node: imageNode, offset: CGPoint(x: offset.x, y: 0.0))
|
|
transition.vertical.animatePositionAdditive(node: imageNode, offset: CGPoint(x: 0.0, y: offset.y))
|
|
|
|
sourceParentNode.addSubnode(sourceReplyPanel.imageNode)
|
|
|
|
imageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
|
|
|
|
sourceReplyPanel.imageNode.frame = sourceReplyPanel.imageNode.frame
|
|
.offsetBy(dx: sourceParentOffset.x, dy: sourceParentOffset.y)
|
|
.offsetBy(dx: localRect.minX - offset.x, dy: localRect.minY - offset.y)
|
|
transition.horizontal.animatePositionAdditive(node: sourceReplyPanel.imageNode, offset: CGPoint(x: offset.x, y: 0.0), removeOnCompletion: false)
|
|
transition.vertical.animatePositionAdditive(node: sourceReplyPanel.imageNode, offset: CGPoint(x: 0.0, y: offset.y), removeOnCompletion: false)
|
|
}
|
|
|
|
do {
|
|
let backgroundView = self.backgroundView
|
|
|
|
let offset = CGPoint(
|
|
x: localRect.minX + sourceReplyPanel.lineNode.frame.minX - backgroundView.frame.minX,
|
|
y: localRect.minY + sourceReplyPanel.lineNode.frame.minY - backgroundView.frame.minY
|
|
)
|
|
|
|
transition.horizontal.animatePositionAdditive(layer: backgroundView.layer, offset: CGPoint(x: offset.x, y: 0.0))
|
|
transition.vertical.animatePositionAdditive(layer: backgroundView.layer, offset: CGPoint(x: 0.0, y: offset.y))
|
|
|
|
sourceParentNode.addSubnode(sourceReplyPanel.lineNode)
|
|
|
|
backgroundView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
|
|
|
|
sourceReplyPanel.lineNode.frame = sourceReplyPanel.lineNode.frame
|
|
.offsetBy(dx: sourceParentOffset.x, dy: sourceParentOffset.y)
|
|
.offsetBy(dx: localRect.minX - offset.x, dy: localRect.minY - offset.y)
|
|
transition.horizontal.animatePositionAdditive(node: sourceReplyPanel.lineNode, offset: CGPoint(x: offset.x, y: 0.0), removeOnCompletion: false)
|
|
transition.vertical.animatePositionAdditive(node: sourceReplyPanel.lineNode, offset: CGPoint(x: 0.0, y: offset.y), removeOnCompletion: false)
|
|
|
|
return offset
|
|
}
|
|
}
|
|
|
|
public func mediaTransitionView() -> UIView? {
|
|
if let imageNode = self.imageNode {
|
|
return imageNode.view
|
|
}
|
|
return nil
|
|
}
|
|
}
|