2023-12-28 16:55:12 +04:00

462 lines
24 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import AvatarNode
import AccountContext
import LocalizedPeerData
import StickerResources
import PhotoResources
import TelegramStringFormatting
import TextFormat
import InvisibleInkDustNode
import TextNodeWithEntities
import AnimationCache
import MultiAnimationRenderer
public final class ChatMessageNotificationItem: NotificationItem {
public let context: AccountContext
public let strings: PresentationStrings
public let dateTimeFormat: PresentationDateTimeFormat
public let nameDisplayOrder: PresentationPersonNameOrder
public let messages: [Message]
public let threadData: MessageHistoryThreadData?
public let tapAction: () -> Bool
public let expandAction: (@escaping () -> (ASDisplayNode?, () -> Void)) -> Void
public var groupingKey: AnyHashable? {
return messages.first?.id.peerId
}
public init(context: AccountContext, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, nameDisplayOrder: PresentationPersonNameOrder, messages: [Message], threadData: MessageHistoryThreadData?, tapAction: @escaping () -> Bool, expandAction: @escaping (() -> (ASDisplayNode?, () -> Void)) -> Void) {
self.context = context
self.strings = strings
self.dateTimeFormat = dateTimeFormat
self.nameDisplayOrder = nameDisplayOrder
self.messages = messages
self.threadData = threadData
self.tapAction = tapAction
self.expandAction = expandAction
}
public func node(compact: Bool) -> NotificationItemNode {
let node = ChatMessageNotificationItemNode()
node.setupItem(self, compact: compact)
return node
}
public func tapped(_ take: @escaping () -> (ASDisplayNode?, () -> Void)) {
if self.tapAction() {
self.expandAction(take)
}
}
public func canBeExpanded() -> Bool {
return true
}
public func expand(_ take: @escaping () -> (ASDisplayNode?, () -> Void)) {
self.expandAction(take)
}
}
private let compactAvatarFont = avatarPlaceholderFont(size: 20.0)
private let avatarFont = avatarPlaceholderFont(size: 24.0)
final class ChatMessageNotificationItemNode: NotificationItemNode {
private var item: ChatMessageNotificationItem?
private let avatarNode: AvatarNode
private let titleIconNode: ASImageNode
private let titleNode: TextNode
private let textNode: TextNodeWithEntities
private var dustNode: InvisibleInkDustNode?
private let imageNode: TransformImageNode
private var titleAttributedText: NSAttributedString?
private var textAttributedText: NSAttributedString?
private var compact: Bool?
private var validLayout: CGFloat?
override init() {
self.avatarNode = AvatarNode(font: avatarFont)
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleIconNode = ASImageNode()
self.titleIconNode.isLayerBacked = true
self.titleIconNode.displayWithoutProcessing = true
self.titleIconNode.displaysAsynchronously = false
self.textNode = TextNodeWithEntities()
self.textNode.textNode.isUserInteractionEnabled = false
self.imageNode = TransformImageNode()
super.init()
self.addSubnode(self.avatarNode)
self.addSubnode(self.titleIconNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.textNode.textNode)
self.addSubnode(self.imageNode)
}
func setupItem(_ item: ChatMessageNotificationItem, compact: Bool) {
self.item = item
self.compact = compact
if compact {
self.avatarNode.font = compactAvatarFont
}
let presentationData = item.context.sharedContext.currentPresentationData.with { $0 }
var isReminder = false
var isScheduled = false
var title: String?
if let firstMessage = item.messages.first, let peer = messageMainPeer(EngineMessage(firstMessage)) {
if case let .channel(channel) = peer, case .broadcast = channel.info {
title = peer.displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder)
} else if let author = firstMessage.author {
if firstMessage.id.peerId.isReplies, let _ = firstMessage.sourceReference, let effectiveAuthor = firstMessage.forwardInfo?.author {
title = EnginePeer(effectiveAuthor).displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder) + "@" + peer.displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder)
} else if author.id != peer.id {
let authorString: String
if author.id == item.context.account.peerId {
authorString = presentationData.strings.DialogList_You
} else {
authorString = EnginePeer(author).displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder)
}
if let threadData = item.threadData {
title = "\(authorString)\(threadData.info.title)"
} else {
title = authorString + "@" + peer.displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder)
}
} else {
title = peer.displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder)
for attribute in firstMessage.attributes {
if let attribute = attribute as? SourceReferenceMessageAttribute {
if let sourcePeer = firstMessage.peers[attribute.messageId.peerId] {
title = EnginePeer(sourcePeer).displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder) + "@" + peer.displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder)
}
break
}
}
if let titleValue = title, let threadData = item.threadData {
title = "\(threadData.info.title) (\(titleValue))"
}
}
} else {
title = peer.displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder)
}
if let _ = title, firstMessage.flags.contains(.WasScheduled) {
if let author = firstMessage.author, author.id == peer.id, author.id == item.context.account.peerId {
isReminder = true
} else {
isScheduled = true
}
}
var avatarPeer = peer
if firstMessage.id.peerId.isReplies, let author = firstMessage.forwardInfo?.author {
avatarPeer = EnginePeer(author)
}
self.avatarNode.setPeer(context: item.context, theme: presentationData.theme, peer: avatarPeer, overrideImage: peer.id == item.context.account.peerId ? .savedMessagesIcon : nil, emptyColor: presentationData.theme.list.mediaPlaceholderColor)
}
var updatedMedia: Media?
var imageDimensions: CGSize?
var isRound = false
var messageText: String
var messageEntities: [MessageTextEntity]?
if item.messages.first?.id.peerId.namespace == Namespaces.Peer.SecretChat {
messageText = item.strings.PUSH_ENCRYPTED_MESSAGE("").string
} else if item.messages.count == 1 {
let message = item.messages[0]
for media in message.media {
if let image = media as? TelegramMediaImage {
updatedMedia = image
if let representation = largestRepresentationForPhoto(image) {
imageDimensions = representation.dimensions.cgSize
}
break
} else if let file = media as? TelegramMediaFile {
updatedMedia = file
if let representation = largestImageRepresentation(file.previewRepresentations) {
imageDimensions = representation.dimensions.cgSize
}
isRound = file.isInstantVideo
break
}
}
if message.containsSecretMedia {
imageDimensions = nil
}
let (textString, _, isText) = descriptionStringForMessage(contentSettings: item.context.currentContentSettings.with { $0 }, message: EngineMessage(message), strings: item.strings, nameDisplayOrder: item.nameDisplayOrder, dateTimeFormat: item.dateTimeFormat, accountPeerId: item.context.account.peerId)
if isText {
messageText = message.text
messageEntities = message.textEntitiesAttribute?.entities.filter { entity in
if case .Spoiler = entity.type {
return true
} else if case .CustomEmoji = entity.type {
return true
} else {
return false
}
}
if messageEntities?.count == 0 {
messageEntities = nil
messageText = textString.string
}
} else {
messageText = textString.string
}
} else if item.messages.count > 1, let peer = item.messages[0].peers[item.messages[0].id.peerId] {
var displayAuthor = true
if let channel = peer as? TelegramChannel {
switch channel.info {
case .group:
displayAuthor = true
case .broadcast:
displayAuthor = false
}
} else if let _ = peer as? TelegramUser {
displayAuthor = false
}
if item.messages[0].forwardInfo != nil && item.messages[0].sourceReference == nil {
if let author = item.messages[0].author, displayAuthor {
if !isReminder {
title = EnginePeer(peer).displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder)
}
messageText = presentationData.strings.PUSH_CHAT_MESSAGE_FWDS_TEXT(Int32(item.messages.count)).replacingOccurrences(of: "{author}", with: EnginePeer(author).compactDisplayTitle)
} else {
title = EnginePeer(peer).displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder)
messageText = presentationData.strings.PUSH_MESSAGE_FWDS_TEXT(Int32(item.messages.count))
}
} else if item.messages[0].groupingKey != nil {
var kind = messageContentKind(contentSettings: item.context.currentContentSettings.with { $0 }, message: EngineMessage(item.messages[0]), strings: presentationData.strings, nameDisplayOrder: presentationData.nameDisplayOrder, dateTimeFormat: presentationData.dateTimeFormat, accountPeerId: item.context.account.peerId).key
for i in 1 ..< item.messages.count {
let nextKind = messageContentKind(contentSettings: item.context.currentContentSettings.with { $0 }, message: EngineMessage(item.messages[i]), strings: presentationData.strings, nameDisplayOrder: presentationData.nameDisplayOrder, dateTimeFormat: presentationData.dateTimeFormat, accountPeerId: item.context.account.peerId)
if kind != nextKind.key {
kind = .text
break
}
}
var isChannel = false
var isGroup = false
if let peer = peer as? TelegramChannel {
if case .broadcast = peer.info {
isChannel = true
} else {
isGroup = true
}
} else if item.messages[0].id.peerId.namespace == Namespaces.Peer.CloudGroup {
isGroup = true
}
title = EnginePeer(peer).displayTitle(strings: item.strings, displayOrder: item.nameDisplayOrder)
if isChannel {
switch kind {
case .image:
messageText = presentationData.strings.PUSH_CHANNEL_MESSAGE_PHOTOS_TEXT(Int32(item.messages.count))
case .video:
messageText = presentationData.strings.PUSH_CHANNEL_MESSAGE_VIDEOS_TEXT(Int32(item.messages.count))
case .file:
messageText = presentationData.strings.PUSH_CHANNEL_MESSAGE_DOCS_TEXT(Int32(item.messages.count))
default:
messageText = presentationData.strings.PUSH_CHANNEL_MESSAGES_TEXT(Int32(item.messages.count))
}
} else if isGroup, var author = item.messages[0].author {
if let sourceReference = item.messages[0].sourceReference, let sourcePeer = item.messages[0].peers[sourceReference.messageId.peerId] {
author = sourcePeer
}
switch kind {
case .image:
messageText = presentationData.strings.PUSH_CHAT_MESSAGE_PHOTOS_TEXT(Int32(item.messages.count)).replacingOccurrences(of: "{author}", with: EnginePeer(author).compactDisplayTitle)
case .video:
messageText = presentationData.strings.PUSH_CHAT_MESSAGE_VIDEOS_TEXT(Int32(item.messages.count)).replacingOccurrences(of: "{author}", with: EnginePeer(author).compactDisplayTitle)
case .file:
messageText = presentationData.strings.PUSH_CHAT_MESSAGE_DOCS_TEXT(Int32(item.messages.count)).replacingOccurrences(of: "{author}", with: EnginePeer(author).compactDisplayTitle)
default:
messageText = presentationData.strings.PUSH_CHAT_MESSAGES_TEXT(Int32(item.messages.count)).replacingOccurrences(of: "{author}", with: EnginePeer(author).compactDisplayTitle)
}
} else {
switch kind {
case .image:
messageText = presentationData.strings.PUSH_MESSAGE_PHOTOS_TEXT(Int32(item.messages.count))
case .video:
messageText = presentationData.strings.PUSH_MESSAGE_VIDEOS_TEXT(Int32(item.messages.count))
case .file:
messageText = presentationData.strings.PUSH_MESSAGE_FILES_TEXT(Int32(item.messages.count))
default:
messageText = presentationData.strings.PUSH_MESSAGES_TEXT(Int32(item.messages.count))
}
}
} else {
messageText = ""
}
} else {
messageText = ""
}
if isReminder {
title = presentationData.strings.ScheduledMessages_ReminderNotification
} else if isScheduled, let currentTitle = title {
title = "📅 \(currentTitle)"
}
if let message = item.messages.first, let attribute = message.attributes.first(where: { $0 is NotificationInfoMessageAttribute }) as? NotificationInfoMessageAttribute, attribute.flags.contains(.muted), let currentTitle = title {
var isAction = false
for media in message.media {
if media is TelegramMediaAction {
isAction = true
break
}
}
if !isAction {
title = "\(currentTitle) 🔕"
}
}
let textFont = compact ? Font.regular(15.0) : Font.regular(16.0)
let textColor = presentationData.theme.inAppNotification.primaryTextColor
var attributedMessageText: NSAttributedString
if let messageEntities = messageEntities {
attributedMessageText = stringWithAppliedEntities(messageText, entities: messageEntities, baseColor: textColor, linkColor: textColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: textFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: item.messages.first)
} else {
attributedMessageText = NSAttributedString(string: messageText.replacingOccurrences(of: "\n\n", with: " "), font: textFont, textColor: textColor)
}
self.titleAttributedText = NSAttributedString(string: title ?? "", font: compact ? Font.semibold(15.0) : Font.semibold(16.0), textColor: presentationData.theme.inAppNotification.primaryTextColor)
let imageNodeLayout = self.imageNode.asyncLayout()
var applyImage: (() -> Void)?
if let imageDimensions = imageDimensions {
let boundingSize = CGSize(width: 55.0, height: 55.0)
var radius: CGFloat = 6.0
if isRound {
radius = floor(boundingSize.width / 2.0)
}
applyImage = imageNodeLayout(TransformImageArguments(corners: ImageCorners(radius: radius), imageSize: imageDimensions.aspectFilled(boundingSize), boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets()))
}
var updateImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
if let firstMessage = item.messages.first, let updatedMedia = updatedMedia, imageDimensions != nil {
if let image = updatedMedia as? TelegramMediaImage {
updateImageSignal = mediaGridMessagePhoto(account: item.context.account, userLocation: .peer(firstMessage.id.peerId), photoReference: .message(message: MessageReference(firstMessage), media: image))
} else if let file = updatedMedia as? TelegramMediaFile {
if file.isSticker {
updateImageSignal = chatMessageSticker(account: item.context.account, userLocation: .peer(firstMessage.id.peerId), file: file, small: true, fetched: true)
} else if file.isVideo {
updateImageSignal = mediaGridMessageVideo(postbox: item.context.account.postbox, userLocation: .peer(firstMessage.id.peerId), videoReference: .message(message: MessageReference(firstMessage), media: file), autoFetchFullSizeThumbnail: true)
}
}
}
if let applyImage = applyImage {
applyImage()
self.imageNode.isHidden = false
} else {
self.imageNode.isHidden = true
}
if let updateImageSignal = updateImageSignal {
self.imageNode.setSignal(updateImageSignal)
}
self.textAttributedText = attributedMessageText
if let width = self.validLayout {
let _ = self.updateLayout(width: width, transition: .immediate)
}
}
override public func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
self.validLayout = width
let compact = self.compact ?? false
let panelHeight: CGFloat = compact ? 64.0 : 74.0
let imageSize: CGSize = compact ? CGSize(width: 44.0, height: 44.0) : CGSize(width: 54.0, height: 54.0)
let imageSpacing: CGFloat = compact ? 19.0 : 23.0
let leftInset: CGFloat = imageSize.width + imageSpacing
var rightInset: CGFloat = 8.0
if !self.imageNode.isHidden {
rightInset += imageSize.width + 8.0
}
transition.updateFrame(node: self.avatarNode, frame: CGRect(origin: CGPoint(x: 10.0, y: (panelHeight - imageSize.height) / 2.0), size: imageSize))
var titleInset: CGFloat = 0.0
if let image = self.titleIconNode.image {
titleInset += image.size.width + 4.0
}
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: self.titleAttributedText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: width - leftInset - rightInset - titleInset, height: CGFloat.greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets()))
let _ = titleApply()
let makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode)
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: self.textAttributedText, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .end, constrainedSize: CGSize(width: width - leftInset - rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets()))
let _ = titleApply()
if let item = self.item {
let theme = item.context.sharedContext.currentPresentationData.with({ $0 }).theme
let _ = textApply(TextNodeWithEntities.Arguments(
context: item.context,
cache: item.context.animationCache,
renderer: item.context.animationRenderer,
placeholderColor: theme.list.mediaPlaceholderColor,
attemptSynchronous: false
))
} else {
let _ = textApply(nil)
}
self.textNode.visibilityRect = CGRect.infinite
let textSpacing: CGFloat = 1.0
let titleFrame = CGRect(origin: CGPoint(x: leftInset + titleInset, y: 1.0 + floor((panelHeight - textLayout.size.height - titleLayout.size.height - textSpacing) / 2.0)), size: titleLayout.size)
transition.updateFrame(node: self.titleNode, frame: titleFrame)
if let image = self.titleIconNode.image {
transition.updateFrame(node: self.titleIconNode, frame: CGRect(origin: CGPoint(x: leftInset + 1.0, y: titleFrame.minY + 3.0), size: image.size))
}
let textFrame = CGRect(origin: CGPoint(x: leftInset, y: titleFrame.maxY + textSpacing), size: textLayout.size)
transition.updateFrame(node: self.textNode.textNode, frame: textFrame)
transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(x: width - 10.0 - imageSize.width, y: (panelHeight - imageSize.height) / 2.0), size: imageSize))
if !textLayout.spoilers.isEmpty, let item = self.item {
let presentationData = item.context.sharedContext.currentPresentationData.with({ $0 })
let dustNode: InvisibleInkDustNode
if let current = self.dustNode {
dustNode = current
} else {
dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: item.context.sharedContext.energyUsageSettings.fullTranslucency)
dustNode.isUserInteractionEnabled = false
self.dustNode = dustNode
self.insertSubnode(dustNode, aboveSubnode: self.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: presentationData.theme.inAppNotification.primaryTextColor, textColor: presentationData.theme.inAppNotification.primaryTextColor, rects: textLayout.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: textLayout.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) })
} else if let dustNode = self.dustNode {
dustNode.removeFromSupernode()
self.dustNode = nil
}
return panelHeight
}
}