GLEGram 12.5 — Initial public release

Based on Swiftgram 12.5 (Telegram iOS 12.5).
All GLEGram features ported and organized in GLEGram/ folder.

Features: Ghost Mode, Saved Deleted Messages, Content Protection Bypass,
Font Replacement, Fake Profile, Chat Export, Plugin System, and more.

See CHANGELOG_12.5.md for full details.
This commit is contained in:
Leeksov
2026-04-06 09:48:12 +03:00
commit 4647310322
39685 changed files with 11052678 additions and 0 deletions
@@ -0,0 +1,272 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ListSectionHeaderNode
import AppBundle
class ChatListArchiveInfoItem: ListViewItem {
let theme: PresentationTheme
let strings: PresentationStrings
let selectable: Bool = false
init(theme: PresentationTheme, strings: PresentationStrings) {
self.theme = theme
self.strings = strings
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ChatListArchiveInfoItemNode()
let (nodeLayout, apply) = node.asyncLayout()(self, params, false)
node.insets = nodeLayout.insets
node.contentSize = nodeLayout.contentSize
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in
apply()
})
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
assert(node() is ChatListArchiveInfoItemNode)
if let nodeValue = node() as? ChatListArchiveInfoItemNode {
let layout = nodeValue.asyncLayout()
async {
let (nodeLayout, apply) = layout(self, params, nextItem == nil)
Queue.mainQueue().async {
completion(nodeLayout, { _ in
apply()
})
}
}
}
}
}
}
private let separatorHeight = 1.0 / UIScreen.main.scale
private let titleFont = Font.regular(20.0)
private let textFont = Font.regular(15.0)
private final class InfoPageNode: ASDisplayNode {
private let iconNodeBase: ASImageNode
private let iconNodeContent: ASImageNode
private let titleNode: TextNode
private let textNode: TextNode
private var theme: PresentationTheme?
override init() {
self.iconNodeBase = ASImageNode()
self.iconNodeBase.displaysAsynchronously = false
self.iconNodeBase.displayWithoutProcessing = true
self.iconNodeContent = ASImageNode()
self.iconNodeContent.displaysAsynchronously = false
self.iconNodeContent.displayWithoutProcessing = true
self.titleNode = TextNode()
self.titleNode.displaysAsynchronously = false
self.textNode = TextNode()
self.textNode.displaysAsynchronously = false
super.init()
self.addSubnode(self.iconNodeBase)
self.addSubnode(self.iconNodeContent)
self.addSubnode(self.titleNode)
self.addSubnode(self.textNode)
}
func asyncLayout() -> (_ theme: PresentationTheme, _ strings: PresentationStrings, _ width: CGFloat, _ index: Int) -> (CGFloat, () -> Void) {
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeTextLayout = TextNode.asyncLayout(self.textNode)
return { [weak self] theme, strings, width, index in
let title: String
let text: String
if index == 0 {
title = strings.ArchivedChats_IntroTitle1
text = strings.ArchivedChats_IntroText1
} else if index == 1 {
title = strings.ArchivedChats_IntroTitle2
text = strings.ArchivedChats_IntroText2
} else {
title = strings.ArchivedChats_IntroTitle3
text = strings.ArchivedChats_IntroText3
}
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: title, font: titleFont, textColor: theme.list.itemPrimaryTextColor, paragraphAlignment: nil), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: min(300.0, width - 16.0), height: .greatestFiniteMagnitude), alignment: .center, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets()))
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: text, font: textFont, textColor: theme.list.itemPrimaryTextColor, paragraphAlignment: nil), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: min(300.0, width - 16.0), height: .greatestFiniteMagnitude), alignment: .center, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets()))
let topContentInset: CGFloat = 98.0
let bottomContentInset: CGFloat = 64.0 + 28.0
let textSpacing: CGFloat = 6.0
let contentHeight = topContentInset + titleLayout.size.height + textSpacing + textLayout.size.height + bottomContentInset
return (contentHeight, {
guard let strongSelf = self else {
return
}
if strongSelf.theme !== theme {
strongSelf.theme = theme
if index == 0 {
strongSelf.iconNodeBase.image = generateTintedImage(image: UIImage(bundleImageName: "Chat List/Archive/Intro1Base"), color: theme.list.itemPrimaryTextColor)
} else {
strongSelf.iconNodeBase.image = generateTintedImage(image: UIImage(bundleImageName: "Chat List/Archive/Intro2Base"), color: theme.list.itemPrimaryTextColor)
}
if index == 0 {
strongSelf.iconNodeContent.image = generateTintedImage(image: UIImage(bundleImageName: "Chat List/Archive/Intro1Content"), color: theme.chatList.unreadBadgeActiveBackgroundColor)
} else if index == 1 {
strongSelf.iconNodeContent.image = generateTintedImage(image: UIImage(bundleImageName: "Chat List/Archive/Intro2Content"), color: theme.chatList.unreadBadgeInactiveBackgroundColor)
} else {
strongSelf.iconNodeContent.image = generateTintedImage(image: UIImage(bundleImageName: "Chat List/Archive/Intro3Content"), color: theme.chatList.unreadBadgeActiveBackgroundColor)
}
}
let topIconInset: CGFloat = 110.0
if let baseImage = strongSelf.iconNodeBase.image, let contentImage = strongSelf.iconNodeContent.image {
strongSelf.iconNodeBase.frame = CGRect(origin: CGPoint(x: floor((width - baseImage.size.width) / 2.0), y: floor((topIconInset - baseImage.size.height) / 2.0)), size: baseImage.size)
strongSelf.iconNodeContent.frame = CGRect(origin: CGPoint(x: floor((width - contentImage.size.width) / 2.0), y: floor((topIconInset - contentImage.size.height) / 2.0)), size: contentImage.size)
}
let _ = titleApply()
let _ = textApply()
let titleFrame = CGRect(origin: CGPoint(x: floor((width - titleLayout.size.width) / 2.0), y: topContentInset), size: titleLayout.size)
strongSelf.titleNode.frame = titleFrame
strongSelf.textNode.frame = CGRect(origin: CGPoint(x: floor((width - textLayout.size.width) / 2.0), y: titleFrame.maxY + textSpacing), size: textLayout.size)
})
}
}
}
class ChatListArchiveInfoItemNode: ListViewItemNode, ASScrollViewDelegate {
private var item: ChatListArchiveInfoItem?
private let scrollNode: ASScrollNode
private let pageControlNode: PageControlNode
private var headerNode: ListSectionHeaderNode?
private let infoPageNodes: [InfoPageNode]
required init() {
self.scrollNode = ASScrollNode()
self.pageControlNode = PageControlNode(dotSize: 7.0, dotSpacing: 9.0, dotColor: .blue, inactiveDotColor: .gray)
self.infoPageNodes = (0 ..< 3).map({ _ in InfoPageNode() })
self.pageControlNode.pagesCount = self.infoPageNodes.count
super.init(layerBacked: false)
self.addSubnode(self.scrollNode)
self.infoPageNodes.forEach(self.scrollNode.addSubnode)
self.addSubnode(self.pageControlNode)
}
override func didLoad() {
super.didLoad()
self.view.disablesInteractiveTransitionGestureRecognizer = true
self.scrollNode.view.showsHorizontalScrollIndicator = false
self.scrollNode.view.isPagingEnabled = true
self.scrollNode.view.delegate = self.wrappedScrollViewDelegate
self.pageControlNode.setPage(0.0)
}
override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
let layout = self.asyncLayout()
let (_, apply) = layout(item as! ChatListArchiveInfoItem, params, nextItem == nil)
apply()
}
func asyncLayout() -> (_ item: ChatListArchiveInfoItem, _ params: ListViewItemLayoutParams, _ isLast: Bool) -> (ListViewItemNodeLayout, () -> Void) {
let previousItem = self.item
let makeInfoPageLayouts = self.infoPageNodes.map({ $0.asyncLayout() })
return { item, params, last in
let baseWidth = params.width - params.leftInset - params.rightInset
let bottomInset: CGFloat = 22.0 + 28.0
let themeUpdated = previousItem?.theme !== item.theme
var infoPageLayoutsAndApply: [(CGFloat, () -> Void)] = []
var maxHeight: CGFloat = 0.0
for i in 0 ..< makeInfoPageLayouts.count {
let sizeAndApply = makeInfoPageLayouts[i](item.theme, item.strings, baseWidth, i)
maxHeight = max(maxHeight, sizeAndApply.0)
infoPageLayoutsAndApply.append(sizeAndApply)
}
let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: maxHeight), insets: UIEdgeInsets())
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.item = item
if themeUpdated {
strongSelf.pageControlNode.dotColor = item.theme.chatList.unreadBadgeActiveBackgroundColor
strongSelf.pageControlNode.inactiveDotColor = item.theme.list.pageIndicatorInactiveColor
}
let resetOffset = !strongSelf.scrollNode.frame.width.isEqual(to: baseWidth)
strongSelf.scrollNode.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: baseWidth, height: layout.contentSize.height))
strongSelf.scrollNode.view.contentSize = CGSize(width: baseWidth * CGFloat(infoPageLayoutsAndApply.count), height: layout.contentSize.height)
if resetOffset {
strongSelf.scrollNode.view.contentOffset = CGPoint(x: 0.0, y: 0.0)
}
for i in 0 ..< infoPageLayoutsAndApply.count {
strongSelf.infoPageNodes[i].frame = CGRect(origin: CGPoint(x: baseWidth * CGFloat(i), y: 0.0), size: CGSize(width: baseWidth, height: layout.contentSize.height))
infoPageLayoutsAndApply[i].1()
}
let pageControlSize = strongSelf.pageControlNode.measure(CGSize(width: baseWidth, height: 100.0))
strongSelf.pageControlNode.frame = CGRect(origin: CGPoint(x: floor((params.width - pageControlSize.width) / 2.0), y: layout.contentSize.height - bottomInset - pageControlSize.height), size: pageControlSize)
if strongSelf.headerNode == nil {
let headerNode = ListSectionHeaderNode(theme: item.theme)
headerNode.title = item.strings.ChatList_ArchivedChatsTitle.uppercased()
strongSelf.addSubnode(headerNode)
strongSelf.headerNode = headerNode
}
if let headerNode = strongSelf.headerNode {
if themeUpdated {
headerNode.updateTheme(theme: item.theme)
}
headerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: layout.contentSize.height - 28.0), size: CGSize(width: params.width, height: 28.0))
headerNode.updateLayout(size: CGSize(width: params.width, height: 28.0), leftInset: params.leftInset, rightInset: params.rightInset)
}
strongSelf.contentSize = layout.contentSize
strongSelf.insets = layout.insets
}
})
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let bounds = scrollView.bounds
if !bounds.width.isZero {
self.pageControlNode.setPage(scrollView.contentOffset.x / bounds.width)
}
}
}
@@ -0,0 +1,213 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
enum ChatListBadgeContent: Equatable {
case none
case blank
case text(NSAttributedString)
case mention
var text: String? {
if case let .text(text) = self {
return text.string
}
return nil
}
var isEmpty: Bool {
if case .none = self {
return true
}
return false
}
}
private func measureString(_ string: String) -> String {
let wideChar = "8"
if string.count < 2 {
return wideChar
} else {
return string[string.startIndex ..< string.index(string.endIndex, offsetBy: -1)] + wideChar
}
}
final class ChatListBadgeNode: ASDisplayNode {
let backgroundNode: ASImageNode
let textNode: TextNode
private let measureTextNode: TextNode
private var text: String?
private var content: ChatListBadgeContent?
private var isHiddenInternal = false
var disableBounce: Bool = false
override init() {
self.backgroundNode = ASImageNode()
self.backgroundNode.isLayerBacked = true
self.backgroundNode.displaysAsynchronously = false
self.backgroundNode.displayWithoutProcessing = true
self.textNode = TextNode()
self.textNode.isUserInteractionEnabled = false
self.textNode.displaysAsynchronously = false
self.measureTextNode = TextNode()
super.init()
self.addSubnode(self.backgroundNode)
self.addSubnode(self.textNode)
}
func asyncLayout() -> (CGSize, CGFloat, UIFont, UIImage?, ChatListBadgeContent) -> (CGSize, (Bool, Bool) -> Void) {
let textLayout = TextNode.asyncLayout(self.textNode)
let measureTextLayout = TextNode.asyncLayout(self.measureTextNode)
let currentContent = self.content
return { [weak self] boundingSize, imageWidth, badgeFont, backgroundImage, content in
var badgeWidth: CGFloat = 0.0
var textLayoutAndApply: (TextNodeLayout, () -> TextNode)?
switch content {
case let .text(text):
textLayoutAndApply = textLayout(TextNodeLayoutArguments(attributedString: text, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: boundingSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (measureLayout, _) = measureTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: measureString(text.string), font: badgeFont, textColor: .black), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: boundingSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
badgeWidth = max(imageWidth, measureLayout.size.width + imageWidth / 2.0)
case .mention, .blank:
badgeWidth = imageWidth
case .none:
badgeWidth = 0.0
}
return (CGSize(width: badgeWidth, height: imageWidth), { animated, bounce in
if let strongSelf = self {
strongSelf.content = content
if let backgroundImage = backgroundImage {
strongSelf.backgroundNode.image = backgroundImage
}
if content == currentContent {
return
}
let badgeWidth = max(imageWidth, badgeWidth)
let previousBadgeWidth = !strongSelf.backgroundNode.bounds.width.isZero ? strongSelf.backgroundNode.bounds.width : badgeWidth
var animateTextNode = false
if animated {
strongSelf.isHidden = false
let currentIsEmpty = currentContent?.isEmpty ?? true
let nextIsEmpty = content.isEmpty
if !nextIsEmpty {
if case .text = content {
strongSelf.textNode.alpha = 1.0
} else {
strongSelf.textNode.alpha = 0.0
}
}
if currentIsEmpty && !nextIsEmpty {
strongSelf.isHiddenInternal = false
if !strongSelf.disableBounce {
if bounce {
strongSelf.layer.animateScale(from: 0.0001, to: 1.2, duration: 0.2, removeOnCompletion: false, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.layer.animateScale(from: 1.15, to: 1.0, duration: 0.12, removeOnCompletion: false)
}
})
} else {
strongSelf.layer.animateScale(from: 0.0001, to: 1.0, duration: 0.2, removeOnCompletion: false)
}
}
} else if !currentIsEmpty && !nextIsEmpty && currentContent?.text != content.text {
var animateScale = bounce
strongSelf.isHiddenInternal = false
if let currentText = currentContent?.text, let currentValue = Int(currentText), let text = content.text, let value = Int(text) {
if value < currentValue {
animateScale = false
}
}
if animateScale && !strongSelf.disableBounce {
strongSelf.layer.animateScale(from: 1.0, to: 1.2, duration: 0.12, removeOnCompletion: false, completion: { [weak self] finished in
if let strongSelf = self {
strongSelf.layer.animateScale(from: 1.2, to: 1.0, duration: 0.12, removeOnCompletion: false)
}
})
}
var animateSnapshot = true
if let currentContent = currentContent, case .blank = currentContent {
animateSnapshot = false
}
if animateSnapshot, let snapshotView = strongSelf.textNode.view.snapshotContentTree() {
snapshotView.frame = strongSelf.textNode.frame
strongSelf.textNode.view.superview?.insertSubview(snapshotView, aboveSubview: strongSelf.textNode.view)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
snapshotView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: (badgeWidth - previousBadgeWidth) / 2.0, y: -8.0), duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true)
}
animateTextNode = true
} else if !currentIsEmpty && nextIsEmpty && !strongSelf.isHiddenInternal {
strongSelf.isHiddenInternal = true
if !strongSelf.disableBounce {
strongSelf.layer.animateScale(from: 1.0, to: 0.0001, duration: 0.12, removeOnCompletion: false, completion: { [weak self] finished in
if let strongSelf = self {
strongSelf.isHidden = true
strongSelf.layer.removeAnimation(forKey: "transform.scale")
}
})
} else {
strongSelf.isHidden = true
}
}
} else {
if case .none = content {
strongSelf.isHidden = true
strongSelf.isHiddenInternal = true
} else {
strongSelf.isHidden = false
strongSelf.isHiddenInternal = false
}
if case .text = content {
strongSelf.textNode.alpha = 1.0
} else {
strongSelf.textNode.alpha = 0.0
}
}
let _ = textLayoutAndApply?.1()
let backgroundFrame = CGRect(x: 0.0, y: 0.0, width: badgeWidth, height: strongSelf.backgroundNode.image?.size.height ?? 0.0)
if let (textLayout, _) = textLayoutAndApply {
let badgeTextFrame = CGRect(origin: CGPoint(x: backgroundFrame.midX - textLayout.size.width / 2.0, y: backgroundFrame.minY + UIScreenPixel + floorToScreenPixels((backgroundFrame.height - textLayout.size.height) / 2.0)), size: textLayout.size)
strongSelf.textNode.position = badgeTextFrame.center
strongSelf.textNode.bounds = CGRect(origin: CGPoint(), size: badgeTextFrame.size)
if animateTextNode {
strongSelf.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
strongSelf.textNode.layer.animatePosition(from: CGPoint(x: (previousBadgeWidth - badgeWidth) / 2.0, y: 8.0), to: CGPoint(), duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true)
}
}
strongSelf.backgroundNode.position = backgroundFrame.center
strongSelf.backgroundNode.bounds = CGRect(origin: CGPoint(), size: backgroundFrame.size)
if animated && badgeWidth != previousBadgeWidth {
let previousBackgroundFrame = CGRect(x: 0.0, y: 0.0, width: previousBadgeWidth, height: backgroundFrame.height)
strongSelf.backgroundNode.layer.animateFrame(from: previousBackgroundFrame, to: backgroundFrame, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue)
}
}
})
}
}
}
@@ -0,0 +1,81 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ListSectionHeaderNode
import AppBundle
class ChatListEmptyHeaderItem: ListViewItem {
let selectable: Bool = false
init() {
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ChatListEmptyHeaderItemNode()
let (nodeLayout, apply) = node.asyncLayout()(self, params, false)
node.insets = nodeLayout.insets
node.contentSize = nodeLayout.contentSize
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in
apply()
})
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
assert(node() is ChatListEmptyHeaderItemNode)
if let nodeValue = node() as? ChatListEmptyHeaderItemNode {
let layout = nodeValue.asyncLayout()
async {
let (nodeLayout, apply) = layout(self, params, nextItem == nil)
Queue.mainQueue().async {
completion(nodeLayout, { _ in
apply()
})
}
}
}
}
}
}
class ChatListEmptyHeaderItemNode: ListViewItemNode {
private var item: ChatListEmptyHeaderItem?
required init() {
super.init(layerBacked: false)
}
override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
let layout = self.asyncLayout()
let (_, apply) = layout(item as! ChatListEmptyHeaderItem, params, nextItem == nil)
apply()
}
func asyncLayout() -> (_ item: ChatListEmptyHeaderItem, _ params: ListViewItemLayoutParams, _ isLast: Bool) -> (ListViewItemNodeLayout, () -> Void) {
return { item, params, last in
let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: 0.0), insets: UIEdgeInsets())
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.item = item
strongSelf.contentSize = layout.contentSize
strongSelf.insets = layout.insets
}
})
}
}
}
@@ -0,0 +1,276 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ListSectionHeaderNode
import AppBundle
import AnimatedStickerNode
import TelegramAnimatedStickerNode
class ChatListEmptyInfoItem: ListViewItem {
let theme: PresentationTheme
let strings: PresentationStrings
let selectable: Bool = false
init(theme: PresentationTheme, strings: PresentationStrings) {
self.theme = theme
self.strings = strings
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ChatListEmptyInfoItemNode()
let (nodeLayout, apply) = node.asyncLayout()(self, params, false)
node.insets = nodeLayout.insets
node.contentSize = nodeLayout.contentSize
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in
apply()
})
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
assert(node() is ChatListEmptyInfoItemNode)
if let nodeValue = node() as? ChatListEmptyInfoItemNode {
let layout = nodeValue.asyncLayout()
async {
let (nodeLayout, apply) = layout(self, params, nextItem == nil)
Queue.mainQueue().async {
completion(nodeLayout, { _ in
apply()
})
}
}
}
}
}
}
class ChatListEmptyInfoItemNode: ListViewItemNode {
private var item: ChatListEmptyInfoItem?
private let animationNode: AnimatedStickerNode
private let textNode: TextNode
override var visibility: ListViewItemNodeVisibility {
didSet {
let wasVisible = self.visibilityStatus
let isVisible: Bool
switch self.visibility {
case let .visible(fraction, _):
isVisible = fraction > 0.2
case .none:
isVisible = false
}
if wasVisible != isVisible {
self.visibilityStatus = isVisible
}
}
}
private var visibilityStatus: Bool = false {
didSet {
if self.visibilityStatus != oldValue {
self.animationNode.visibility = self.visibilityStatus
}
}
}
required init() {
self.animationNode = DefaultAnimatedStickerNodeImpl()
self.textNode = TextNode()
super.init(layerBacked: false)
self.addSubnode(self.animationNode)
self.addSubnode(self.textNode)
}
override func didLoad() {
super.didLoad()
}
override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
let layout = self.asyncLayout()
let (_, apply) = layout(item as! ChatListEmptyInfoItem, params, nextItem == nil)
apply()
}
func asyncLayout() -> (_ item: ChatListEmptyInfoItem, _ params: ListViewItemLayoutParams, _ isLast: Bool) -> (ListViewItemNodeLayout, () -> Void) {
let makeTextLayout = TextNode.asyncLayout(self.textNode)
return { item, params, last in
let baseWidth = params.width - params.leftInset - params.rightInset
let topInset: CGFloat = 8.0
let textSpacing: CGFloat = 27.0
let bottomInset: CGFloat = 24.0
let animationHeight: CGFloat = 140.0
let string = NSMutableAttributedString(string: item.strings.ChatList_EmptyChatList, font: Font.semibold(17.0), textColor: item.theme.list.itemPrimaryTextColor)
let textLayout = makeTextLayout(TextNodeLayoutArguments(attributedString: string, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: baseWidth, height: .greatestFiniteMagnitude), alignment: .center))
let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: topInset + animationHeight + textSpacing + textLayout.0.size.height + bottomInset), insets: UIEdgeInsets())
return (layout, { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.item = item
var topOffset: CGFloat = topInset
let animationFrame = CGRect(origin: CGPoint(x: floor((params.width - animationHeight) * 0.5), y: topOffset), size: CGSize(width: animationHeight, height: animationHeight))
if strongSelf.animationNode.bounds.isEmpty {
strongSelf.animationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: "ChatListEmpty"), width: 248, height: 248, playbackMode: .once, mode: .direct(cachePathPrefix: nil))
}
strongSelf.animationNode.frame = animationFrame
topOffset += animationHeight + textSpacing
let _ = textLayout.1()
strongSelf.textNode.frame = CGRect(origin: CGPoint(x: floor((params.width - textLayout.0.size.width) * 0.5), y: topOffset), size: textLayout.0.size)
strongSelf.contentSize = layout.contentSize
strongSelf.insets = layout.insets
})
}
}
}
class ChatListSectionHeaderItem: ListViewItem {
let theme: PresentationTheme
let strings: PresentationStrings
let hide: (() -> Void)?
let selectable: Bool = false
init(theme: PresentationTheme, strings: PresentationStrings, hide: (() -> Void)?) {
self.theme = theme
self.strings = strings
self.hide = hide
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ChatListSectionHeaderNode()
let (nodeLayout, apply) = node.asyncLayout()(self, params, false)
node.insets = nodeLayout.insets
node.contentSize = nodeLayout.contentSize
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in
apply()
})
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
assert(node() is ChatListSectionHeaderNode)
if let nodeValue = node() as? ChatListSectionHeaderNode {
let layout = nodeValue.asyncLayout()
async {
let (nodeLayout, apply) = layout(self, params, nextItem == nil)
Queue.mainQueue().async {
completion(nodeLayout, { _ in
apply()
})
}
}
}
}
}
}
class ChatListSectionHeaderNode: ListViewItemNode {
private var item: ChatListSectionHeaderItem?
private var headerNode: ListSectionHeaderNode?
required init() {
super.init(layerBacked: false)
self.zPosition = 1.0
}
override func didLoad() {
super.didLoad()
}
override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
let layout = self.asyncLayout()
let (_, apply) = layout(item as! ChatListSectionHeaderItem, params, nextItem == nil)
apply()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let headerNode = self.headerNode {
if let result = headerNode.view.hitTest(self.view.convert(point, to: headerNode.view), with: event) {
return result
}
}
return nil
}
func asyncLayout() -> (_ item: ChatListSectionHeaderItem, _ params: ListViewItemLayoutParams, _ isLast: Bool) -> (ListViewItemNodeLayout, () -> Void) {
return { item, params, last in
let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: 28.0), insets: UIEdgeInsets())
return (layout, { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.item = item
let headerNode: ListSectionHeaderNode
if let current = strongSelf.headerNode {
headerNode = current
} else {
headerNode = ListSectionHeaderNode(theme: item.theme)
strongSelf.headerNode = headerNode
strongSelf.addSubnode(headerNode)
}
headerNode.title = item.strings.ChatList_EmptyListContactsHeader
if item.hide != nil {
headerNode.action = item.strings.ChatList_EmptyListContactsHeaderHide
headerNode.actionType = .generic
headerNode.activateAction = { _ in
guard let self else {
return
}
self.item?.hide?()
}
} else {
headerNode.action = nil
}
headerNode.updateTheme(theme: item.theme)
headerNode.updateLayout(size: CGSize(width: params.width, height: layout.contentSize.height), leftInset: params.leftInset, rightInset: params.rightInset)
strongSelf.contentSize = layout.contentSize
strongSelf.insets = layout.insets
})
}
}
}
@@ -0,0 +1,339 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramPresentationData
import ComponentFlow
import LottieComponent
class ChatListHoleItem: ListViewItem {
let theme: PresentationTheme
let selectable: Bool = false
init(theme: PresentationTheme) {
self.theme = theme
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ChatListHoleItemNode()
node.relativePosition = (first: previousItem == nil, last: nextItem == nil)
node.insets = ChatListItemNode.insets(first: false, last: false, firstWithHeader: false)
node.layoutForParams(params, item: self, previousItem: previousItem, nextItem: nextItem)
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in })
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
assert(node() is ChatListHoleItemNode)
if let nodeValue = node() as? ChatListHoleItemNode {
let layout = nodeValue.asyncLayout()
async {
let first = previousItem == nil
let last = nextItem == nil
let (nodeLayout, apply) = layout(self, params, first, last)
Queue.mainQueue().async {
completion(nodeLayout, { _ in
apply()
})
}
}
}
}
}
}
class ChatListHoleItemNode: ListViewItemNode {
var relativePosition: (first: Bool, last: Bool) = (false, false)
required init() {
super.init(layerBacked: false)
}
override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
let layout = self.asyncLayout()
let (_, apply) = layout(item as! ChatListHoleItem, params, self.relativePosition.first, self.relativePosition.last)
apply()
}
func asyncLayout() -> (_ item: ChatListHoleItem, _ params: ListViewItemLayoutParams, _ first: Bool, _ last: Bool) -> (ListViewItemNodeLayout, () -> Void) {
return { item, params, first, last in
let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: 0.0), insets: UIEdgeInsets())
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.relativePosition = (first, last)
strongSelf.contentSize = layout.contentSize
strongSelf.insets = layout.insets
}
})
}
}
}
class ChatListSearchEmptyFooterItem: ListViewItem {
let theme: PresentationTheme
let strings: PresentationStrings
let searchQuery: String?
let searchAllMessages: (() -> Void)?
let header: ListViewItemHeader?
let selectable: Bool = false
init(theme: PresentationTheme, strings: PresentationStrings, header: ListViewItemHeader?, searchQuery: String?, searchAllMessages: (() -> Void)?) {
self.theme = theme
self.strings = strings
self.header = header
self.searchQuery = searchQuery
self.searchAllMessages = searchAllMessages
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ChatListSearchEmptyFooterItemNode()
let (layout, apply) = node.asyncLayout()(self, params)
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
assert(node() is ChatListSearchEmptyFooterItemNode)
if let nodeValue = node() as? ChatListSearchEmptyFooterItemNode {
let layout = nodeValue.asyncLayout()
async {
let (nodeLayout, apply) = layout(self, params)
Queue.mainQueue().async {
completion(nodeLayout, { _ in
apply()
})
}
}
}
}
}
}
class ChatListSearchEmptyFooterItemNode: ListViewItemNode {
private let contentNode: ASDisplayNode
private let titleNode: TextNode
private let textNode: TextNode
private let searchAllMessagesButton: HighlightableButtonNode
private let searchAllMessagesTitle: TextNode
private let icon = ComponentView<Empty>()
private var item: ChatListSearchEmptyFooterItem?
required init() {
self.contentNode = ASDisplayNode()
self.titleNode = TextNode()
self.textNode = TextNode()
self.searchAllMessagesButton = HighlightableButtonNode()
self.searchAllMessagesTitle = TextNode()
self.searchAllMessagesTitle.isUserInteractionEnabled = false
super.init(layerBacked: false)
self.addSubnode(self.contentNode)
self.contentNode.addSubnode(self.titleNode)
self.contentNode.addSubnode(self.textNode)
self.contentNode.addSubnode(self.searchAllMessagesButton)
self.searchAllMessagesButton.addSubnode(self.searchAllMessagesTitle)
self.searchAllMessagesButton.addTarget(self, action: #selector(self.searchAllMessagesButtonPressed), forControlEvents: .touchUpInside)
self.wantsTrailingItemSpaceUpdates = true
}
@objc private func searchAllMessagesButtonPressed() {
self.item?.searchAllMessages?()
}
override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
let layout = self.asyncLayout()
let (_, apply) = layout(item as! ChatListSearchEmptyFooterItem, params)
apply()
}
override func headers() -> [ListViewItemHeader]? {
if let item = self.item {
return item.header.flatMap { [$0] }
} else {
return nil
}
}
override func updateTrailingItemSpace(_ trailingItemSpace: CGFloat, transition: ContainedViewLayoutTransition) {
var contentFrame = self.contentNode.frame
contentFrame.origin.y = max(0.0, floor(trailingItemSpace * 0.5))
self.contentNode.frame = contentFrame
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
if let contentResult = self.contentNode.view.hitTest(self.view.convert(point, to: self.contentNode.view), with: event), contentResult === self.searchAllMessagesButton.view {
return contentResult
}
return result
}
func asyncLayout() -> (_ item: ChatListSearchEmptyFooterItem, _ params: ListViewItemLayoutParams) -> (ListViewItemNodeLayout, () -> Void) {
let makeTitleNodeLayout = TextNode.asyncLayout(self.titleNode)
let makeTextNodeLayout = TextNode.asyncLayout(self.textNode)
let makeSearchAllMessagesTitleLayout = TextNode.asyncLayout(self.searchAllMessagesTitle)
return { [weak self] item, params in
let titleLayout = makeTitleNodeLayout(TextNodeLayoutArguments(
attributedString: NSAttributedString(string: item.strings.ChatList_Search_NoResults, font: Font.semibold(17.0), textColor: item.theme.list.freeTextColor),
maximumNumberOfLines: 1,
truncationType: .end,
constrainedSize: CGSize(width: params.width - params.leftInset * 2.0 - 12.0 * 2.0, height: 1000.0)
))
let textValue: String
if let searchQuery = item.searchQuery {
textValue = item.strings.ChatList_Search_NoResultsQueryDescription(searchQuery).string
} else {
textValue = item.strings.ChatList_Search_NoResults
}
let textLayout = makeTextNodeLayout(TextNodeLayoutArguments(
attributedString: NSAttributedString(string: textValue, font: Font.regular(16.0), textColor: item.theme.list.freeTextColor),
maximumNumberOfLines: 0,
truncationType: .end,
constrainedSize: CGSize(width: params.width - params.leftInset * 2.0 - 12.0 * 2.0, height: 1000.0),
alignment: .center,
lineSpacing: 0.1
))
let searchAllMessagesTitleLayout = makeSearchAllMessagesTitleLayout(TextNodeLayoutArguments(
attributedString: NSAttributedString(string: item.strings.ChatList_EmptyResult_SearchInAll, font: Font.regular(17.0), textColor: item.theme.list.itemAccentColor),
maximumNumberOfLines: 1,
truncationType: .end,
constrainedSize: CGSize(width: params.width - params.leftInset * 2.0 - 12.0 * 2.0, height: 1000.0)
))
var contentHeight: CGFloat = 0.0
let topInset: CGFloat = 40.0
let bottomInset: CGFloat = 10.0
let iconSpacing: CGFloat = 20.0
let titleSpacing: CGFloat = 6.0
let buttonSpacing: CGFloat = 14.0
let buttonInset: CGFloat = 11.0
let iconSize = CGSize(width: 128.0, height: 128.0)
contentHeight += topInset
contentHeight += iconSize.height
contentHeight += iconSpacing
contentHeight += titleLayout.0.size.height
contentHeight += titleSpacing
contentHeight += textLayout.0.size.height
if item.searchAllMessages != nil {
contentHeight += buttonSpacing
contentHeight += buttonInset
contentHeight += searchAllMessagesTitleLayout.0.size.height
contentHeight += buttonInset
}
contentHeight += bottomInset
let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: contentHeight), insets: UIEdgeInsets())
return (layout, { [weak self] in
guard let self else {
return
}
self.item = item
self.contentSize = layout.contentSize
self.insets = layout.insets
let _ = titleLayout.1()
let _ = textLayout.1()
let _ = searchAllMessagesTitleLayout.1()
var contentY: CGFloat = 0.0
contentY += topInset
let _ = self.icon.update(
transition: .immediate,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(
name: "ChatListNoResults"
),
color: nil,
placeholderColor: nil,
startingPosition: .begin,
size: iconSize,
renderingScale: nil,
loop: false,
playOnce: nil
)),
environment: {}, containerSize: iconSize
)
let iconFrame = CGRect(origin: CGPoint(x: floor((params.width - iconSize.width) * 0.5), y: contentY), size: iconSize)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.contentNode.view.addSubview(iconView)
}
iconView.frame = iconFrame
}
contentY += iconSize.height
contentY += iconSpacing
let titleFrame = CGRect(origin: CGPoint(x: floor((params.width - titleLayout.0.size.width) * 0.5), y: contentY), size: titleLayout.0.size)
self.titleNode.frame = titleFrame
contentY += titleLayout.0.size.height
contentY += titleSpacing
let textFrame = CGRect(origin: CGPoint(x: floor((params.width - textLayout.0.size.width) * 0.5), y: contentY), size: textLayout.0.size)
self.textNode.frame = textFrame
contentY += textLayout.0.size.height
if item.searchAllMessages != nil {
contentY += buttonSpacing
let searchAllMessagesButtonFrame = CGRect(origin: CGPoint(x: floor((params.width - searchAllMessagesTitleLayout.0.size.width) * 0.5), y: contentY), size: CGSize(width: searchAllMessagesTitleLayout.0.size.width, height: searchAllMessagesTitleLayout.0.size.height + buttonInset * 2.0))
contentY += searchAllMessagesTitleLayout.0.size.height + buttonInset * 2.0
self.searchAllMessagesButton.frame = searchAllMessagesButtonFrame
self.searchAllMessagesTitle.frame = CGRect(origin: CGPoint(x: 0.0, y: buttonInset), size: searchAllMessagesTitleLayout.0.size)
contentY += buttonInset
contentY += searchAllMessagesTitleLayout.0.size.height
contentY += buttonInset
}
contentY += bottomInset
let contentFrame = CGRect(origin: CGPoint(x: 0.0, y: self.contentNode.frame.minY), size: CGSize(width: params.width, height: contentHeight))
self.contentNode.frame = contentFrame
})
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,484 @@
import Foundation
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import TelegramStringFormatting
import LocalizedPeerData
import TextFormat
private enum MessageGroupType {
case photos
case videos
case music
case files
case generic
}
private func singleMessageType(message: EngineMessage) -> MessageGroupType {
for media in message.media {
if let _ = media as? TelegramMediaImage {
return .photos
} else if let file = media as? TelegramMediaFile {
if file.isMusic {
return .music
}
if file.isVideo && !file.isInstantVideo {
return .videos
}
return .files
}
}
return .generic
}
private func singleExtendedMediaType(extendedMedia: TelegramExtendedMedia) -> MessageGroupType {
switch extendedMedia {
case let .preview(_, _, videoDuration):
if let _ = videoDuration {
return .videos
} else {
return .photos
}
case let .full(fullMedia):
if let _ = fullMedia as? TelegramMediaImage {
return .photos
} else if let file = fullMedia as? TelegramMediaFile, file.isVideo {
return .videos
}
}
return .generic
}
private func messageGroupType(messages: [EngineMessage]) -> MessageGroupType {
if messages.isEmpty {
return .generic
}
let currentType = singleMessageType(message: messages[0])
for i in 1 ..< messages.count {
let nextType = singleMessageType(message: messages[i])
if nextType != currentType {
return .generic
}
}
return currentType
}
private func paidContentGroupType(paidContent: TelegramMediaPaidContent) -> MessageGroupType {
if paidContent.extendedMedia.isEmpty {
return .generic
}
let currentType = singleExtendedMediaType(extendedMedia: paidContent.extendedMedia[0])
for i in 1 ..< paidContent.extendedMedia.count {
let nextType = singleExtendedMediaType(extendedMedia: paidContent.extendedMedia[i])
if nextType != currentType {
return .generic
}
}
return currentType
}
public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, dateTimeFormat: PresentationDateTimeFormat, contentSettings: ContentSettings, messages: [EngineMessage], chatPeer: EngineRenderedPeer, accountPeerId: EnginePeer.Id, enableMediaEmoji: Bool = true, isPeerGroup: Bool = false) -> (peer: EnginePeer?, hideAuthor: Bool, messageText: String, messageEntities: [MessageTextEntity], spoilers: [NSRange]?, customEmojiRanges: [(NSRange, ChatTextInputTextCustomEmojiAttribute)]?) {
let peer: EnginePeer?
let message = messages.last
if let restrictionReason = message?._asMessage().restrictionReason(platform: "ios", contentSettings: contentSettings) {
return (nil, false, restrictionReason, [], nil, nil)
}
if let restrictionReason = chatPeer.chatMainPeer?.restrictionText(platform: "ios", contentSettings: contentSettings) {
return (nil, false, restrictionReason, [], nil, nil)
}
var hideAuthor = false
var messageText: String
var messageEntities: [MessageTextEntity] = []
var spoilers: [NSRange]?
var customEmojiRanges: [(NSRange, ChatTextInputTextCustomEmojiAttribute)]?
if let message = message {
if let messageMain = messageMainPeer(message) {
peer = messageMain
} else {
peer = chatPeer.chatMainPeer
}
messageText = ""
for message in messages {
if !message.text.isEmpty {
messageText = message.text
messageEntities = message._asMessage().textEntitiesAttribute?.entities ?? []
break
}
}
let paidContent = message.media.first(where: { $0 is TelegramMediaPaidContent }) as? TelegramMediaPaidContent
var textIsReady = false
if messages.count > 1 || (paidContent != nil && (paidContent?.extendedMedia.count ?? 0) > 1) {
let groupType: MessageGroupType
let count: Int32
if let paidContent {
groupType = paidContentGroupType(paidContent: paidContent)
count = Int32(paidContent.extendedMedia.count)
} else {
groupType = messageGroupType(messages: messages)
count = Int32(messages.count)
}
switch groupType {
case .photos:
if !messageText.isEmpty {
textIsReady = true
} else {
messageText = strings.ChatList_MessagePhotos(count)
textIsReady = true
}
case .videos:
if !messageText.isEmpty {
textIsReady = true
} else {
messageText = strings.ChatList_MessageVideos(count)
textIsReady = true
}
case .music:
if !messageText.isEmpty {
textIsReady = true
} else {
messageText = strings.ChatList_MessageMusic(count)
textIsReady = true
}
case .files:
if !messageText.isEmpty {
textIsReady = true
} else {
messageText = strings.ChatList_MessageFiles(count)
textIsReady = true
}
case .generic:
var messageTypes = Set<MessageGroupType>()
if let paidContent {
for extendedMedia in paidContent.extendedMedia {
messageTypes.insert(singleExtendedMediaType(extendedMedia: extendedMedia))
}
} else {
for message in messages {
messageTypes.insert(singleMessageType(message: message))
}
}
if messageTypes.count == 2 && messageTypes.contains(.photos) && messageTypes.contains(.videos) {
if !messageText.isEmpty {
textIsReady = true
}
}
}
}
if !textIsReady {
for media in message.media {
switch media {
case let paidContent as TelegramMediaPaidContent:
for extendedMedia in paidContent.extendedMedia {
let type = singleExtendedMediaType(extendedMedia: extendedMedia)
switch type {
case .photos:
if message.text.isEmpty {
messageText = strings.Message_Photo
} else if enableMediaEmoji {
messageText = "🖼 \(messageText)"
}
case .videos:
if message.text.isEmpty {
messageText = strings.Message_Video
} else if enableMediaEmoji {
messageText = "📹 \(messageText)"
}
default:
break
}
}
case _ as TelegramMediaImage:
if message.text.isEmpty {
messageText = strings.Message_Photo
} else if enableMediaEmoji {
messageText = "🖼 \(messageText)"
}
case let fileMedia as TelegramMediaFile:
var processed = false
inner: for attribute in fileMedia.attributes {
switch attribute {
case .Animated:
messageText = strings.Message_Animation
processed = true
break inner
case let .Audio(isVoice, _, title, performer, _):
if !message.text.isEmpty {
messageText = "🎤 \(messageText)"
processed = true
} else if isVoice {
if message.text.isEmpty {
messageText = strings.Message_Audio
} else {
messageText = "🎤 \(messageText)"
}
processed = true
break inner
} else {
let descriptionString: String
if let title = title, let performer = performer, !title.isEmpty, !performer.isEmpty {
descriptionString = title + "" + performer
} else if let title = title, !title.isEmpty {
descriptionString = title
} else if let performer = performer, !performer.isEmpty {
descriptionString = performer
} else if let fileName = fileMedia.fileName {
descriptionString = fileName
} else {
descriptionString = strings.Message_Audio
}
messageText = descriptionString
processed = true
break inner
}
case let .Sticker(displayText, _, _):
if displayText.isEmpty {
messageText = strings.Message_Sticker
processed = true
break inner
} else {
messageText = strings.Message_StickerText(displayText).string
processed = true
break inner
}
case let .Video(_, _, flags, _, _, _):
if flags.contains(.instantRoundVideo) {
messageText = strings.Message_VideoMessage
processed = true
break inner
} else {
if message.text.isEmpty {
if flags.contains(.isLivePhoto) {
//TODO:localize
messageText = "Live Photo"
} else {
messageText = strings.Message_Video
}
processed = true
} else {
if enableMediaEmoji {
if !fileMedia.isAnimated {
if flags.contains(.isLivePhoto) {
messageText = "🖼 \(messageText)"
} else {
messageText = "📹 \(messageText)"
}
}
}
processed = true
break inner
}
}
default:
break
}
}
if !processed {
if !message.text.isEmpty {
messageText = "📎 \(messageText)"
} else {
if fileMedia.isAnimatedSticker {
messageText = strings.Message_Sticker
} else {
if let fileName = fileMedia.fileName {
messageText = fileName
} else {
messageText = strings.Message_File
}
}
}
}
case let location as TelegramMediaMap:
if location.liveBroadcastingTimeout != nil {
messageText = strings.Message_LiveLocation
} else {
messageText = strings.Message_Location
}
case _ as TelegramMediaContact:
messageText = strings.Message_Contact
case let game as TelegramMediaGame:
messageText = "🎮 \(game.title)"
case let invoice as TelegramMediaInvoice:
messageText = invoice.title
case let action as TelegramMediaAction:
switch action.action {
case let .conferenceCall(conferenceCall):
let incoming = message.flags.contains(.Incoming)
let missedTimeout: Int32 = 30
let currentTime = Int32(Date().timeIntervalSince1970)
if conferenceCall.flags.contains(.isMissed) {
messageText = strings.Chat_CallMessage_DeclinedGroupCall
} else if conferenceCall.duration == nil && message.timestamp < currentTime - missedTimeout {
messageText = strings.Chat_CallMessage_MissedGroupCall
} else {
if incoming {
messageText = strings.Chat_CallMessage_IncomingGroupCall
} else {
messageText = strings.Chat_CallMessage_OutgoingGroupCall
}
}
case let .phoneCall(_, discardReason, _, isVideo):
hideAuthor = !isPeerGroup
let incoming = message.flags.contains(.Incoming)
if let discardReason = discardReason {
switch discardReason {
case .disconnect:
if isVideo {
messageText = strings.Notification_VideoCallCanceled
} else {
messageText = strings.Notification_CallCanceled
}
case .missed, .busy:
if incoming {
if isVideo {
messageText = strings.Notification_VideoCallMissed
} else {
messageText = strings.Notification_CallMissed
}
} else {
if isVideo {
messageText = strings.Notification_VideoCallCanceled
} else {
messageText = strings.Notification_CallCanceled
}
}
case .hangup:
break
}
}
if messageText.isEmpty {
if incoming {
if isVideo {
messageText = strings.Notification_VideoCallIncoming
} else {
messageText = strings.Notification_CallIncoming
}
} else {
if isVideo {
messageText = strings.Notification_VideoCallOutgoing
} else {
messageText = strings.Notification_CallOutgoing
}
}
}
default:
switch action.action {
case .topicCreated, .topicEdited:
hideAuthor = false
default:
hideAuthor = true
}
if let (text, textSpoilers, customEmojiRangesValue) = plainServiceMessageString(strings: strings, nameDisplayOrder: nameDisplayOrder, dateTimeFormat: dateTimeFormat, message: message, accountPeerId: accountPeerId, forChatList: true, forForumOverview: false) {
messageText = text
spoilers = textSpoilers
customEmojiRanges = customEmojiRangesValue
}
}
case _ as TelegramMediaExpiredContent:
if let (text, _, _) = plainServiceMessageString(strings: strings, nameDisplayOrder: nameDisplayOrder, dateTimeFormat: dateTimeFormat, message: message, accountPeerId: accountPeerId, forChatList: true, forForumOverview: false) {
messageText = text
}
case let poll as TelegramMediaPoll:
let pollPrefix = "📊 "
let entityOffset = (pollPrefix as NSString).length
messageText = "\(pollPrefix)\(poll.text)"
for entity in poll.textEntities {
if case let .CustomEmoji(_, fileId) = entity.type {
if customEmojiRanges == nil {
customEmojiRanges = []
}
let range = NSRange(location: entityOffset + entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound)
let attribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: message.associatedMedia[EngineMedia.Id(namespace: Namespaces.Media.CloudFile, id: fileId)] as? TelegramMediaFile)
customEmojiRanges?.append((range, attribute))
}
}
case let dice as TelegramMediaDice:
messageText = dice.emoji
case let story as TelegramMediaStory:
if story.isMention, let peer {
if message.flags.contains(.Incoming) {
messageText = strings.Conversation_StoryMentionTextIncoming(peer.compactDisplayTitle).string
} else {
messageText = strings.Conversation_StoryMentionTextOutgoing(peer.compactDisplayTitle).string
}
} else {
messageText = strings.Notification_Story
}
case _ as TelegramMediaGiveaway:
if let forwardInfo = message.forwardInfo, let author = forwardInfo.author {
messageText = strings.Message_GiveawayStartedOther(EnginePeer(author).compactDisplayTitle).string
} else {
if let author = message.author, case let .channel(channel) = author, case .group = channel.info {
messageText = strings.Message_GiveawayStartedGroup
} else {
messageText = strings.Message_GiveawayStarted
}
}
case let results as TelegramMediaGiveawayResults:
if results.winnersCount == 0 {
messageText = strings.Message_GiveawayEndedNoWinners
} else {
messageText = strings.Message_GiveawayEndedWinners(results.winnersCount)
}
case let webpage as TelegramMediaWebpage:
if messageText.isEmpty, case let .Loaded(content) = webpage.content {
messageText = content.displayUrl
}
case let todo as TelegramMediaTodo:
let pollPrefix = "☑️ "
let entityOffset = (pollPrefix as NSString).length
messageText = "\(pollPrefix)\(todo.text)"
for entity in todo.textEntities {
if case let .CustomEmoji(_, fileId) = entity.type {
if customEmojiRanges == nil {
customEmojiRanges = []
}
let range = NSRange(location: entityOffset + entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound)
let attribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: message.associatedMedia[EngineMedia.Id(namespace: Namespaces.Media.CloudFile, id: fileId)] as? TelegramMediaFile)
customEmojiRanges?.append((range, attribute))
}
}
default:
break
}
}
}
} else {
peer = chatPeer.chatMainPeer
messageText = ""
if chatPeer.peerId.namespace == Namespaces.Peer.SecretChat {
if case let .secretChat(secretChat) = chatPeer.peers[chatPeer.peerId] {
switch secretChat.embeddedState {
case .active:
switch secretChat.role {
case .creator:
messageText = strings.DialogList_EncryptedChatStartedOutgoing(peer?.compactDisplayTitle ?? "").string
case .participant:
messageText = strings.DialogList_EncryptedChatStartedIncoming(peer?.compactDisplayTitle ?? "").string
}
case .terminated:
messageText = strings.DialogList_EncryptionRejected
case .handshake:
switch secretChat.role {
case .creator:
messageText = strings.DialogList_AwaitingEncryption(peer?.compactDisplayTitle ?? "").string
case .participant:
messageText = strings.DialogList_EncryptionProcessing
}
}
}
}
}
return (peer, hideAuthor, messageText, messageEntities, spoilers, customEmojiRanges)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,950 @@
import Foundation
import UIKit
import Postbox
import TelegramCore
import TelegramPresentationData
import MergeLists
import AccountContext
enum ChatListNodeEntryId: Hashable {
case Header
case Hole(Int64)
case PeerId(Int64)
case ThreadId(Int64)
case GroupId(EngineChatList.Group)
case ContactId(EnginePeer.Id)
case ArchiveIntro
case EmptyIntro
case SectionHeader
case Notice
case additionalCategory(Int)
}
enum ChatListNodeEntrySortIndex: Comparable {
case index(EngineChatList.Item.Index)
case additionalCategory(Int)
case sectionHeader
case contact(id: EnginePeer.Id, presence: EnginePeer.Presence)
static func <(lhs: ChatListNodeEntrySortIndex, rhs: ChatListNodeEntrySortIndex) -> Bool {
switch lhs {
case let .index(lhsIndex):
switch rhs {
case let .index(rhsIndex):
return lhsIndex < rhsIndex
case .additionalCategory:
return false
case .sectionHeader:
return true
case .contact:
return true
}
case let .additionalCategory(lhsIndex):
switch rhs {
case let .additionalCategory(rhsIndex):
return lhsIndex < rhsIndex
case .index:
return true
case .sectionHeader:
return true
case .contact:
return true
}
case .sectionHeader:
switch rhs {
case .additionalCategory, .index, .sectionHeader:
return false
case .contact:
return true
}
case let .contact(lhsId, lhsPresense):
switch rhs {
case .sectionHeader:
return false
case let .contact(rhsId, rhsPresense):
if lhsPresense != rhsPresense {
return rhsPresense.status > rhsPresense.status
} else {
return lhsId < rhsId
}
default:
return false
}
}
}
}
public enum ChatListNodeEntryPromoInfo: Equatable {
case proxy
case psa(type: String, message: String?)
}
enum ChatListNodeEntry: Comparable, Identifiable {
struct PeerEntryData: Equatable {
var index: EngineChatList.Item.Index
var presentationData: ChatListPresentationData
var messages: [EngineMessage]
var readState: EnginePeerReadCounters?
var isRemovedFromTotalUnreadCount: Bool
var draftState: ChatListItemContent.DraftState?
var mediaDraftContentType: EngineChatList.MediaDraftContentType?
var peer: EngineRenderedPeer
var threadInfo: ChatListItemContent.ThreadInfo?
var presence: EnginePeer.Presence?
var hasUnseenMentions: Bool
var hasUnseenReactions: Bool
var editing: Bool
var hasActiveRevealControls: Bool
var selected: Bool
var inputActivities: [(EnginePeer, PeerInputActivity)]?
var promoInfo: ChatListNodeEntryPromoInfo?
var hasFailedMessages: Bool
var isContact: Bool
var autoremoveTimeout: Int32?
var forumTopicData: EngineChatList.ForumTopicData?
var topForumTopicItems: [EngineChatList.ForumTopicData]
var revealed: Bool
var storyState: ChatListNodeState.StoryState?
var requiresPremiumForMessaging: Bool
var displayAsTopicList: Bool
init(
index: EngineChatList.Item.Index,
presentationData: ChatListPresentationData,
messages: [EngineMessage],
readState: EnginePeerReadCounters?,
isRemovedFromTotalUnreadCount: Bool,
draftState: ChatListItemContent.DraftState?,
mediaDraftContentType: EngineChatList.MediaDraftContentType?,
peer: EngineRenderedPeer,
threadInfo: ChatListItemContent.ThreadInfo?,
presence: EnginePeer.Presence?,
hasUnseenMentions: Bool,
hasUnseenReactions: Bool,
editing: Bool,
hasActiveRevealControls: Bool,
selected: Bool,
inputActivities: [(EnginePeer, PeerInputActivity)]?,
promoInfo: ChatListNodeEntryPromoInfo?,
hasFailedMessages: Bool,
isContact: Bool,
autoremoveTimeout: Int32?,
forumTopicData: EngineChatList.ForumTopicData?,
topForumTopicItems: [EngineChatList.ForumTopicData],
revealed: Bool,
storyState: ChatListNodeState.StoryState?,
requiresPremiumForMessaging: Bool,
displayAsTopicList: Bool
) {
self.index = index
self.presentationData = presentationData
self.messages = messages
self.readState = readState
self.isRemovedFromTotalUnreadCount = isRemovedFromTotalUnreadCount
self.draftState = draftState
self.mediaDraftContentType = mediaDraftContentType
self.peer = peer
self.threadInfo = threadInfo
self.presence = presence
self.hasUnseenMentions = hasUnseenMentions
self.hasUnseenReactions = hasUnseenReactions
self.editing = editing
self.hasActiveRevealControls = hasActiveRevealControls
self.selected = selected
self.inputActivities = inputActivities
self.promoInfo = promoInfo
self.hasFailedMessages = hasFailedMessages
self.isContact = isContact
self.autoremoveTimeout = autoremoveTimeout
self.forumTopicData = forumTopicData
self.topForumTopicItems = topForumTopicItems
self.revealed = revealed
self.storyState = storyState
self.requiresPremiumForMessaging = requiresPremiumForMessaging
self.displayAsTopicList = displayAsTopicList
}
static func ==(lhs: PeerEntryData, rhs: PeerEntryData) -> Bool {
if lhs.index != rhs.index {
return false
}
if lhs.presentationData !== rhs.presentationData {
return false
}
if lhs.readState != rhs.readState {
return false
}
if lhs.messages.count != rhs.messages.count {
return false
}
for i in 0 ..< lhs.messages.count {
if lhs.messages[i].stableVersion != rhs.messages[i].stableVersion {
return false
}
if lhs.messages[i].id != rhs.messages[i].id {
return false
}
if lhs.messages[i].associatedMessages.count != rhs.messages[i].associatedMessages.count {
return false
}
for (id, message) in lhs.messages[i].associatedMessages {
if let otherMessage = rhs.messages[i].associatedMessages[id] {
if message.stableVersion != otherMessage.stableVersion {
return false
}
} else {
return false
}
}
}
if lhs.isRemovedFromTotalUnreadCount != rhs.isRemovedFromTotalUnreadCount {
return false
}
if let lhsPeerPresence = lhs.presence, let rhsPeerPresence = rhs.presence {
if lhsPeerPresence != rhsPeerPresence {
return false
}
} else if (lhs.presence != nil) != (rhs.presence != nil) {
return false
}
if let lhsEmbeddedState = lhs.draftState, let rhsEmbeddedState = rhs.draftState {
if lhsEmbeddedState != rhsEmbeddedState {
return false
}
} else if (lhs.draftState != nil) != (rhs.draftState != nil) {
return false
}
if lhs.mediaDraftContentType != rhs.mediaDraftContentType {
return false
}
if lhs.editing != rhs.editing {
return false
}
if lhs.hasActiveRevealControls != rhs.hasActiveRevealControls {
return false
}
if lhs.selected != rhs.selected {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.threadInfo != rhs.threadInfo {
return false
}
if lhs.hasUnseenMentions != rhs.hasUnseenMentions {
return false
}
if lhs.hasUnseenReactions != rhs.hasUnseenReactions {
return false
}
if let lhsInputActivities = lhs.inputActivities, let rhsInputActivities = rhs.inputActivities {
if lhsInputActivities.count != rhsInputActivities.count {
return false
}
for i in 0 ..< lhsInputActivities.count {
if lhsInputActivities[i].0 != rhsInputActivities[i].0 {
return false
}
if lhsInputActivities[i].1 != rhsInputActivities[i].1 {
return false
}
}
} else if (lhs.inputActivities != nil) != (rhs.inputActivities != nil) {
return false
}
if lhs.promoInfo != rhs.promoInfo {
return false
}
if lhs.hasFailedMessages != rhs.hasFailedMessages {
return false
}
if lhs.isContact != rhs.isContact {
return false
}
if lhs.autoremoveTimeout != rhs.autoremoveTimeout {
return false
}
if lhs.forumTopicData != rhs.forumTopicData {
return false
}
if lhs.topForumTopicItems != rhs.topForumTopicItems {
return false
}
if lhs.revealed != rhs.revealed {
return false
}
if lhs.storyState != rhs.storyState {
return false
}
if lhs.requiresPremiumForMessaging != rhs.requiresPremiumForMessaging {
return false
}
if lhs.displayAsTopicList != rhs.displayAsTopicList {
return false
}
return true
}
}
struct ContactEntryData: Equatable {
var presentationData: ChatListPresentationData
var peer: EnginePeer
var presence: EnginePeer.Presence
init(presentationData: ChatListPresentationData, peer: EnginePeer, presence: EnginePeer.Presence) {
self.presentationData = presentationData
self.peer = peer
self.presence = presence
}
static func ==(lhs: ContactEntryData, rhs: ContactEntryData) -> Bool {
if lhs.presentationData !== rhs.presentationData {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.presence != rhs.presence {
return false
}
return true
}
}
struct GroupReferenceEntryData: Equatable {
var index: EngineChatList.Item.Index
var presentationData: ChatListPresentationData
var groupId: EngineChatList.Group
var peers: [EngineChatList.GroupItem.Item]
var message: EngineMessage?
var editing: Bool
var unreadCount: Int
var revealed: Bool
var hiddenByDefault: Bool
var appearsPinned: Bool
var storyState: ChatListNodeState.StoryState?
init(
index: EngineChatList.Item.Index,
presentationData: ChatListPresentationData,
groupId: EngineChatList.Group,
peers: [EngineChatList.GroupItem.Item],
message: EngineMessage?,
editing: Bool,
unreadCount: Int,
revealed: Bool,
hiddenByDefault: Bool,
appearsPinned: Bool,
storyState: ChatListNodeState.StoryState?
) {
self.index = index
self.presentationData = presentationData
self.groupId = groupId
self.peers = peers
self.message = message
self.editing = editing
self.unreadCount = unreadCount
self.revealed = revealed
self.hiddenByDefault = hiddenByDefault
self.appearsPinned = appearsPinned
self.storyState = storyState
}
static func ==(lhs: GroupReferenceEntryData, rhs: GroupReferenceEntryData) -> Bool {
if lhs.index != rhs.index {
return false
}
if lhs.presentationData !== rhs.presentationData {
return false
}
if lhs.groupId != rhs.groupId {
return false
}
if lhs.peers != rhs.peers {
return false
}
if lhs.message?.stableId != rhs.message?.stableId {
return false
}
if lhs.editing != rhs.editing {
return false
}
if lhs.unreadCount != rhs.unreadCount {
return false
}
if lhs.revealed != rhs.revealed {
return false
}
if lhs.hiddenByDefault != rhs.hiddenByDefault {
return false
}
if lhs.appearsPinned != rhs.appearsPinned {
return false
}
if lhs.storyState != rhs.storyState {
return false
}
return true
}
}
case HeaderEntry
case PeerEntry(PeerEntryData)
case HoleEntry(EngineMessage.Index, theme: PresentationTheme)
case GroupReferenceEntry(GroupReferenceEntryData)
case ContactEntry(ContactEntryData)
case ArchiveIntro(presentationData: ChatListPresentationData)
case EmptyIntro(presentationData: ChatListPresentationData)
case SectionHeader(presentationData: ChatListPresentationData, displayHide: Bool)
case AdditionalCategory(index: Int, id: Int, title: String, image: UIImage?, appearance: ChatListNodeAdditionalCategory.Appearance, selected: Bool, presentationData: ChatListPresentationData)
var sortIndex: ChatListNodeEntrySortIndex {
switch self {
case .HeaderEntry:
return .index(.chatList(.absoluteUpperBound))
case let .PeerEntry(peerEntry):
return .index(peerEntry.index)
case let .HoleEntry(holeIndex, _):
return .index(.chatList(EngineChatList.Item.Index.ChatList(pinningIndex: nil, messageIndex: holeIndex)))
case let .GroupReferenceEntry(groupReferenceEntry):
return .index(groupReferenceEntry.index)
case let .ContactEntry(contactEntry):
return .contact(id: contactEntry.peer.id, presence: contactEntry.presence)
case .ArchiveIntro:
return .index(.chatList(EngineChatList.Item.Index.ChatList.absoluteUpperBound.successor))
case .EmptyIntro:
return .index(.chatList(EngineChatList.Item.Index.ChatList.absoluteUpperBound.successor))
case .SectionHeader:
return .sectionHeader
case let .AdditionalCategory(index, _, _, _, _, _, _):
return .additionalCategory(index)
}
}
var stableId: ChatListNodeEntryId {
switch self {
case .HeaderEntry:
return .Header
case let .PeerEntry(peerEntry):
switch peerEntry.index {
case let .chatList(index):
return .PeerId(index.messageIndex.id.peerId.toInt64())
case let .forum(_, _, threadId, _, _):
return .ThreadId(threadId)
}
case let .HoleEntry(holeIndex, _):
return .Hole(Int64(holeIndex.id.id))
case let .GroupReferenceEntry(groupReferenceEntry):
return .GroupId(groupReferenceEntry.groupId)
case let .ContactEntry(contactEntry):
return .ContactId(contactEntry.peer.id)
case .ArchiveIntro:
return .ArchiveIntro
case .EmptyIntro:
return .EmptyIntro
case .SectionHeader:
return .SectionHeader
case let .AdditionalCategory(_, id, _, _, _, _, _):
return .additionalCategory(id)
}
}
static func <(lhs: ChatListNodeEntry, rhs: ChatListNodeEntry) -> Bool {
return lhs.sortIndex < rhs.sortIndex
}
static func ==(lhs: ChatListNodeEntry, rhs: ChatListNodeEntry) -> Bool {
switch lhs {
case .HeaderEntry:
if case .HeaderEntry = rhs {
return true
} else {
return false
}
case let .PeerEntry(peerEntry):
if case .PeerEntry(peerEntry) = rhs {
return true
} else {
return false
}
case let .HoleEntry(lhsHole, lhsTheme):
switch rhs {
case let .HoleEntry(rhsHole, rhsTheme):
return lhsHole == rhsHole && lhsTheme === rhsTheme
default:
return false
}
case let .GroupReferenceEntry(groupReferenceEntry):
if case .GroupReferenceEntry(groupReferenceEntry) = rhs {
return true
} else {
return false
}
case let .ContactEntry(contactEntry):
if case .ContactEntry(contactEntry) = rhs {
return true
} else {
return false
}
case let .ArchiveIntro(lhsPresentationData):
if case let .ArchiveIntro(rhsPresentationData) = rhs {
if lhsPresentationData !== rhsPresentationData {
return false
}
return true
} else {
return false
}
case let .EmptyIntro(lhsPresentationData):
if case let .EmptyIntro(rhsPresentationData) = rhs {
if lhsPresentationData !== rhsPresentationData {
return false
}
return true
} else {
return false
}
case let .SectionHeader(lhsPresentationData, lhsDisplayHide):
if case let .SectionHeader(rhsPresentationData, rhsDisplayHide) = rhs {
if lhsPresentationData !== rhsPresentationData {
return false
}
if lhsDisplayHide != rhsDisplayHide {
return false
}
return true
} else {
return false
}
case let .AdditionalCategory(lhsIndex, lhsId, lhsTitle, lhsImage, lhsAppearance, lhsSelected, lhsPresentationData):
if case let .AdditionalCategory(rhsIndex, rhsId, rhsTitle, rhsImage, rhsAppearance, rhsSelected, rhsPresentationData) = rhs {
if lhsIndex != rhsIndex {
return false
}
if lhsId != rhsId {
return false
}
if lhsTitle != rhsTitle {
return false
}
if lhsImage !== rhsImage {
return false
}
if lhsAppearance != rhsAppearance {
return false
}
if lhsSelected != rhsSelected {
return false
}
if lhsPresentationData !== rhsPresentationData {
return false
}
return true
} else {
return false
}
}
}
}
private func offsetPinnedIndex(_ index: EngineChatList.Item.Index, offset: UInt16) -> EngineChatList.Item.Index {
if case let .chatList(index) = index, let pinningIndex = index.pinningIndex {
return .chatList(EngineChatList.Item.Index.ChatList(pinningIndex: pinningIndex + offset, messageIndex: index.messageIndex))
} else {
return index
}
}
struct ChatListContactPeer {
var peer: EnginePeer
var presence: EnginePeer.Presence
init(peer: EnginePeer, presence: EnginePeer.Presence) {
self.peer = peer
self.presence = presence
}
}
func chatListNodeEntriesForView(view: EngineChatList, state: ChatListNodeState, savedMessagesPeer: EnginePeer?, foundPeers: [(EnginePeer, EnginePeer?)], hideArchivedFolderByDefault: Bool, displayArchiveIntro: Bool, mode: ChatListNodeMode, chatListLocation: ChatListControllerLocation, contacts: [ChatListContactPeer], accountPeerId: EnginePeer.Id, isMainTab: Bool) -> (entries: [ChatListNodeEntry], loading: Bool) {
var groupItems = view.groupItems
if isMainTab && state.archiveStoryState != nil && groupItems.isEmpty {
groupItems.append(EngineChatList.GroupItem(
id: .archive,
topMessage: nil,
items: [],
unreadCount: 0
))
}
var result: [ChatListNodeEntry] = []
var hasContacts = false
if !view.hasEarlier {
var existingPeerIds = Set<EnginePeer.Id>()
for item in view.items {
existingPeerIds.insert(item.renderedPeer.peerId)
}
for contact in contacts {
if existingPeerIds.contains(contact.peer.id) {
continue
}
result.append(.ContactEntry(ChatListNodeEntry.ContactEntryData(
presentationData: state.presentationData,
peer: contact.peer,
presence: contact.presence
)))
hasContacts = true
}
if hasContacts {
result.append(.SectionHeader(presentationData: state.presentationData, displayHide: !view.items.isEmpty))
}
}
var pinnedIndexOffset: UInt16 = 0
if !view.hasLater, case .chatList = mode {
var groupEntryCount = 0
for _ in groupItems {
groupEntryCount += 1
}
pinnedIndexOffset += UInt16(groupEntryCount)
}
let filteredAdditionalItemEntries = view.additionalItems.filter { item -> Bool in
return item.item.renderedPeer.peerId != state.hiddenPsaPeerId
}
var foundPeerIds = Set<EnginePeer.Id>()
for peer in foundPeers {
foundPeerIds.insert(peer.0.id)
}
if !view.hasLater && savedMessagesPeer == nil {
pinnedIndexOffset += UInt16(filteredAdditionalItemEntries.count)
}
var hiddenGeneralThread: ChatListNodeEntry?
var hasPinned = false
loop: for entry in view.items {
var peerId: EnginePeer.Id?
var threadId: Int64?
var activityItemId: ChatListNodePeerInputActivities.ItemId?
if case let .chatList(index) = entry.index {
peerId = index.messageIndex.id.peerId
activityItemId = ChatListNodePeerInputActivities.ItemId(peerId: index.messageIndex.id.peerId, threadId: nil)
} else if case let .forum(_, _, threadIdValue, _, _) = entry.index, case let .forum(peerIdValue) = chatListLocation {
peerId = peerIdValue
activityItemId = ChatListNodePeerInputActivities.ItemId(peerId: peerIdValue, threadId: threadIdValue)
threadId = threadIdValue
}
if let savedMessagesPeer = savedMessagesPeer, let peerId = peerId, savedMessagesPeer.id == peerId || foundPeerIds.contains(peerId) {
continue loop
}
if let peerId = peerId, state.pendingRemovalItemIds.contains(ChatListNodeState.ItemId(peerId: peerId, threadId: threadId)) {
continue loop
}
var updatedMessages = entry.messages
var updatedCombinedReadState = entry.readCounters
if let peerId = peerId, state.pendingClearHistoryPeerIds.contains(ChatListNodeState.ItemId(peerId: peerId, threadId: threadId)) {
updatedMessages = []
updatedCombinedReadState = nil
}
var draftState: ChatListItemContent.DraftState?
if let draft = entry.draft {
draftState = ChatListItemContent.DraftState(draft: draft)
}
var hasActiveRevealControls = false
if let peerId {
hasActiveRevealControls = ChatListNodeState.ItemId(peerId: peerId, threadId: threadId) == state.peerIdWithRevealedOptions
}
var inputActivities: [(EnginePeer, PeerInputActivity)]?
if let activityItemId {
inputActivities = state.peerInputActivities?.activities[activityItemId]
}
var isSelected = false
if let threadId, threadId != 0 {
isSelected = state.selectedThreadIds.contains(threadId)
} else if let peerId {
isSelected = state.selectedPeerIds.contains(peerId)
}
var threadInfo: ChatListItemContent.ThreadInfo?
if let threadData = entry.threadData, let threadId {
threadInfo = ChatListItemContent.ThreadInfo(id: threadId, info: threadData.info, isOwnedByMe: threadData.isOwnedByMe, isClosed: threadData.isClosed, isHidden: threadData.isHidden, threadPeer: nil)
}
switch entry.index {
case let .chatList(chatList):
if chatList.pinningIndex != nil {
hasPinned = true
}
case let .forum(pinnedIndex, _, _, _, _):
if case .index = pinnedIndex {
hasPinned = true
}
}
let entry: ChatListNodeEntry = .PeerEntry(ChatListNodeEntry.PeerEntryData(
index: offsetPinnedIndex(entry.index, offset: pinnedIndexOffset),
presentationData: state.presentationData,
messages: updatedMessages,
readState: updatedCombinedReadState,
isRemovedFromTotalUnreadCount: entry.isMuted,
draftState: draftState,
mediaDraftContentType: entry.mediaDraftContentType,
peer: entry.renderedPeer,
threadInfo: threadInfo,
presence: entry.presence,
hasUnseenMentions: entry.hasUnseenMentions,
hasUnseenReactions: entry.hasUnseenReactions,
editing: state.editing,
hasActiveRevealControls: hasActiveRevealControls,
selected: isSelected,
inputActivities: inputActivities,
promoInfo: nil,
hasFailedMessages: entry.hasFailed,
isContact: entry.isContact,
autoremoveTimeout: entry.autoremoveTimeout,
forumTopicData: entry.forumTopicData,
topForumTopicItems: entry.topForumTopicItems,
revealed: threadId == 1 && (state.hiddenItemShouldBeTemporaryRevealed || state.editing),
storyState: entry.renderedPeer.peerId == accountPeerId ? nil : entry.storyStats.flatMap { stats -> ChatListNodeState.StoryState in
return ChatListNodeState.StoryState(
stats: stats,
hasUnseenCloseFriends: stats.hasUnseenCloseFriends
)
},
requiresPremiumForMessaging: entry.isPremiumRequiredToMessage,
displayAsTopicList: entry.displayAsTopicList
))
if let threadInfo, threadInfo.isHidden {
hiddenGeneralThread = entry
} else {
result.append(entry)
}
}
if let hiddenGeneralThread {
result.append(hiddenGeneralThread)
}
if !view.hasLater {
var pinningIndex: UInt16 = UInt16(pinnedIndexOffset == 0 ? 0 : (pinnedIndexOffset - 1))
if let savedMessagesPeer = savedMessagesPeer {
if !foundPeers.isEmpty {
var foundPinningIndex: UInt16 = UInt16(foundPeers.count)
for peer in foundPeers.reversed() {
var peers: [EnginePeer.Id: EnginePeer] = [peer.0.id: peer.0]
if let chatPeer = peer.1 {
peers[chatPeer.id] = chatPeer
}
let messageIndex = EngineMessage.Index(id: EngineMessage.Id(peerId: peer.0.id, namespace: 0, id: 0), timestamp: 1)
result.append(.PeerEntry(ChatListNodeEntry.PeerEntryData(
index: .chatList(EngineChatList.Item.Index.ChatList(pinningIndex: foundPinningIndex, messageIndex: messageIndex)),
presentationData: state.presentationData,
messages: [],
readState: nil,
isRemovedFromTotalUnreadCount: false,
draftState: nil,
mediaDraftContentType: nil,
peer: EngineRenderedPeer(peerId: peer.0.id, peers: peers, associatedMedia: [:]),
threadInfo: nil,
presence: nil,
hasUnseenMentions: false,
hasUnseenReactions: false,
editing: state.editing,
hasActiveRevealControls: false,
selected: state.selectedPeerIds.contains(peer.0.id),
inputActivities: nil,
promoInfo: nil,
hasFailedMessages: false,
isContact: false,
autoremoveTimeout: nil,
forumTopicData: nil,
topForumTopicItems: [],
revealed: false,
storyState: nil,
requiresPremiumForMessaging: false,
displayAsTopicList: false
)))
if foundPinningIndex != 0 {
foundPinningIndex -= 1
hasPinned = true
}
}
}
result.append(.PeerEntry(ChatListNodeEntry.PeerEntryData(
index: .chatList(EngineChatList.Item.Index.ChatList.absoluteUpperBound.predecessor),
presentationData: state.presentationData,
messages: [],
readState: nil,
isRemovedFromTotalUnreadCount: false,
draftState: nil,
mediaDraftContentType: nil,
peer: EngineRenderedPeer(peerId: savedMessagesPeer.id, peers: [savedMessagesPeer.id: savedMessagesPeer], associatedMedia: [:]),
threadInfo: nil,
presence: nil,
hasUnseenMentions: false,
hasUnseenReactions: false,
editing: state.editing,
hasActiveRevealControls: false,
selected: state.selectedPeerIds.contains(savedMessagesPeer.id),
inputActivities: nil,
promoInfo: nil,
hasFailedMessages: false,
isContact: false,
autoremoveTimeout: nil,
forumTopicData: nil,
topForumTopicItems: [],
revealed: false,
storyState: nil,
requiresPremiumForMessaging: false,
displayAsTopicList: false
)))
} else {
if !filteredAdditionalItemEntries.isEmpty {
for item in filteredAdditionalItemEntries.reversed() {
guard case let .chatList(index) = item.item.index else {
continue
}
let promoInfo: ChatListNodeEntryPromoInfo
switch item.promoInfo.content {
case .proxy:
promoInfo = .proxy
case let .psa(type, message):
promoInfo = .psa(type: type, message: message)
}
let draftState = item.item.draft.flatMap(ChatListItemContent.DraftState.init)
let peerId = index.messageIndex.id.peerId
let isSelected = state.selectedPeerIds.contains(peerId)
var threadId: Int64 = 0
switch item.item.index {
case let .forum(_, _, threadIdValue, _, _):
threadId = threadIdValue
default:
break
}
result.append(.PeerEntry(ChatListNodeEntry.PeerEntryData(
index: .chatList(EngineChatList.Item.Index.ChatList(pinningIndex: pinningIndex, messageIndex: index.messageIndex)),
presentationData: state.presentationData,
messages: item.item.messages,
readState: item.item.readCounters,
isRemovedFromTotalUnreadCount: item.item.isMuted,
draftState: draftState,
mediaDraftContentType: item.item.mediaDraftContentType,
peer: item.item.renderedPeer,
threadInfo: item.item.threadData.flatMap {
return ChatListItemContent.ThreadInfo(id: threadId, info: $0.info, isOwnedByMe: $0.isOwnedByMe, isClosed: $0.isClosed, isHidden: $0.isHidden, threadPeer: nil)
},
presence: item.item.presence,
hasUnseenMentions: item.item.hasUnseenMentions,
hasUnseenReactions: item.item.hasUnseenReactions,
editing: state.editing,
hasActiveRevealControls: ChatListNodeState.ItemId(peerId: peerId, threadId: threadId) == state.peerIdWithRevealedOptions,
selected: isSelected,
inputActivities: state.peerInputActivities?.activities[ChatListNodePeerInputActivities.ItemId(peerId: peerId, threadId: nil)],
promoInfo: promoInfo,
hasFailedMessages: item.item.hasFailed,
isContact: item.item.isContact,
autoremoveTimeout: item.item.autoremoveTimeout,
forumTopicData: item.item.forumTopicData,
topForumTopicItems: item.item.topForumTopicItems,
revealed: state.hiddenItemShouldBeTemporaryRevealed || state.editing,
storyState: nil,
requiresPremiumForMessaging: false,
displayAsTopicList: false
)))
if pinningIndex != 0 {
pinningIndex -= 1
hasPinned = true
}
}
}
}
if !view.hasLater, case .chatList = mode {
for groupReference in groupItems {
let messageIndex = EngineMessage.Index(id: EngineMessage.Id(peerId: EnginePeer.Id(0), namespace: 0, id: 0), timestamp: 1)
var mappedStoryState: ChatListNodeState.StoryState?
if let archiveStoryState = state.archiveStoryState {
mappedStoryState = archiveStoryState
}
result.append(.GroupReferenceEntry(ChatListNodeEntry.GroupReferenceEntryData(
index: .chatList(EngineChatList.Item.Index.ChatList(pinningIndex: pinningIndex, messageIndex: messageIndex)),
presentationData: state.presentationData,
groupId: groupReference.id,
peers: groupReference.items,
message: groupReference.topMessage,
editing: state.editing,
unreadCount: groupReference.unreadCount,
revealed: state.hiddenItemShouldBeTemporaryRevealed,
hiddenByDefault: hideArchivedFolderByDefault,
appearsPinned: hasPinned,
storyState: mappedStoryState
)))
if pinningIndex != 0 {
pinningIndex -= 1
hasPinned = true
}
}
if displayArchiveIntro {
//result.append(.ArchiveIntro(presentationData: state.presentationData))
} else if !contacts.isEmpty && !result.contains(where: { entry in
if case .PeerEntry = entry {
return true
} else {
return false
}
}) {
result.append(.EmptyIntro(presentationData: state.presentationData))
}
result.append(.HeaderEntry)
}
if !view.hasLater {
if case let .peers(_, _, additionalCategories, _, _, _) = mode {
var index = 0
for category in additionalCategories.reversed() {
result.append(.AdditionalCategory(index: index, id: category.id, title: category.title, image: category.icon, appearance: category.appearance, selected: state.selectedAdditionalCategoryIds.contains(category.id), presentationData: state.presentationData))
index += 1
}
} else if case let .peerType(types, hasCreate) = mode, !result.isEmpty && hasCreate {
for type in types {
switch type {
case .group:
result.append(.AdditionalCategory(index: 0, id: 0, title: state.presentationData.strings.RequestPeer_CreateNewGroup, image: PresentationResourcesItemList.createGroupIcon(state.presentationData.theme), appearance: .action, selected: false, presentationData: state.presentationData))
case .channel:
result.append(.AdditionalCategory(index: 0, id: 0, title: state.presentationData.strings.RequestPeer_CreateNewChannel, image: PresentationResourcesItemList.createGroupIcon(state.presentationData.theme), appearance: .action, selected: false, presentationData: state.presentationData))
default:
break
}
}
}
}
}
if result.count >= 1, case .HoleEntry = result[result.count - 1] {
return ([.HeaderEntry], true)
} else if result.count == 1, case .HoleEntry = result[0] {
return ([.HeaderEntry], true)
}
return (result, view.isLoading)
}
@@ -0,0 +1,419 @@
import Foundation
import Postbox
import TelegramCore
import SwiftSignalKit
import Display
import TelegramUIPreferences
import AccountContext
public enum ChatListNodeLocation: Equatable {
case initial(count: Int, filter: ChatListFilter?)
case navigation(index: EngineChatList.Item.Index, filter: ChatListFilter?)
case scroll(index: EngineChatList.Item.Index, sourceIndex: EngineChatList.Item.Index, scrollPosition: ListViewScrollPosition, animated: Bool, filter: ChatListFilter?)
public var filter: ChatListFilter? {
switch self {
case let .initial(_, filter):
return filter
case let .navigation(_, filter):
return filter
case let .scroll(_, _, _, _, filter):
return filter
}
}
}
public struct ChatListNodeViewUpdate {
public let list: EngineChatList
public let type: ViewUpdateType
public let scrollPosition: ChatListNodeViewScrollPosition?
public init(list: EngineChatList, type: ViewUpdateType, scrollPosition: ChatListNodeViewScrollPosition?) {
self.list = list
self.type = type
self.scrollPosition = scrollPosition
}
}
public func chatListFilterPredicate(filter: ChatListFilterData, accountPeerId: EnginePeer.Id) -> ChatListFilterPredicate {
var includePeers = Set(filter.includePeers.peers)
var excludePeers = Set(filter.excludePeers)
if !filter.includePeers.pinnedPeers.isEmpty {
includePeers.subtract(filter.includePeers.pinnedPeers)
excludePeers.subtract(filter.includePeers.pinnedPeers)
}
var includeAdditionalPeerGroupIds: [PeerGroupId] = []
if !filter.excludeArchived {
includeAdditionalPeerGroupIds.append(Namespaces.PeerGroup.archive)
}
var messageTagSummary: ChatListMessageTagSummaryResultCalculation?
if filter.excludeRead || filter.excludeMuted {
messageTagSummary = ChatListMessageTagSummaryResultCalculation(addCount: ChatListMessageTagSummaryResultComponent(tag: .unseenPersonalMessage, namespace: Namespaces.Message.Cloud), subtractCount: ChatListMessageTagActionsSummaryResultComponent(type: PendingMessageActionType.consumeUnseenPersonalMessage, namespace: Namespaces.Message.Cloud))
}
return ChatListFilterPredicate(includePeerIds: includePeers, excludePeerIds: excludePeers, pinnedPeerIds: filter.includePeers.pinnedPeers, messageTagSummary: messageTagSummary, includeAdditionalPeerGroupIds: includeAdditionalPeerGroupIds, include: { peer, isMuted, isUnread, isContact, messageTagSummaryResult in
if filter.excludeRead {
var effectiveUnread = isUnread
if let messageTagSummaryResult = messageTagSummaryResult, messageTagSummaryResult {
effectiveUnread = true
}
if !effectiveUnread {
return false
}
}
if filter.excludeMuted {
if isMuted {
if let messageTagSummaryResult = messageTagSummaryResult, messageTagSummaryResult {
} else {
return false
}
}
}
if !filter.categories.contains(.contacts) && isContact {
if let user = peer as? TelegramUser {
if user.botInfo == nil && !user.flags.contains(.isSupport) {
return false
}
} else if let _ = peer as? TelegramSecretChat {
return false
}
}
if !filter.categories.contains(.nonContacts) && (!isContact && peer.id != accountPeerId) {
if let user = peer as? TelegramUser {
if user.botInfo == nil {
return false
}
} else if let _ = peer as? TelegramSecretChat {
return false
}
}
if filter.categories.contains(.nonContacts) && peer.id == accountPeerId {
return false
}
if !filter.categories.contains(.bots) {
if let user = peer as? TelegramUser {
if user.botInfo != nil || user.flags.contains(.isSupport) {
return false
}
}
}
if !filter.categories.contains(.groups) {
if let _ = peer as? TelegramGroup {
return false
} else if let channel = peer as? TelegramChannel {
if case .group = channel.info {
return false
}
}
}
if !filter.categories.contains(.channels) {
if let channel = peer as? TelegramChannel {
if case .broadcast = channel.info {
return false
}
}
}
return true
})
}
public func chatListViewForLocation(chatListLocation: ChatListControllerLocation, location: ChatListNodeLocation, account: Account, shouldLoadCanMessagePeer: Bool) -> Signal<ChatListNodeViewUpdate, NoError> {
let accountPeerId = account.peerId
switch chatListLocation {
case let .chatList(groupId):
let filterPredicate: ChatListFilterPredicate?
if let filter = location.filter, case let .filter(_, _, _, data) = filter {
filterPredicate = chatListFilterPredicate(filter: data, accountPeerId: account.peerId)
} else {
filterPredicate = nil
}
switch location {
case let .initial(count, _):
let signal: Signal<(ChatListView, ViewUpdateType), NoError>
signal = account.viewTracker.tailChatListView(groupId: groupId._asGroup(), filterPredicate: filterPredicate, count: count, shouldLoadCanMessagePeer: shouldLoadCanMessagePeer)
return signal
|> map { view, updateType -> ChatListNodeViewUpdate in
return ChatListNodeViewUpdate(list: EngineChatList(view, accountPeerId: accountPeerId), type: updateType, scrollPosition: nil)
}
case let .navigation(index, _):
guard case let .chatList(index) = index else {
return .never()
}
var first = true
return account.viewTracker.aroundChatListView(groupId: groupId._asGroup(), filterPredicate: filterPredicate, index: index, count: 80, shouldLoadCanMessagePeer: shouldLoadCanMessagePeer)
|> map { view, updateType -> ChatListNodeViewUpdate in
let genericType: ViewUpdateType
if first {
first = false
genericType = ViewUpdateType.UpdateVisible
} else {
genericType = updateType
}
return ChatListNodeViewUpdate(list: EngineChatList(view, accountPeerId: accountPeerId), type: genericType, scrollPosition: nil)
}
case let .scroll(index, sourceIndex, scrollPosition, animated, _):
guard case let .chatList(index) = index else {
return .never()
}
let directionHint: ListViewScrollToItemDirectionHint = sourceIndex > .chatList(index) ? .Down : .Up
let chatScrollPosition: ChatListNodeViewScrollPosition = .index(index: index, position: scrollPosition, directionHint: directionHint, animated: animated)
var first = true
return account.viewTracker.aroundChatListView(groupId: groupId._asGroup(), filterPredicate: filterPredicate, index: index, count: 80, shouldLoadCanMessagePeer: shouldLoadCanMessagePeer)
|> map { view, updateType -> ChatListNodeViewUpdate in
let genericType: ViewUpdateType
let scrollPosition: ChatListNodeViewScrollPosition? = first ? chatScrollPosition : nil
if first {
first = false
genericType = ViewUpdateType.UpdateVisible
} else {
genericType = updateType
}
return ChatListNodeViewUpdate(list: EngineChatList(view, accountPeerId: accountPeerId), type: genericType, scrollPosition: scrollPosition)
}
}
case let .forum(peerId):
let viewKey: PostboxViewKey = .messageHistoryThreadIndex(
id: peerId,
summaryComponents: ChatListEntrySummaryComponents(
components: [
ChatListEntryMessageTagSummaryKey(
tag: .unseenPersonalMessage,
actionType: PendingMessageActionType.consumeUnseenPersonalMessage
): ChatListEntrySummaryComponents.Component(
tagSummary: ChatListEntryMessageTagSummaryComponent(namespace: Namespaces.Message.Cloud),
actionsSummary: ChatListEntryPendingMessageActionsSummaryComponent(namespace: Namespaces.Message.Cloud)
),
ChatListEntryMessageTagSummaryKey(
tag: .unseenReaction,
actionType: PendingMessageActionType.readReaction
): ChatListEntrySummaryComponents.Component(
tagSummary: ChatListEntryMessageTagSummaryComponent(namespace: Namespaces.Message.Cloud),
actionsSummary: ChatListEntryPendingMessageActionsSummaryComponent(namespace: Namespaces.Message.Cloud)
)
]
)
)
let readStateKey: PostboxViewKey = .combinedReadState(peerId: peerId, handleThreads: false)
var isFirst = false
return account.postbox.combinedView(keys: [viewKey, readStateKey])
|> map { views -> ChatListNodeViewUpdate in
guard let view = views.views[viewKey] as? MessageHistoryThreadIndexView else {
preconditionFailure()
}
guard let readStateView = views.views[readStateKey] as? CombinedReadStateView else {
preconditionFailure()
}
var maxReadId: Int32 = 0
if let state = readStateView.state?.states.first(where: { $0.0 == Namespaces.Message.Cloud }) {
if case let .idBased(maxIncomingReadId, _, _, _, _) = state.1 {
maxReadId = maxIncomingReadId
}
}
var items: [EngineChatList.Item] = []
for item in view.items {
guard let peer = view.peer else {
continue
}
guard let data = item.info.get(MessageHistoryThreadData.self) else {
continue
}
let defaultPeerNotificationSettings: TelegramPeerNotificationSettings = (view.peerNotificationSettings as? TelegramPeerNotificationSettings) ?? .defaultSettings
var hasUnseenMentions = false
var isMuted = false
switch data.notificationSettings.muteState {
case .muted:
isMuted = true
case .unmuted:
isMuted = false
case .default:
if case .default = data.notificationSettings.muteState {
if case .muted = defaultPeerNotificationSettings.muteState {
isMuted = true
}
}
}
if let info = item.tagSummaryInfo[ChatListEntryMessageTagSummaryKey(
tag: .unseenPersonalMessage,
actionType: PendingMessageActionType.consumeUnseenPersonalMessage
)] {
hasUnseenMentions = (info.tagSummaryCount ?? 0) > (info.actionsSummaryCount ?? 0)
}
var hasUnseenReactions = false
if let info = item.tagSummaryInfo[ChatListEntryMessageTagSummaryKey(
tag: .unseenReaction,
actionType: PendingMessageActionType.readReaction
)] {
hasUnseenReactions = (info.tagSummaryCount ?? 0) != 0// > (info.actionsSummaryCount ?? 0)
}
let pinnedIndex: EngineChatList.Item.PinnedIndex
if let index = item.pinnedIndex {
pinnedIndex = .index(index)
} else {
pinnedIndex = .none
}
var topicMaxIncomingReadId = data.maxIncomingReadId
if data.maxIncomingReadId == 0 && maxReadId != 0 && Int64(maxReadId) <= item.id {
topicMaxIncomingReadId = max(topicMaxIncomingReadId, maxReadId)
}
let readCounters = EnginePeerReadCounters(state: CombinedPeerReadState(states: [(Namespaces.Message.Cloud, .idBased(maxIncomingReadId: topicMaxIncomingReadId, maxOutgoingReadId: data.maxOutgoingReadId, maxKnownId: 1, count: data.incomingUnreadCount, markedUnread: false))]), isMuted: false)
var draft: EngineChatList.Draft?
if let embeddedState = item.embeddedInterfaceState, let _ = embeddedState.overrideChatTimestamp {
if let opaqueState = _internal_decodeStoredChatInterfaceState(state: embeddedState) {
if let text = opaqueState.synchronizeableInputState?.text {
draft = EngineChatList.Draft(text: text, entities: opaqueState.synchronizeableInputState?.entities ?? [])
}
}
}
items.append(EngineChatList.Item(
id: .forum(item.id),
index: .forum(pinnedIndex: pinnedIndex, timestamp: item.index.timestamp, threadId: item.id, namespace: item.index.id.namespace, id: item.index.id.id),
messages: item.topMessage.flatMap { [EngineMessage($0)] } ?? [],
readCounters: readCounters,
isMuted: isMuted,
draft: draft,
threadData: data,
renderedPeer: EngineRenderedPeer(peer: EnginePeer(peer)),
presence: nil,
hasUnseenMentions: hasUnseenMentions,
hasUnseenReactions: hasUnseenReactions,
forumTopicData: nil,
topForumTopicItems: [],
hasFailed: false,
isContact: false,
autoremoveTimeout: nil,
storyStats: nil,
displayAsTopicList: false,
isPremiumRequiredToMessage: false,
mediaDraftContentType: nil
))
}
let list = EngineChatList(
items: items.reversed(),
groupItems: [],
additionalItems: [],
hasEarlier: false,
hasLater: false,
isLoading: view.isLoading
)
let type: ViewUpdateType
if isFirst {
type = .Initial
} else {
type = .Generic
}
isFirst = false
return ChatListNodeViewUpdate(list: list, type: type, scrollPosition: nil)
}
case let .savedMessagesChats(peerId):
let viewKey: PostboxViewKey = .savedMessagesIndex(peerId: peerId)
let interfaceStateKey: PostboxViewKey = .chatInterfaceState(peerId: peerId)
var isFirst = true
return account.postbox.combinedView(keys: [viewKey, interfaceStateKey])
|> map { views -> ChatListNodeViewUpdate in
guard let view = views.views[viewKey] as? MessageHistorySavedMessagesIndexView else {
preconditionFailure()
}
var draft: EngineChatList.Draft?
if let interfaceStateView = views.views[interfaceStateKey] as? ChatInterfaceStateView {
if let embeddedState = interfaceStateView.value, let _ = embeddedState.overrideChatTimestamp {
if let opaqueState = _internal_decodeStoredChatInterfaceState(state: embeddedState) {
if let text = opaqueState.synchronizeableInputState?.text {
draft = EngineChatList.Draft(text: text, entities: opaqueState.synchronizeableInputState?.entities ?? [])
}
}
}
}
var items: [EngineChatList.Item] = []
for item in view.items {
guard let sourcePeer = item.peer else {
continue
}
let sourceId = PeerId(item.id)
var messages: [EngineMessage] = []
if let topMessage = item.topMessage {
messages.append(EngineMessage(topMessage))
}
let mappedMessageIndex = MessageIndex(id: MessageId(peerId: sourceId, namespace: item.index.id.namespace, id: item.index.id.id), timestamp: item.index.timestamp)
let readCounters = EnginePeerReadCounters(state: CombinedPeerReadState(states: [(Namespaces.Message.Cloud, .idBased(maxIncomingReadId: 0, maxOutgoingReadId: 0, maxKnownId: 0, count: Int32(item.unreadCount), markedUnread: item.markedUnread))]), isMuted: false)
var itemDraft: EngineChatList.Draft?
if let embeddedState = item.embeddedInterfaceState, let _ = embeddedState.overrideChatTimestamp {
if let opaqueState = _internal_decodeStoredChatInterfaceState(state: embeddedState) {
if let text = opaqueState.synchronizeableInputState?.text {
itemDraft = EngineChatList.Draft(text: text, entities: opaqueState.synchronizeableInputState?.entities ?? [])
}
}
}
items.append(EngineChatList.Item(
id: .chatList(sourceId),
index: .chatList(ChatListIndex(pinningIndex: item.pinnedIndex.flatMap(UInt16.init), messageIndex: mappedMessageIndex)),
messages: messages,
readCounters: readCounters,
isMuted: false,
draft: sourceId == accountPeerId ? draft : itemDraft,
threadData: nil,
renderedPeer: EngineRenderedPeer(peer: EnginePeer(sourcePeer)),
presence: nil,
hasUnseenMentions: false,
hasUnseenReactions: false,
forumTopicData: nil,
topForumTopicItems: [],
hasFailed: false,
isContact: false,
autoremoveTimeout: nil,
storyStats: nil,
displayAsTopicList: false,
isPremiumRequiredToMessage: false,
mediaDraftContentType: nil
))
}
let list = EngineChatList(
items: items.reversed(),
groupItems: [],
additionalItems: [],
hasEarlier: false,
hasLater: false,
isLoading: view.isLoading
)
let type: ViewUpdateType
if isFirst {
type = .Initial
} else {
type = .Generic
}
isFirst = false
return ChatListNodeViewUpdate(list: list, type: type, scrollPosition: nil)
}
}
}
@@ -0,0 +1,24 @@
import Foundation
import UIKit
import TelegramPresentationData
import TelegramUIPreferences
public final class ChatListPresentationData {
public let theme: PresentationTheme
public let fontSize: PresentationFontSize
public let strings: PresentationStrings
public let dateTimeFormat: PresentationDateTimeFormat
public let nameSortOrder: PresentationPersonNameOrder
public let nameDisplayOrder: PresentationPersonNameOrder
public let disableAnimations: Bool
public init(theme: PresentationTheme, fontSize: PresentationFontSize, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, disableAnimations: Bool) {
self.theme = theme
self.fontSize = fontSize
self.strings = strings
self.dateTimeFormat = dateTimeFormat
self.nameSortOrder = nameSortOrder
self.nameDisplayOrder = nameDisplayOrder
self.disableAnimations = disableAnimations
}
}
@@ -0,0 +1,480 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import LegacyComponents
import RadialStatusNode
enum ChatListStatusNodeState: Equatable {
case none
case clock(UIImage?, UIImage?)
case delivered(UIColor)
case read(UIColor)
case progress(UIColor, CGFloat)
case failed(UIColor, UIColor)
func contentNode() -> ChatListStatusContentNode? {
switch self {
case .none:
return nil
case let .clock(frameImage, minImage):
return ChatListStatusClockNode(frameImage: frameImage, minImage: minImage)
case let .delivered(color):
return ChatListStatusChecksNode(color: color)
case let .read(color):
return ChatListStatusChecksNode(color: color)
case let .progress(color, progress):
return ChatListStatusProgressNode(color: color, progress: progress)
case let .failed(fill, foreground):
return ChatListStatusFailedNode(fill: fill, foreground: foreground)
}
}
}
private let transitionDuration = 0.2
class ChatListStatusContentNode: ASDisplayNode {
var fontSize: CGFloat = 17.0
override init() {
super.init()
self.isOpaque = false
}
func updateWithState(_ state: ChatListStatusNodeState, animated: Bool) {
}
func animateOut(to: ChatListStatusNodeState, completion: @escaping () -> Void) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: transitionDuration, removeOnCompletion: false, completion: { _ in
completion()
})
}
func animateIn(from: ChatListStatusNodeState) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: transitionDuration)
}
}
final class ChatListStatusNode: ASDisplayNode {
private(set) var state: ChatListStatusNodeState = .none
var fontSize: CGFloat = 17.0 {
didSet {
self.contentNode?.fontSize = self.fontSize
self.nextContentNode?.fontSize = self.fontSize
}
}
private var contentNode: ChatListStatusContentNode?
private var nextContentNode: ChatListStatusContentNode?
public func transitionToState(_ state: ChatListStatusNodeState, animated: Bool = false, completion: @escaping () -> Void = {}) -> Bool {
if self.state != state {
let currentState = self.state
self.state = state
let contentNode = state.contentNode()
contentNode?.fontSize = self.fontSize
if contentNode?.classForCoder != self.contentNode?.classForCoder {
contentNode?.updateWithState(state, animated: animated)
self.transitionToContentNode(contentNode, state: state, fromState: currentState, animated: animated, completion: completion)
} else {
self.contentNode?.updateWithState(state, animated: animated)
}
return true
} else {
completion()
return false
}
}
private func transitionToContentNode(_ node: ChatListStatusContentNode?, state: ChatListStatusNodeState, fromState: ChatListStatusNodeState, animated: Bool, completion: @escaping () -> Void) {
if let previousContentNode = self.contentNode {
if !animated {
previousContentNode.removeFromSupernode()
self.contentNode = node
if let contentNode = self.contentNode {
self.addSubnode(contentNode)
}
} else {
self.contentNode = node
if let contentNode = self.contentNode {
self.addSubnode(contentNode)
contentNode.frame = self.bounds
if self.isNodeLoaded {
contentNode.animateIn(from: fromState)
contentNode.layout()
}
}
previousContentNode.animateOut(to: state) {
previousContentNode.removeFromSupernode()
}
}
} else {
self.contentNode = node
if let contentNode = self.contentNode {
contentNode.frame = self.bounds
self.addSubnode(contentNode)
if self.isNodeLoaded {
contentNode.layout()
}
}
}
}
override public func layout() {
if let contentNode = self.contentNode {
contentNode.frame = self.bounds
}
}
}
class ChatListStatusClockNode: ChatListStatusContentNode {
private var clockFrameNode: ASImageNode
private var clockMinNode: ASImageNode
init(frameImage: UIImage?, minImage: UIImage?) {
self.clockFrameNode = ASImageNode()
self.clockMinNode = ASImageNode()
super.init()
self.clockFrameNode.image = frameImage
self.clockMinNode.image = minImage
self.addSubnode(self.clockFrameNode)
self.addSubnode(self.clockMinNode)
}
override func updateWithState(_ state: ChatListStatusNodeState, animated: Bool) {
if case let .clock(frameImage, minImage) = state {
self.clockFrameNode.image = frameImage
self.clockMinNode.image = minImage
}
}
override func didEnterHierarchy() {
super.didEnterHierarchy()
maybeAddRotationAnimation(self.clockFrameNode.layer, duration: 6.0)
maybeAddRotationAnimation(self.clockMinNode.layer, duration: 1.0)
}
override func didExitHierarchy() {
super.didExitHierarchy()
self.clockFrameNode.layer.removeAllAnimations()
self.clockMinNode.layer.removeAllAnimations()
}
override func layout() {
super.layout()
let bounds = self.bounds
if let frameImage = self.clockFrameNode.image {
self.clockFrameNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.width - frameImage.size.width) / 2.0), y: floorToScreenPixels((bounds.height - frameImage.size.height) / 2.0)), size: frameImage.size)
}
if let minImage = self.clockMinNode.image {
self.clockMinNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.width - minImage.size.width) / 2.0), y: floorToScreenPixels((bounds.height - minImage.size.height) / 2.0)), size: minImage.size)
}
}
}
private func maybeAddRotationAnimation(_ layer: CALayer, duration: Double) {
if let _ = layer.animation(forKey: "clockFrameAnimation") {
return
}
let basicAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
basicAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut)
basicAnimation.duration = duration
basicAnimation.fromValue = NSNumber(value: Float(0.0))
basicAnimation.toValue = NSNumber(value: Float(Double.pi * 2.0))
basicAnimation.repeatCount = Float.infinity
basicAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
basicAnimation.beginTime = 1.0
layer.add(basicAnimation, forKey: "clockFrameAnimation")
}
private final class StatusChecksNodeParameters: NSObject {
let color: UIColor
let progress: CGFloat
let fontSize: CGFloat
init(color: UIColor, progress: CGFloat, fontSize: CGFloat) {
self.color = color
self.progress = progress
self.fontSize = fontSize
super.init()
}
}
private class ChatListStatusChecksNode: ChatListStatusContentNode {
private var state: ChatListStatusNodeState?
var color: UIColor {
didSet {
self.setNeedsDisplay()
}
}
private var effectiveProgress: CGFloat = 1.0 {
didSet {
self.setNeedsDisplay()
}
}
override var fontSize: CGFloat {
didSet {
self.setNeedsDisplay()
}
}
init(color: UIColor) {
self.color = color
super.init()
}
func animateProgress(from: CGFloat, to: CGFloat) {
self.pop_removeAllAnimations()
let animation = POPBasicAnimation()
animation.property = (POPAnimatableProperty.property(withName: "progress", initializer: { property in
property?.readBlock = { node, values in
values?.pointee = (node as! ChatListStatusChecksNode).effectiveProgress
}
property?.writeBlock = { node, values in
(node as! ChatListStatusChecksNode).effectiveProgress = values!.pointee
}
property?.threshold = 0.01
}) as! POPAnimatableProperty)
animation.fromValue = from as NSNumber
animation.toValue = to as NSNumber
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
animation.duration = 0.2
self.pop_add(animation, forKey: "progress")
}
override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
return StatusChecksNodeParameters(color: self.color, progress: self.effectiveProgress, fontSize: self.fontSize)
}
override func didEnterHierarchy() {
super.didEnterHierarchy()
}
@objc override class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
if !isRasterizing {
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fill(bounds)
}
guard let parameters = parameters as? StatusChecksNodeParameters else {
return
}
let scaleFactor = min(1.4, parameters.fontSize / 17.0)
context.translateBy(x: bounds.width / 2.0, y: bounds.height / 2.0)
context.scaleBy(x: scaleFactor, y: scaleFactor)
context.translateBy(x: -bounds.width / 2.0, y: -bounds.height / 2.0)
let progress = parameters.progress
context.setStrokeColor(parameters.color.cgColor)
context.setLineWidth(1.0 + UIScreenPixel)
context.setLineCap(.round)
context.setLineJoin(.round)
context.setMiterLimit(10.0)
context.saveGState()
var s1 = CGPoint(x: 9.0, y: 13.0)
var s2 = CGPoint(x: 5.0, y: 13.0)
let p1 = CGPoint(x: 3.5, y: 3.5)
let p2 = CGPoint(x: 7.5 - UIScreenPixel, y: -8.0)
let check1FirstSegment: CGFloat = max(0.0, min(1.0, progress * 3.0))
let check2FirstSegment: CGFloat = max(0.0, min(1.0, (progress - 1.0) * 3.0))
let firstProgress = max(0.0, min(1.0, progress))
let secondProgress = max(0.0, min(1.0, progress - 1.0))
let scale: CGFloat = 1.2
context.translateBy(x: 16.0, y: 13.0)
context.scaleBy(x: scale - abs((scale - 1.0) * (firstProgress - 0.5) / 0.5), y: scale - abs((scale - 1.0) * (firstProgress - 0.5) / 0.5))
s1 = s1.offsetBy(dx: -16.0, dy: -13.0)
if !check1FirstSegment.isZero {
if check1FirstSegment < 1.0 {
context.move(to: CGPoint(x: s1.x + p1.x * check1FirstSegment, y: s1.y + p1.y * check1FirstSegment))
context.addLine(to: s1)
} else {
let secondSegment = (min(1.0, progress) - 0.33) * 1.5
context.move(to: CGPoint(x: s1.x + p1.x + p2.x * secondSegment, y: s1.y + p1.y + p2.y * secondSegment))
context.addLine(to: CGPoint(x: s1.x + p1.x, y: s1.y + p1.y))
context.addLine(to: CGPoint(x: s1.x + p1.x * min(1.0, check2FirstSegment), y: s1.y + p1.y * min(1.0, check2FirstSegment)))
}
}
context.strokePath()
context.restoreGState()
context.translateBy(x: 12.0, y: 13.0)
context.scaleBy(x: scale - abs((scale - 1.0) * (secondProgress - 0.5) / 0.5), y: scale - abs((scale - 1.0) * (secondProgress - 0.5) / 0.5))
s2 = s2.offsetBy(dx: -12.0, dy: -13.0)
if !check2FirstSegment.isZero {
if check2FirstSegment < 1.0 {
context.move(to: CGPoint(x: s2.x + p1.x * check2FirstSegment, y: s2.y + p1.y * check2FirstSegment))
context.addLine(to: s2)
} else {
let secondSegment = (max(0.0, (progress - 1.0)) - 0.33) * 1.5
context.move(to: CGPoint(x: s2.x + p1.x + p2.x * secondSegment, y: s2.y + p1.y + p2.y * secondSegment))
context.addLine(to: CGPoint(x: s2.x + p1.x, y: s2.y + p1.y))
context.addLine(to: s2)
}
}
context.strokePath()
}
override func updateWithState(_ state: ChatListStatusNodeState, animated: Bool) {
switch state {
case let .delivered(color), let .read(color):
self.color = color
default:
break
}
var animating = false
if let previousState = self.state, case .delivered = previousState, case .read = state, animated {
animating = true
self.animateProgress(from: 1.0, to: 2.0)
}
if !animating {
if case .delivered = state {
self.effectiveProgress = 1.0
} else if case .read = state {
self.effectiveProgress = 2.0
}
}
self.state = state
}
override func animateIn(from: ChatListStatusNodeState) {
if let state = self.state, case .delivered = state {
self.animateProgress(from: 0.0, to: 1.0)
} else {
super.animateIn(from: from)
}
}
}
private final class ChatListStatusFailedNodeParameters: NSObject {
let fill: UIColor
let foreground: UIColor
init(fill: UIColor, foreground: UIColor) {
self.fill = fill
self.foreground = foreground
super.init()
}
}
private class ChatListStatusFailedNode: ChatListStatusContentNode {
private var state: ChatListStatusNodeState?
var fill: UIColor {
didSet {
self.setNeedsDisplay()
}
}
var foreground: UIColor {
didSet {
self.setNeedsDisplay()
}
}
init(fill: UIColor, foreground: UIColor) {
self.fill = fill
self.foreground = foreground
super.init()
}
override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? {
return ChatListStatusFailedNodeParameters(fill: self.fill, foreground: self.foreground)
}
@objc override class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) {
let context = UIGraphicsGetCurrentContext()!
if !isRasterizing {
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fill(bounds)
}
guard let parameters = parameters as? ChatListStatusFailedNodeParameters else {
return
}
let diameter: CGFloat = 14.0
let rect = CGRect(origin: CGPoint(x: floor((bounds.width - diameter) / 2.0), y: floor((bounds.height - diameter) / 2.0)), size: CGSize(width: diameter, height: diameter)).offsetBy(dx: 1.0, dy: UIScreenPixel)
context.setFillColor(parameters.fill.cgColor)
context.fillEllipse(in: rect)
context.setStrokeColor(parameters.foreground.cgColor)
let string = NSAttributedString(string: "!", font: Font.medium(12.0), textColor: parameters.foreground)
let stringRect = string.boundingRect(with: rect.size, options: .usesLineFragmentOrigin, context: nil)
UIGraphicsPushContext(context)
string.draw(at: CGPoint(x: rect.minX + floor((rect.width - stringRect.width) / 2.0), y: 1.0 - UIScreenPixel + rect.minY + floor((rect.height - stringRect.height) / 2.0)))
UIGraphicsPopContext()
}
override func updateWithState(_ state: ChatListStatusNodeState, animated: Bool) {
switch state {
case let .failed(fill, foreground):
self.fill = fill
self.foreground = foreground
default:
break
}
self.state = state
}
}
private class ChatListStatusProgressNode: ChatListStatusContentNode {
private let statusNode: RadialStatusNode
init(color: UIColor, progress: CGFloat) {
self.statusNode = RadialStatusNode(backgroundNodeColor: .clear)
super.init()
self.statusNode.transitionToState(.progress(color: color, lineWidth: 1.0, value: progress, cancelEnabled: false, animateRotation: true))
self.addSubnode(self.statusNode)
}
override func updateWithState(_ state: ChatListStatusNodeState, animated: Bool) {
if case let .progress(color, progress) = state {
self.statusNode.transitionToState(.progress(color: color, lineWidth: 1.0, value: progress, cancelEnabled: false, animateRotation: true), animated: animated, completion: {})
}
}
override func layout() {
super.layout()
let bounds = self.bounds
let size = CGSize(width: 12.0, height: 12.0)
self.statusNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.width - size.width) / 2.0), y: floorToScreenPixels((bounds.height - size.height) / 2.0)), size: size)
}
}
@@ -0,0 +1,166 @@
import Foundation
import UIKit
import AsyncDisplayKit
import TelegramCore
import Display
import SwiftSignalKit
import TelegramPresentationData
import ChatTitleActivityNode
import LocalizedPeerData
final class ChatListInputActivitiesNode: ASDisplayNode {
private let activityNode: ChatTitleActivityNode
override init() {
self.activityNode = ChatTitleActivityNode()
super.init()
self.addSubnode(self.activityNode)
}
func asyncLayout() -> (CGSize, ChatListPresentationData, UIColor, EnginePeer.Id?, [(EnginePeer, PeerInputActivity)]) -> (CGSize, () -> Void) {
return { [weak self] boundingSize, presentationData, color, peerId, activities in
let strings = presentationData.strings
let textFont = Font.regular(floor(presentationData.fontSize.itemListBaseFontSize * 15.0 / 17.0))
var state = ChatTitleActivityNodeState.none
if !activities.isEmpty {
var commonKey: Int32? = activities[0].1.key
for i in 1 ..< activities.count {
if activities[i].1.key != commonKey {
commonKey = nil
break
}
}
let lightColor = color.withAlphaComponent(0.85)
if activities.count == 1 {
if activities[0].0.id == peerId {
let text: String
switch activities[0].1 {
case .uploadingVideo:
text = strings.Activity_UploadingVideo
case .uploadingInstantVideo:
text = strings.Activity_UploadingVideoMessage
case .uploadingPhoto:
text = strings.Activity_UploadingPhoto
case .uploadingFile:
text = strings.Activity_UploadingDocument
case .recordingVoice:
text = strings.Activity_RecordingAudio
case .recordingInstantVideo:
text = strings.Activity_RecordingVideoMessage
case .playingGame:
text = strings.Activity_PlayingGame
case .typingText:
text = strings.DialogList_Typing
case .choosingSticker:
text = strings.Activity_ChoosingSticker
case let .interactingWithEmoji(emoticon, _, _):
text = strings.Activity_TappingInteractiveEmoji(emoticon).string
case .speakingInGroupCall, .seeingEmojiInteraction:
text = ""
}
let string = NSAttributedString(string: text, font: textFont, textColor: color)
switch activities[0].1 {
case .typingText:
state = .typingText(string, lightColor)
case .recordingVoice:
state = .recordingVoice(string, lightColor)
case .recordingInstantVideo:
state = .recordingVideo(string, lightColor)
case .uploadingFile, .uploadingInstantVideo, .uploadingPhoto, .uploadingVideo:
state = .uploading(string, lightColor)
case .playingGame:
state = .playingGame(string, lightColor)
case .speakingInGroupCall:
state = .typingText(string, lightColor)
case .choosingSticker:
state = .choosingSticker(string, lightColor)
case .interactingWithEmoji:
state = .interactingWithEmoji(string, lightColor)
case .seeingEmojiInteraction:
state = .none
}
} else {
let text: String
if let _ = commonKey {
let peerTitle = activities[0].0.compactDisplayTitle
switch activities[0].1 {
case .uploadingVideo:
text = strings.DialogList_SingleUploadingVideoSuffix(peerTitle).string
case .uploadingInstantVideo:
text = strings.DialogList_SingleUploadingVideoSuffix(peerTitle).string
case .uploadingPhoto:
text = strings.DialogList_SingleUploadingPhotoSuffix(peerTitle).string
case .uploadingFile:
text = strings.DialogList_SingleUploadingFileSuffix(peerTitle).string
case .recordingVoice:
text = strings.DialogList_SingleRecordingAudioSuffix(peerTitle).string
case .recordingInstantVideo:
text = strings.DialogList_SingleRecordingVideoMessageSuffix(peerTitle).string
case .playingGame:
text = strings.DialogList_SinglePlayingGameSuffix(peerTitle).string
case .typingText:
text = strings.DialogList_SingleTypingSuffix(peerTitle).string
case .choosingSticker:
text = strings.DialogList_SingleChoosingStickerSuffix(peerTitle).string
case .speakingInGroupCall, .seeingEmojiInteraction, .interactingWithEmoji:
text = ""
}
} else {
text = activities[0].0.compactDisplayTitle
}
let string = NSAttributedString(string: text, font: textFont, textColor: color)
switch activities[0].1 {
case .typingText:
state = .typingText(string, lightColor)
case .recordingVoice:
state = .recordingVoice(string, lightColor)
case .recordingInstantVideo:
state = .recordingVideo(string, lightColor)
case .uploadingFile, .uploadingInstantVideo, .uploadingPhoto, .uploadingVideo:
state = .uploading(string, lightColor)
case .playingGame:
state = .playingGame(string, lightColor)
case .speakingInGroupCall:
state = .typingText(string, lightColor)
case .choosingSticker:
state = .choosingSticker(string, lightColor)
case .seeingEmojiInteraction, .interactingWithEmoji:
state = .none
}
}
} else {
let string: NSAttributedString
if activities.count > 1 {
let peerTitle = activities[0].0.compactDisplayTitle
if activities.count == 2 {
let secondPeerTitle = activities[1].0.compactDisplayTitle
string = NSAttributedString(string: strings.DialogList_MultipleTypingPair(peerTitle, secondPeerTitle).string, font: textFont, textColor: color)
} else {
string = NSAttributedString(string: strings.DialogList_MultipleTyping(peerTitle, strings.DialogList_MultipleTypingSuffix(activities.count - 1).string).string, font: textFont, textColor: color)
}
} else {
string = NSAttributedString(string: strings.DialogList_MultipleTypingSuffix(activities.count).string, font: textFont, textColor: color)
}
state = .typingText(string, lightColor)
}
}
return (boundingSize, {
if let strongSelf = self {
let _ = strongSelf.activityNode.transitionToState(state, animation: .none)
let size = strongSelf.activityNode.updateLayout(CGSize(width: boundingSize.width - 12.0, height: boundingSize.height), alignment: .left)
strongSelf.activityNode.frame = CGRect(origin: CGPoint(x: -3.0, y: 1.0), size: size)
}
})
}
}
}
@@ -0,0 +1,222 @@
import Foundation
import Postbox
import TelegramCore
import SwiftSignalKit
import Display
import MergeLists
import SearchUI
import TelegramUIPreferences
struct ChatListNodeView {
let originalList: EngineChatList
let filteredEntries: [ChatListNodeEntry]
let isLoading: Bool
let filter: ChatListFilter?
}
enum ChatListNodeViewTransitionReason {
case initial
case interactiveChanges
case holeChanges
case reload
}
struct ChatListNodeViewTransitionInsertEntry {
let index: Int
let previousIndex: Int?
let entry: ChatListNodeEntry
let directionHint: ListViewItemOperationDirectionHint?
}
struct ChatListNodeViewTransitionUpdateEntry {
let index: Int
let previousIndex: Int
let entry: ChatListNodeEntry
let directionHint: ListViewItemOperationDirectionHint?
}
struct ChatListNodeViewTransition {
let chatListView: ChatListNodeView
let deleteItems: [ListViewDeleteItem]
let insertEntries: [ChatListNodeViewTransitionInsertEntry]
let updateEntries: [ChatListNodeViewTransitionUpdateEntry]
let options: ListViewDeleteAndInsertOptions
let scrollToItem: ListViewScrollToItem?
let stationaryItemRange: (Int, Int)?
let adjustScrollToFirstItem: Bool
let animateCrossfade: Bool
}
public enum ChatListNodeViewScrollPosition {
case index(index: ChatListIndex, position: ListViewScrollPosition, directionHint: ListViewScrollToItemDirectionHint, animated: Bool)
}
func preparedChatListNodeViewTransition(from fromView: ChatListNodeView?, to toView: ChatListNodeView, reason: ChatListNodeViewTransitionReason, previewing: Bool, disableAnimations: Bool, account: Account, scrollPosition: ChatListNodeViewScrollPosition?, searchMode: Bool, forceAllUpdated: Bool) -> Signal<ChatListNodeViewTransition, NoError> {
return Signal<ChatListNodeViewTransition, NoError> { subscriber in
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromView?.filteredEntries ?? [], rightList: toView.filteredEntries, allUpdated: forceAllUpdated)
var adjustedDeleteIndices: [ListViewDeleteItem] = []
let previousCount: Int
if let fromView = fromView {
previousCount = fromView.filteredEntries.count
} else {
previousCount = 0
}
for index in deleteIndices {
adjustedDeleteIndices.append(ListViewDeleteItem(index: previousCount - 1 - index, directionHint: nil))
}
var adjustedIndicesAndItems: [ChatListNodeViewTransitionInsertEntry] = []
var adjustedUpdateItems: [ChatListNodeViewTransitionUpdateEntry] = []
let updatedCount = toView.filteredEntries.count
var options: ListViewDeleteAndInsertOptions = []
var maxAnimatedInsertionIndex = -1
var scrollToItem: ListViewScrollToItem?
switch reason {
case .initial:
let _ = options.insert(.LowLatency)
let _ = options.insert(.Synchronous)
case .interactiveChanges:
for (index, _, _) in indicesAndItems.sorted(by: { $0.0 > $1.0 }) {
let adjustedIndex = updatedCount - 1 - index
if adjustedIndex == maxAnimatedInsertionIndex + 1 {
maxAnimatedInsertionIndex += 1
}
}
var minTimestamp: Int32?
var maxTimestamp: Int32?
for (_, item, _) in indicesAndItems {
if case .PeerEntry = item, case let .index(index) = item.sortIndex, case let .chatList(chatListIndex) = index, chatListIndex.pinningIndex == nil {
let timestamp = chatListIndex.messageIndex.timestamp
if minTimestamp == nil || timestamp < minTimestamp! {
minTimestamp = timestamp
}
if maxTimestamp == nil || timestamp > maxTimestamp! {
maxTimestamp = timestamp
}
}
}
let _ = options.insert(.AnimateAlpha)
if !disableAnimations {
let _ = options.insert(.AnimateInsertion)
}
case .reload:
break
case .holeChanges:
break
}
for (index, entry, previousIndex) in indicesAndItems {
let adjustedIndex = updatedCount - 1 - index
let adjustedPrevousIndex: Int?
if let previousIndex = previousIndex {
adjustedPrevousIndex = previousCount - 1 - previousIndex
} else {
adjustedPrevousIndex = nil
}
var directionHint: ListViewItemOperationDirectionHint?
if maxAnimatedInsertionIndex >= 0 && adjustedIndex <= maxAnimatedInsertionIndex {
directionHint = .Down
}
adjustedIndicesAndItems.append(ChatListNodeViewTransitionInsertEntry(index: adjustedIndex, previousIndex: adjustedPrevousIndex, entry: entry, directionHint: directionHint))
}
for (index, entry, previousIndex) in updateIndices {
let adjustedIndex = updatedCount - 1 - index
let adjustedPreviousIndex = previousCount - 1 - previousIndex
let directionHint: ListViewItemOperationDirectionHint? = nil
adjustedUpdateItems.append(ChatListNodeViewTransitionUpdateEntry(index: adjustedIndex, previousIndex: adjustedPreviousIndex, entry: entry, directionHint: directionHint))
}
if let scrollPosition = scrollPosition {
switch scrollPosition {
case let .index(scrollIndex, position, directionHint, animated):
var index = toView.filteredEntries.count - 1
for entry in toView.filteredEntries {
if entry.sortIndex >= .index(.chatList(scrollIndex)) {
scrollToItem = ListViewScrollToItem(index: index, position: position, animated: animated, curve: .Default(duration: nil), directionHint: directionHint)
break
}
index -= 1
}
if scrollToItem == nil {
var index = 0
for entry in toView.filteredEntries.reversed() {
if entry.sortIndex < .index(.chatList(scrollIndex)) {
scrollToItem = ListViewScrollToItem(index: index, position: position, animated: animated, curve: .Default(duration: nil), directionHint: directionHint)
break
}
index += 1
}
}
}
}
var fromEmptyView: Bool
fromEmptyView = false
var animateCrossfade: Bool
animateCrossfade = false
if let fromView = fromView {
var wasSingleHeader = false
if fromView.filteredEntries.count == 1, case .HeaderEntry = fromView.filteredEntries[0] {
wasSingleHeader = true
}
var isSingleHeader = false
if toView.filteredEntries.count == 1, case .HeaderEntry = toView.filteredEntries[0] {
isSingleHeader = true
}
if (wasSingleHeader || isSingleHeader), case .interactiveChanges = reason {
if wasSingleHeader != isSingleHeader {
if wasSingleHeader {
animateCrossfade = true
options.remove(.AnimateInsertion)
options.remove(.AnimateAlpha)
} else {
let _ = options.insert(.AnimateInsertion)
}
}
} else if fromView.filteredEntries.isEmpty || fromView.filter != toView.filter {
var updateEmpty = true
if !fromView.filteredEntries.isEmpty, let fromFilter = fromView.filter, let toFilter = toView.filter, case var .filter(_, _, _, fromData) = fromFilter, case let .filter(_, _, _, toData) = toFilter, fromData.includePeers.pinnedPeers != toData.includePeers.pinnedPeers {
fromData.includePeers = toData.includePeers
if fromData == toData {
options.insert(.AnimateInsertion)
updateEmpty = false
}
}
if updateEmpty {
options.remove(.AnimateInsertion)
options.remove(.AnimateAlpha)
fromEmptyView = true
}
}
} else {
fromEmptyView = true
}
if let fromView = fromView, !fromView.isLoading, toView.isLoading {
options.remove(.AnimateInsertion)
options.remove(.AnimateAlpha)
}
var adjustScrollToFirstItem = false
if !previewing && !searchMode && fromEmptyView && scrollToItem == nil && toView.filteredEntries.count >= 1 {
adjustScrollToFirstItem = true
}
subscriber.putNext(ChatListNodeViewTransition(chatListView: toView, deleteItems: adjustedDeleteIndices, insertEntries: adjustedIndicesAndItems, updateEntries: adjustedUpdateItems, options: options, scrollToItem: scrollToItem, stationaryItemRange: nil, adjustScrollToFirstItem: adjustScrollToFirstItem, animateCrossfade: animateCrossfade))
subscriber.putCompletion()
return EmptyDisposable
}
}