Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "ContactListUI",
module_name = "ContactListUI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
"//submodules/Display:Display",
"//submodules/TelegramCore:TelegramCore",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/AccountContext:AccountContext",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/MergeLists:MergeLists",
"//submodules/SearchUI:SearchUI",
"//submodules/ChatListSearchItemHeader:ChatListSearchItemHeader",
"//submodules/ItemListPeerItem:ItemListPeerItem",
"//submodules/ContactsPeerItem:ContactsPeerItem",
"//submodules/ChatListSearchItemNode:ChatListSearchItemNode",
"//submodules/TelegramPermissionsUI:TelegramPermissionsUI",
"//submodules/TelegramNotices:TelegramNotices",
"//submodules/AlertUI:AlertUI",
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/ShareController:ShareController",
"//submodules/AppBundle:AppBundle",
"//submodules/OverlayStatusController:OverlayStatusController",
"//submodules/PhoneNumberFormat:PhoneNumberFormat",
"//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode",
"//submodules/StickerResources:StickerResources",
"//submodules/AnimatedStickerNode:AnimatedStickerNode",
"//submodules/TelegramAnimatedStickerNode:TelegramAnimatedStickerNode",
"//submodules/QrCodeUI:QrCodeUI",
"//submodules/LocalizedPeerData:LocalizedPeerData",
"//submodules/TelegramUI/Components/Stories/StoryContainerScreen",
"//submodules/TelegramUI/Components/Stories/StoryPeerListComponent",
"//submodules/TelegramUI/Components/ChatListTitleView",
"//submodules/TelegramUI/Components/ChatListHeaderComponent",
"//submodules/ComponentFlow",
"//submodules/TooltipUI",
"//submodules/UndoUI",
"//submodules/TelegramIntents",
"//submodules/ContextUI",
"//submodules/TelegramUI/Components/EdgeEffect",
"//submodules/TelegramUI/Components/SearchInputPanelComponent",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,256 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import AppBundle
import PhoneNumberFormat
import AccountContext
private let titleFont = Font.regular(17.0)
public class ContactsAddItem: ListViewItem {
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let phoneNumber: String
let action: () -> Void
public let header: ListViewItemHeader?
public init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, phoneNumber: String, header: ListViewItemHeader?, action: @escaping () -> Void) {
self.context = context
self.theme = theme
self.strings = strings
self.phoneNumber = phoneNumber
self.action = action
self.header = header
}
public 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 = ContactsAddItemNode()
let makeLayout = node.asyncLayout()
let (first, last, firstWithHeader) = ContactsAddItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem)
let (nodeLayout, nodeApply) = makeLayout(self, params, first, last, firstWithHeader)
node.contentSize = nodeLayout.contentSize
node.insets = nodeLayout.insets
completion(node, {
return (nil, { _ in nodeApply(false) })
})
}
}
public 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 {
if let nodeValue = node() as? ContactsAddItemNode {
let layout = nodeValue.asyncLayout()
async {
let (first, last, firstWithHeader) = ContactsAddItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem)
let (nodeLayout, apply) = layout(self, params, first, last, firstWithHeader)
Queue.mainQueue().async {
completion(nodeLayout, { _ in
apply(animation.isAnimated)
})
}
}
}
}
}
public var selectable: Bool {
return true
}
public func selected(listView: ListView) {
self.action()
}
static func mergeType(item: ContactsAddItem, previousItem: ListViewItem?, nextItem: ListViewItem?) -> (first: Bool, last: Bool, firstWithHeader: Bool) {
var first = false
var last = false
var firstWithHeader = false
if let previousItem = previousItem {
if let header = item.header {
if let previousItem = previousItem as? ContactsAddItem {
firstWithHeader = header.id != previousItem.header?.id
} else {
firstWithHeader = true
}
}
} else {
first = true
firstWithHeader = item.header != nil
}
if let nextItem = nextItem {
if let header = item.header {
if let nextItem = nextItem as? ContactsAddItem {
last = header.id != nextItem.header?.id
} else {
last = true
}
}
} else {
last = true
}
return (first, last, firstWithHeader)
}
}
private let separatorHeight = 1.0 / UIScreen.main.scale
class ContactsAddItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let separatorNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let iconNode: ASImageNode
private let titleNode: TextNode
private var layoutParams: (ContactsAddItem, ListViewItemLayoutParams, Bool, Bool, Bool)?
private var item: ContactsAddItem? {
return self.layoutParams?.0
}
required init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
self.iconNode = ASImageNode()
self.titleNode = TextNode()
super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false)
self.addSubnode(self.backgroundNode)
self.addSubnode(self.separatorNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.titleNode)
}
override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
if let (item, _, _, _, _) = self.layoutParams {
let (first, last, firstWithHeader) = ContactsAddItem.mergeType(item: item, previousItem: previousItem, nextItem: nextItem)
self.layoutParams = (item, params, first, last, firstWithHeader)
let makeLayout = self.asyncLayout()
let (nodeLayout, nodeApply) = makeLayout(item, params, first, last, firstWithHeader)
self.contentSize = nodeLayout.contentSize
self.insets = nodeLayout.insets
let _ = nodeApply(false)
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if highlighted {
self.highlightedBackgroundNode.alpha = 1.0
if self.highlightedBackgroundNode.supernode == nil {
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode)
}
} else {
if self.highlightedBackgroundNode.supernode != nil {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in
if let strongSelf = self {
if completed {
strongSelf.highlightedBackgroundNode.removeFromSupernode()
}
}
})
self.highlightedBackgroundNode.alpha = 0.0
} else {
self.highlightedBackgroundNode.removeFromSupernode()
}
}
}
}
func asyncLayout() -> (_ item: ContactsAddItem, _ params: ListViewItemLayoutParams, _ first: Bool, _ last: Bool, _ firstWithHeader: Bool) -> (ListViewItemNodeLayout, (Bool) -> Void) {
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let currentItem = self.layoutParams?.0
return { [weak self] item, params, first, last, firstWithHeader in
var updatedTheme: PresentationTheme?
var updatedIcon: UIImage?
if currentItem?.theme !== item.theme {
updatedTheme = item.theme
updatedIcon = generateTintedImage(image: UIImage(bundleImageName: "Contact List/AddMemberIcon"), color: item.theme.list.itemAccentColor)
}
let leftInset: CGFloat = 65.0 + params.leftInset
let rightInset: CGFloat = 10.0 + params.rightInset
let titleAttributedString = NSAttributedString(string: item.strings.Contacts_AddPhoneNumber(formatPhoneNumber(context: item.context, number: item.phoneNumber)).string, font: titleFont, textColor: item.theme.list.itemAccentColor)
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: max(0.0, params.width - leftInset - rightInset), height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: 50.0), insets: UIEdgeInsets(top: firstWithHeader ? 29.0 : 0.0, left: 0.0, bottom: 0.0, right: 0.0))
let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: 14.0), size: titleLayout.size)
return (nodeLayout, { [weak self] animated in
if let strongSelf = self {
strongSelf.layoutParams = (item, params, first, last, firstWithHeader)
let transition: ContainedViewLayoutTransition
if animated {
transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
} else {
transition = .immediate
}
if let _ = updatedTheme {
strongSelf.separatorNode.backgroundColor = item.theme.list.itemPlainSeparatorColor
strongSelf.backgroundNode.backgroundColor = item.theme.list.plainBackgroundColor
strongSelf.highlightedBackgroundNode.backgroundColor = item.theme.list.itemHighlightedBackgroundColor
}
let _ = titleApply()
transition.updateFrame(node: strongSelf.titleNode, frame: titleFrame)
if let updatedIcon = updatedIcon {
strongSelf.iconNode.image = updatedIcon
}
transition.updateFrame(node: strongSelf.iconNode, frame: CGRect(x: params.leftInset + 14.0, y: 5.0, width: 40.0, height: 40.0))
let topHighlightInset: CGFloat = (first || !nodeLayout.insets.top.isZero) ? 0.0 : separatorHeight
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: nodeLayout.contentSize.width, height: nodeLayout.contentSize.height))
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -nodeLayout.insets.top - topHighlightInset), size: CGSize(width: nodeLayout.size.width, height: nodeLayout.size.height + topHighlightInset))
strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: nodeLayout.contentSize.height - separatorHeight), size: CGSize(width: max(0.0, nodeLayout.size.width - leftInset), height: separatorHeight))
strongSelf.separatorNode.isHidden = last
}
})
}
}
override func layoutHeaderAccessoryItemNode(_ accessoryItemNode: ListViewAccessoryItemNode) {
let bounds = self.bounds
accessoryItemNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -29.0), size: CGSize(width: bounds.size.width, height: 29.0))
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * 0.5)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.5, removeOnCompletion: false)
}
override public func headers() -> [ListViewItemHeader]? {
if let (item, _, _, _, _) = self.layoutParams {
return item.header.flatMap { [$0] }
} else {
return nil
}
}
}
@@ -0,0 +1,254 @@
import Foundation
import UIKit
import SwiftSignalKit
import ContextUI
import AccountContext
import TelegramCore
import Display
import AlertUI
import PresentationDataUtils
import OverlayStatusController
import LocalizedPeerData
import UndoUI
import TooltipUI
func contactContextMenuItems(context: AccountContext, peerId: EnginePeer.Id, contactsController: ContactsController?, isStories: Bool) -> Signal<[ContextMenuItem], NoError> {
let strings = context.sharedContext.currentPresentationData.with({ $0 }).strings
return context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId),
TelegramEngine.EngineData.Item.Peer.AreVoiceCallsAvailable(id: peerId),
TelegramEngine.EngineData.Item.Peer.AreVideoCallsAvailable(id: peerId),
TelegramEngine.EngineData.Item.Peer.NotificationSettings(id: peerId),
TelegramEngine.EngineData.Item.NotificationSettings.Global(),
TelegramEngine.EngineData.Item.Contacts.Top()
)
|> map { [weak contactsController] peer, areVoiceCallsAvailable, areVideoCallsAvailable, notificationSettings, globalSettings, topSearchPeers -> [ContextMenuItem] in
guard let peer else {
return []
}
var items: [ContextMenuItem] = []
if isStories {
items.append(.action(ContextMenuActionItem(text: strings.StoryFeed_ContextOpenProfile, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/User"), color: theme.contextMenu.primaryColor)
}, action: { c, _ in
c?.dismiss(completion: {
let _ = (context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
)
|> deliverOnMainQueue).start(next: { peer in
guard let peer = peer, let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else {
return
}
(contactsController?.navigationController as? NavigationController)?.pushViewController(controller)
})
})
})))
let isMuted = resolvedAreStoriesMuted(globalSettings: globalSettings._asGlobalNotificationSettings(), peer: peer._asPeer(), peerSettings: notificationSettings._asNotificationSettings(), topSearchPeers: topSearchPeers)
items.append(.action(ContextMenuActionItem(text: isMuted ? strings.StoryFeed_ContextNotifyOn : strings.StoryFeed_ContextNotifyOff, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: isMuted ? "Chat/Context Menu/Unmute" : "Chat/Context Menu/Muted"), color: theme.contextMenu.primaryColor)
}, action: { _, f in
f(.default)
let _ = context.engine.peers.togglePeerStoriesMuted(peerId: peerId).start()
do {
let iconColor = UIColor.white
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
if isMuted {
contactsController?.present(UndoOverlayController(
presentationData: presentationData,
content: .universal(animation: "anim_profileunmute", scale: 0.075, colors: [
"Middle.Group 1.Fill 1": iconColor,
"Top.Group 1.Fill 1": iconColor,
"Bottom.Group 1.Fill 1": iconColor,
"EXAMPLE.Group 1.Fill 1": iconColor,
"Line.Group 1.Stroke 1": iconColor
], title: nil, text: presentationData.strings.StoryFeed_TooltipNotifyOn(peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string, customUndoText: nil, timeout: nil),
elevatedLayout: false,
animateInAsReplacement: false,
action: { _ in return false }
), in: .current)
} else {
contactsController?.present(UndoOverlayController(
presentationData: presentationData,
content: .universal(animation: "anim_profilemute", scale: 0.075, colors: [
"Middle.Group 1.Fill 1": iconColor,
"Top.Group 1.Fill 1": iconColor,
"Bottom.Group 1.Fill 1": iconColor,
"EXAMPLE.Group 1.Fill 1": iconColor,
"Line.Group 1.Stroke 1": iconColor
], title: nil, text: presentationData.strings.StoryFeed_TooltipNotifyOff(peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string, customUndoText: nil, timeout: nil),
elevatedLayout: false,
animateInAsReplacement: false,
action: { _ in return false }
), in: .current)
}
}
})))
items.append(.action(ContextMenuActionItem(text: strings.StoryFeed_ContextUnarchive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/MoveToChats"), color: theme.contextMenu.primaryColor)
}, action: { _, f in
f(.dismissWithoutContent)
context.engine.peers.updatePeerStoriesHidden(id: peerId, isHidden: false)
})))
return items
}
items.append(.action(ContextMenuActionItem(text: strings.ContactList_Context_SendMessage, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Message"), color: theme.contextMenu.primaryColor) }, action: { _, f in
let _ = (context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
)
|> deliverOnMainQueue).start(next: { peer in
guard let peer = peer else {
return
}
if let contactsController = contactsController, let navigationController = contactsController.navigationController as? NavigationController {
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), peekData: nil))
}
f(.default)
})
})))
var canStartSecretChat = true
if case let .user(user) = peer, user.flags.contains(.isSupport) {
canStartSecretChat = false
}
if canStartSecretChat {
items.append(.action(ContextMenuActionItem(text: strings.ContactList_Context_StartSecretChat, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Timer"), color: theme.contextMenu.primaryColor) }, action: { _, f in
let _ = (context.engine.peers.mostRecentSecretChat(id: peerId)
|> deliverOnMainQueue).start(next: { [weak contactsController] currentPeerId in
if let currentPeerId = currentPeerId {
let _ = (context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: currentPeerId)
)
|> deliverOnMainQueue).start(next: { peer in
guard let peer = peer else {
return
}
if let contactsController = contactsController, let navigationController = (contactsController.navigationController as? NavigationController) {
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), peekData: nil))
}
})
} else {
var createSignal = context.engine.peers.createSecretChat(peerId: peerId)
var cancelImpl: (() -> Void)?
let progressSignal = Signal<Never, NoError> { subscriber in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: {
cancelImpl?()
}))
contactsController?.present(controller, in: .window(.root))
return ActionDisposable { [weak controller] in
Queue.mainQueue().async() {
controller?.dismiss()
}
}
}
|> runOn(Queue.mainQueue())
|> delay(0.15, queue: Queue.mainQueue())
let progressDisposable = progressSignal.start()
createSignal = createSignal
|> afterDisposed {
Queue.mainQueue().async {
progressDisposable.dispose()
}
}
let createSecretChatDisposable = MetaDisposable()
cancelImpl = {
createSecretChatDisposable.set(nil)
}
createSecretChatDisposable.set((createSignal
|> deliverOnMainQueue).start(next: { peerId in
let _ = (context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)
)
|> deliverOnMainQueue).start(next: { peer in
guard let peer = peer else {
return
}
if let navigationController = (contactsController?.navigationController as? NavigationController) {
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), peekData: nil))
}
})
}, error: { error in
if let contactsController = contactsController {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let text: String
switch error {
case .limitExceeded:
text = presentationData.strings.TwoStepAuth_FloodError
case .premiumRequired:
text = presentationData.strings.Conversation_SendMessageErrorNonPremiumForbidden(peer.compactDisplayTitle).string
default:
text = presentationData.strings.Login_UnknownError
}
contactsController.present(textAlertController(context: context, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root))
}
}))
}
})
f(.default)
})))
}
var canCall = true
if case let .user(user) = peer, (user.flags.contains(.isSupport) || !areVoiceCallsAvailable) {
canCall = false
}
var canVideoCall = false
if canCall {
if areVideoCallsAvailable {
canVideoCall = true
}
}
if canCall {
items.append(.action(ContextMenuActionItem(text: strings.ContactList_Context_Call, icon: { theme in
generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Call"), color: theme.contextMenu.primaryColor)
}, action: { _, f in
context.requestCall(peerId: peerId, isVideo: false, completion: {})
f(.default)
})))
}
if canVideoCall {
items.append(.action(ContextMenuActionItem(text: strings.ContactList_Context_VideoCall, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/VideoCall"), color: theme.contextMenu.primaryColor)
}, action: { _, f in
context.requestCall(peerId: peerId, isVideo: true, completion: {})
f(.default)
})))
}
items.append(.action(ContextMenuActionItem(text: strings.ContactList_Context_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor)
}, action: { [weak contactsController] _, f in
if let contactsController {
contactsController.requestDeleteContacts(peerIds: [peerId])
}
f(.dismissWithoutContent)
})))
items.append(.separator)
items.append(.action(ContextMenuActionItem(text: strings.ContactList_Context_Select, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor)
}, action: { [weak contactsController] _, f in
if let contactsController {
contactsController.beginSelection(peerId: peerId)
}
f(.default)
})))
return items
}
}
@@ -0,0 +1,422 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import ContactsPeerItem
import AccountContext
public enum ContactListActionItemHighlight {
case cell
case alpha
}
public class ContactListActionItem: ListViewItem, ListViewItemWithHeader {
public enum Style {
case accent
case generic
}
public enum Height {
case generic
case tall
}
let presentationData: ItemListPresentationData
let style: ItemListStyle
let systemStyle: ItemListSystemStyle
let title: String
let subtitle: String?
let height: Height
let icon: ContactListActionItemIcon
let actionStyle: Style
let highlight: ContactListActionItemHighlight
let clearHighlightAutomatically: Bool
let accessible: Bool
let action: () -> Void
public let header: ListViewItemHeader?
public init(presentationData: ItemListPresentationData, style: ItemListStyle = .plain, systemStyle: ItemListSystemStyle = .legacy, title: String, subtitle: String? = nil, icon: ContactListActionItemIcon, actionStyle: Style = .accent, height: Height = .generic, highlight: ContactListActionItemHighlight = .cell, clearHighlightAutomatically: Bool = true, accessible: Bool = true, header: ListViewItemHeader?, action: @escaping () -> Void) {
self.presentationData = presentationData
self.style = style
self.systemStyle = systemStyle
self.title = title
self.subtitle = subtitle
self.icon = icon
self.actionStyle = actionStyle
self.height = height
self.highlight = highlight
self.header = header
self.clearHighlightAutomatically = clearHighlightAutomatically
self.accessible = accessible
self.action = action
}
public 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 = ContactListActionItemNode()
let (_, last, firstWithHeader) = ContactListActionItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem)
let (layout, apply) = node.asyncLayout()(self, params, firstWithHeader, last)
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
public 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 {
if let nodeValue = node() as? ContactListActionItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (_, last, firstWithHeader) = ContactListActionItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem)
let (layout, apply) = makeLayout(self, params, firstWithHeader, last)
Queue.mainQueue().async {
completion(layout, { _ in
apply()
})
}
}
}
}
}
public var selectable: Bool = true
public func selected(listView: ListView){
self.action()
if self.clearHighlightAutomatically {
listView.clearHighlightAnimated(true)
}
}
static func mergeType(item: ContactListActionItem, previousItem: ListViewItem?, nextItem: ListViewItem?) -> (first: Bool, last: Bool, firstWithHeader: Bool) {
var first = false
var last = false
var firstWithHeader = false
if let previousItem = previousItem {
if let header = item.header {
if let previousItem = previousItem as? ContactsPeerItem {
firstWithHeader = header.id != previousItem.header?.id
} else if let previousItem = previousItem as? ContactListActionItem {
firstWithHeader = header.id != previousItem.header?.id
} else {
firstWithHeader = true
}
}
} else {
first = true
firstWithHeader = item.header != nil
}
if let nextItem = nextItem {
if let nextItem = nextItem as? ContactsPeerItem {
last = item.header?.id != nextItem.header?.id
} else if let nextItem = nextItem as? ContactListActionItem {
last = item.header?.id != nextItem.header?.id
} else {
last = true
}
} else {
last = true
}
return (first, last, firstWithHeader)
}
}
class ContactListActionItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let maskNode: ASImageNode
private let iconNode: ASImageNode
private let titleNode: TextNode
private let subtitleNode: TextNode
private let activateArea: AccessibilityAreaNode
private var item: ContactListActionItem?
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.backgroundNode.backgroundColor = .white
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.maskNode.isUserInteractionEnabled = false
self.titleNode = TextNode()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.contentMode = .left
self.titleNode.contentsScale = UIScreen.main.scale
self.subtitleNode = TextNode()
self.subtitleNode.isUserInteractionEnabled = false
self.subtitleNode.contentMode = .left
self.subtitleNode.contentsScale = UIScreen.main.scale
self.iconNode = ASImageNode()
self.iconNode.isLayerBacked = true
self.iconNode.displayWithoutProcessing = true
self.iconNode.displaysAsynchronously = false
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isLayerBacked = true
self.activateArea = AccessibilityAreaNode()
super.init(layerBacked: false, dynamicBounce: false)
self.addSubnode(self.iconNode)
self.addSubnode(self.titleNode)
self.addSubnode(self.subtitleNode)
self.addSubnode(self.activateArea)
self.activateArea.activate = { [weak self] in
self?.item?.action()
return true
}
}
func asyncLayout() -> (_ item: ContactListActionItem, _ params: ListViewItemLayoutParams, _ firstWithHeader: Bool, _ last: Bool) -> (ListViewItemNodeLayout, () -> Void) {
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeSubtitleLayout = TextNode.asyncLayout(self.subtitleNode)
let currentItem = self.item
return { item, params, firstWithHeader, last in
var updatedTheme: PresentationTheme?
if currentItem?.presentationData.theme !== item.presentationData.theme {
updatedTheme = item.presentationData.theme
}
var leftInset: CGFloat = 16.0 + params.leftInset
if case .generic = item.icon {
leftInset += 49.0
}
let titleColor: UIColor
let titleFont: UIFont
switch item.actionStyle {
case .accent:
titleColor = item.presentationData.theme.list.itemAccentColor
titleFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize)
case .generic:
titleColor = item.presentationData.theme.list.itemPrimaryTextColor
titleFont = Font.medium(item.presentationData.fontSize.itemListBaseFontSize)
}
let subtitleFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 13.0 / 17.0))
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: titleFont, textColor: titleColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - 10.0 - leftInset - params.rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let subtitleAttributedString = item.subtitle.flatMap { NSAttributedString(string: $0, font: subtitleFont, textColor: item.presentationData.theme.list.itemSecondaryTextColor) }
let (subtitleLayout, subtitleApply) = makeSubtitleLayout(TextNodeLayoutArguments(attributedString: subtitleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - 10.0 - leftInset - params.rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let subtitleHeightComponent: CGFloat
if subtitleAttributedString == nil {
subtitleHeightComponent = 0.0
} else {
subtitleHeightComponent = -1.0 + subtitleLayout.size.height
}
let contentHeight: CGFloat
var verticalInset: CGFloat = subtitleAttributedString != nil ? 6.0 : 12.0
if case .glass = item.systemStyle {
verticalInset += 4.0
}
if case .tall = item.height {
contentHeight = 50.0
} else if case .alpha = item.highlight {
contentHeight = 50.0
} else {
contentHeight = verticalInset * 2.0 + titleLayout.size.height + subtitleHeightComponent
}
let contentSize = CGSize(width: params.width, height: contentHeight)
let insets = UIEdgeInsets(top: firstWithHeader ? 29.0 : 0.0, left: 0.0, bottom: 0.0, right: 0.0)
let separatorHeight = UIScreenPixel
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.item = item
strongSelf.activateArea.accessibilityLabel = item.title
strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: layout.contentSize.width - params.leftInset - params.rightInset, height: layout.contentSize.height))
if let _ = updatedTheme {
strongSelf.topStripeNode.backgroundColor = item.presentationData.theme.list.itemPlainSeparatorColor
strongSelf.bottomStripeNode.backgroundColor = item.presentationData.theme.list.itemPlainSeparatorColor
strongSelf.backgroundNode.backgroundColor = item.presentationData.theme.list.plainBackgroundColor
strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor
if case .generic = item.actionStyle {
strongSelf.iconNode.image = item.icon.image
} else {
strongSelf.iconNode.image = generateTintedImage(image: item.icon.image, color: item.presentationData.theme.list.itemAccentColor)
}
}
if item.accessible && strongSelf.activateArea.supernode == nil {
strongSelf.view.accessibilityElementsHidden = false
strongSelf.addSubnode(strongSelf.activateArea)
} else if !item.accessible && strongSelf.activateArea.supernode != nil {
strongSelf.view.accessibilityElementsHidden = true
strongSelf.activateArea.removeFromSupernode()
}
let _ = titleApply()
let _ = subtitleApply()
var titleOffset = leftInset
var hideBottomStripe: Bool = last
if let image = item.icon.image {
var iconFrame: CGRect
switch item.icon {
case let .inline(_, position):
hideBottomStripe = true
let iconSpacing: CGFloat = 4.0
let totalWidth: CGFloat = titleLayout.size.width + image.size.width + iconSpacing
switch position {
case .left:
iconFrame = CGRect(origin: CGPoint(x: params.leftInset + floor((contentSize.width - params.leftInset - params.rightInset - totalWidth) / 2.0), y: floor((contentSize.height - image.size.height) / 2.0)), size: image.size)
titleOffset = iconFrame.minX + iconSpacing
case .right:
iconFrame = CGRect(origin: CGPoint(x: params.leftInset + floor((contentSize.width - params.leftInset - params.rightInset - totalWidth) / 2.0) + totalWidth - image.size.width, y: floor((contentSize.height - image.size.height) / 2.0)), size: image.size)
titleOffset = iconFrame.maxX - totalWidth
}
default:
iconFrame = CGRect(origin: CGPoint(x: params.leftInset + floor((leftInset - params.leftInset - image.size.width) / 2.0) + 3.0, y: floor((contentSize.height - image.size.height) / 2.0)), size: image.size)
}
strongSelf.iconNode.frame = iconFrame
}
if strongSelf.backgroundNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0)
}
if strongSelf.topStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1)
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2)
}
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
let hasCorners = itemListHasRoundedBlockLayout(params)
if case .blocks = item.style {
if strongSelf.maskNode.supernode == nil {
strongSelf.addSubnode(strongSelf.maskNode)
}
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: false, bottom: true, glass: item.systemStyle == .glass) : nil
strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
}
strongSelf.topStripeNode.isHidden = true
strongSelf.bottomStripeNode.isHidden = hideBottomStripe
strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentSize.height - separatorHeight), size: CGSize(width: params.width - leftInset, height: separatorHeight))
let titleFrame: CGRect
if subtitleAttributedString != nil {
titleFrame = CGRect(origin: CGPoint(x: titleOffset, y: verticalInset), size: titleLayout.size)
} else {
titleFrame = CGRect(origin: CGPoint(x: titleOffset, y: floor((contentSize.height - titleLayout.size.height) / 2.0)), size: titleLayout.size)
}
strongSelf.titleNode.frame = titleFrame
let subtitleFrame = CGRect(origin: CGPoint(x: titleOffset, y: titleFrame.maxY - 1.0), size: subtitleLayout.size)
strongSelf.subtitleNode.frame = subtitleFrame
strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: contentSize.height + UIScreenPixel + UIScreenPixel))
}
})
}
}
override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) {
super.setHighlighted(highlighted, at: point, animated: animated)
if let item = self.item, case .alpha = item.highlight {
if highlighted {
self.titleNode.alpha = 0.4
self.iconNode.alpha = 0.4
} else {
if animated {
self.titleNode.layer.animateAlpha(from: self.titleNode.alpha, to: 1.0, duration: 0.2)
self.iconNode.layer.animateAlpha(from: self.iconNode.alpha, to: 1.0, duration: 0.2)
}
self.titleNode.alpha = 1.0
self.iconNode.alpha = 1.0
}
} else {
if highlighted {
self.highlightedBackgroundNode.alpha = 1.0
if self.highlightedBackgroundNode.supernode == nil {
var anchorNode: ASDisplayNode?
if self.bottomStripeNode.supernode != nil {
anchorNode = self.bottomStripeNode
} else if self.topStripeNode.supernode != nil {
anchorNode = self.topStripeNode
} else if self.backgroundNode.supernode != nil {
anchorNode = self.backgroundNode
}
if let anchorNode = anchorNode {
self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode)
} else {
self.addSubnode(self.highlightedBackgroundNode)
}
}
} else {
if self.highlightedBackgroundNode.supernode != nil {
if animated {
self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in
if let strongSelf = self {
if completed {
strongSelf.highlightedBackgroundNode.removeFromSupernode()
}
}
})
self.highlightedBackgroundNode.alpha = 0.0
} else {
self.highlightedBackgroundNode.removeFromSupernode()
}
}
}
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
override public func headers() -> [ListViewItemHeader]? {
if let item = self.item {
return item.header.flatMap { [$0] }
} else {
return nil
}
}
}
@@ -0,0 +1,74 @@
import Foundation
import Display
import UIKit
import TelegramPresentationData
import ListSectionHeaderNode
final class ContactListNameIndexHeader: Equatable, ListViewItemHeader {
let id: ListViewItemNode.HeaderId
let stackingId: ListViewItemNode.HeaderId? = nil
let theme: PresentationTheme
let letter: unichar
let stickDirection: ListViewItemHeaderStickDirection = .top
public let stickOverInsets: Bool = true
let height: CGFloat = 29.0
init(theme: PresentationTheme, letter: unichar) {
self.theme = theme
self.letter = letter
self.id = ListViewItemNode.HeaderId(space: 0, id: Int64(letter))
}
func combinesWith(other: ListViewItemHeader) -> Bool {
if let other = other as? ContactListNameIndexHeader, self.id == other.id {
return true
} else {
return false
}
}
func node(synchronousLoad: Bool) -> ListViewItemHeaderNode {
return ContactListNameIndexHeaderNode(theme: self.theme, letter: self.letter)
}
func updateNode(_ node: ListViewItemHeaderNode, previous: ListViewItemHeader?, next: ListViewItemHeader?) {
}
static func ==(lhs: ContactListNameIndexHeader, rhs: ContactListNameIndexHeader) -> Bool {
return lhs.id == rhs.id
}
}
final class ContactListNameIndexHeaderNode: ListViewItemHeaderNode {
private var theme: PresentationTheme
private let letter: unichar
private let sectionHeaderNode: ListSectionHeaderNode
init(theme: PresentationTheme, letter: unichar) {
self.theme = theme
self.letter = letter
self.sectionHeaderNode = ListSectionHeaderNode(theme: theme)
super.init()
if let scalar = UnicodeScalar(letter) {
self.sectionHeaderNode.title = "\(Character(scalar))"
}
self.addSubnode(self.sectionHeaderNode)
}
func updateTheme(theme: PresentationTheme) {
self.theme = theme
self.sectionHeaderNode.updateTheme(theme: theme)
}
override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
self.sectionHeaderNode.frame = CGRect(origin: CGPoint(), size: size)
self.sectionHeaderNode.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,842 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import DeviceAccess
import AccountContext
import AlertUI
import PresentationDataUtils
import TelegramPermissions
import TelegramNotices
import ContactsPeerItem
import SearchUI
import TelegramPermissionsUI
import AppBundle
import StickerResources
import ContextUI
import QrCodeUI
import StoryContainerScreen
import ChatListHeaderComponent
import TelegramIntents
import UndoUI
import ShareController
private final class HeaderContextReferenceContentSource: ContextReferenceContentSource {
private let controller: ViewController
private let sourceView: UIView
init(controller: ViewController, sourceView: UIView) {
self.controller = controller
self.sourceView = sourceView
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
private final class SortHeaderButton: HighlightableButtonNode {
let referenceNode: ContextReferenceContentNode
let containerNode: ContextControllerSourceNode
private let textNode: ImmediateTextNode
var contextAction: ((ASDisplayNode, ContextGesture?) -> Void)?
init(presentationData: PresentationData) {
self.referenceNode = ContextReferenceContentNode()
self.containerNode = ContextControllerSourceNode()
self.containerNode.animateScale = false
self.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
super.init()
self.containerNode.addSubnode(self.referenceNode)
self.referenceNode.addSubnode(self.textNode)
self.addSubnode(self.containerNode)
self.containerNode.shouldBegin = { [weak self] location in
guard let strongSelf = self, let _ = strongSelf.contextAction else {
return false
}
return true
}
self.containerNode.activated = { [weak self] gesture, _ in
guard let strongSelf = self else {
return
}
strongSelf.contextAction?(strongSelf.containerNode, gesture)
}
self.update(theme: presentationData.theme, strings: presentationData.strings)
}
override func didLoad() {
super.didLoad()
self.view.isOpaque = false
}
func update(theme: PresentationTheme, strings: PresentationStrings) {
self.textNode.attributedText = NSAttributedString(string: strings.Contacts_Sort, font: Font.regular(17.0), textColor: theme.rootController.navigationBar.accentTextColor)
let size = self.textNode.updateLayout(CGSize(width: 100.0, height: 44.0))
self.textNode.frame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((44.0 - size.height) / 2.0)), size: size)
self.containerNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: 44.0))
self.referenceNode.frame = self.containerNode.bounds
self.accessibilityLabel = strings.Contacts_Sort
}
override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize {
let size = self.textNode.updateLayout(CGSize(width: 100.0, height: 44.0))
return CGSize(width: size.width, height: 44.0)
}
func onLayout() {
}
}
public class ContactsController: ViewController {
private let context: AccountContext
private var contactsNode: ContactsControllerNode {
return self.displayNode as! ContactsControllerNode
}
private var validLayout: ContainerViewLayout?
private let index: PresentationPersonNameOrder = .lastFirst
private var _ready = Promise<Bool>()
override public var ready: Promise<Bool> {
return self._ready
}
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
private var authorizationDisposable: Disposable?
private var selectionDisposable: Disposable?
private var actionDisposable = MetaDisposable()
private let sortOrderPromise = Promise<ContactsSortOrder>()
private let isInVoiceOver = ValuePromise<Bool>(false)
public var switchToChatsController: (() -> Void)?
public override func updateNavigationCustomData(_ data: Any?, progress: CGFloat, transition: ContainedViewLayoutTransition) {
if self.isNodeLoaded {
self.contactsNode.contactListNode.updateSelectedChatLocation(data as? ChatLocation, progress: progress, transition: transition)
}
}
private let sortButton: SortHeaderButton
public init(context: AccountContext) {
self.context = context
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.sortButton = SortHeaderButton(presentationData: self.presentationData)
//super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData))
super.init(navigationBarPresentationData: nil)
self.tabBarItemContextActionType = .always
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
self.title = self.presentationData.strings.Contacts_Title
self.tabBarItem.title = self.presentationData.strings.Contacts_Title
let icon: UIImage?
if useSpecialTabBarIcons() {
icon = UIImage(bundleImageName: "Chat List/Tabs/Holiday/IconContacts")
} else {
icon = UIImage(bundleImageName: "Chat List/Tabs/IconContacts")
}
self.tabBarItem.image = icon
self.tabBarItem.selectedImage = icon
if !self.presentationData.reduceMotion {
self.tabBarItem.animationName = "TabContacts"
}
self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil)
self.navigationItem.leftBarButtonItem = UIBarButtonItem(customDisplayNode: self.sortButton)
self.navigationItem.leftBarButtonItem?.accessibilityLabel = self.presentationData.strings.Contacts_Sort
self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationAddIcon(self.presentationData.theme), style: .plain, target: self, action: #selector(self.addPressed))
self.navigationItem.rightBarButtonItem?.accessibilityLabel = self.presentationData.strings.Contacts_VoiceOver_AddContact
self.scrollToTop = { [weak self] in
if let strongSelf = self {
strongSelf.contactsNode.scrollToTop()
}
}
self.presentationDataDisposable = (context.sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
if let strongSelf = self {
let previousTheme = strongSelf.presentationData.theme
let previousStrings = strongSelf.presentationData.strings
strongSelf.presentationData = presentationData
if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings {
strongSelf.updateThemeAndStrings()
}
}
}).strict()
if #available(iOSApplicationExtension 10.0, iOS 10.0, *) {
self.authorizationDisposable = (combineLatest(DeviceAccess.authorizationStatus(subject: .contacts), combineLatest(context.sharedContext.accountManager.noticeEntry(key: ApplicationSpecificNotice.permissionWarningKey(permission: .contacts)!), context.account.postbox.preferencesView(keys: [PreferencesKeys.contactsSettings]), context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.contactSynchronizationSettings]))
|> map { noticeView, preferences, sharedData -> (Bool, ContactsSortOrder) in
let settings: ContactsSettings = preferences.values[PreferencesKeys.contactsSettings]?.get(ContactsSettings.self) ?? ContactsSettings.defaultSettings
let synchronizeDeviceContacts: Bool = settings.synchronizeContacts
let contactsSettings = sharedData.entries[ApplicationSpecificSharedDataKeys.contactSynchronizationSettings]?.get(ContactSynchronizationSettings.self)
let sortOrder: ContactsSortOrder = contactsSettings?.sortOrder ?? .presence
if !synchronizeDeviceContacts {
return (true, sortOrder)
}
let timestamp = noticeView.value.flatMap({ ApplicationSpecificNotice.getTimestampValue($0) })
if let timestamp = timestamp, timestamp > 0 {
return (true, sortOrder)
} else {
return (false, sortOrder)
}
})
|> deliverOnMainQueue).start(next: { [weak self] status, suppressedAndSortOrder in
if let strongSelf = self {
let (suppressed, sortOrder) = suppressedAndSortOrder
strongSelf.tabBarItem.badgeValue = status != .allowed && !suppressed ? "!" : nil
strongSelf.sortOrderPromise.set(.single(sortOrder))
}
}).strict()
} else {
self.sortOrderPromise.set(context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.contactSynchronizationSettings])
|> map { sharedData -> ContactsSortOrder in
let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.contactSynchronizationSettings]?.get(ContactSynchronizationSettings.self)
return settings?.sortOrder ?? .presence
})
}
self.sortButton.addTarget(self, action: #selector(self.sortPressed), forControlEvents: .touchUpInside)
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDataDisposable?.dispose()
self.authorizationDisposable?.dispose()
self.actionDisposable.dispose()
self.selectionDisposable?.dispose()
}
private func updateThemeAndStrings() {
self.sortButton.update(theme: self.presentationData.theme, strings: self.presentationData.strings)
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
self.navigationBar?.updatePresentationData(NavigationBarPresentationData(presentationData: self.presentationData))
self.title = self.presentationData.strings.Contacts_Title
self.tabBarItem.title = self.presentationData.strings.Contacts_Title
if !self.presentationData.reduceMotion {
self.tabBarItem.animationName = "TabContacts"
} else {
self.tabBarItem.animationName = nil
}
self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil)
if self.navigationItem.rightBarButtonItem != nil {
self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationAddIcon(self.presentationData.theme), style: .plain, target: self, action: #selector(self.addPressed))
self.navigationItem.rightBarButtonItem?.accessibilityLabel = self.presentationData.strings.Contacts_VoiceOver_AddContact
}
}
override public func loadDisplayNode() {
let sortOrderSignal: Signal<ContactsSortOrder, NoError> = combineLatest(self.sortOrderPromise.get(), self.isInVoiceOver.get())
|> map { sortOrder, isInVoiceOver in
if isInVoiceOver {
return .natural
} else {
return sortOrder
}
}
self.displayNode = ContactsControllerNode(context: self.context, sortOrder: sortOrderSignal |> distinctUntilChanged, present: { [weak self] c, a in
self?.present(c, in: .window(.root), with: a)
}, controller: self)
self._ready.set(combineLatest(queue: .mainQueue(),
self.contactsNode.contactListNode.ready,
self.contactsNode.storiesReady.get()
)
|> filter { a, b in
return a && b
}
|> take(1)
|> map { _ -> Bool in true })
self.contactsNode.navigationBar = self.navigationBar
let openPeer: (ContactListPeer, Bool) -> Void = { [weak self] peer, fromSearch in
if let strongSelf = self {
switch peer {
case let .peer(peer, _, _):
if let navigationController = strongSelf.navigationController as? NavigationController {
var scrollToEndIfExists = false
if let layout = strongSelf.validLayout, case .regular = layout.metrics.widthClass {
scrollToEndIfExists = true
}
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(EnginePeer(peer)), purposefulAction: { [weak self] in
if fromSearch {
self?.deactivateSearch(animated: false)
self?.switchToChatsController?()
}
}, scrollToEndIfExists: scrollToEndIfExists, options: [.removeOnMasterDetails], completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.contactsNode.contactListNode.listNode.clearHighlightAnimated(true)
}
}))
}
case let .deviceContact(id, _):
let _ = ((strongSelf.context.sharedContext.contactDataManager?.extendedData(stableId: id) ?? .single(nil))
|> take(1)
|> deliverOnMainQueue).start(next: { value in
guard let strongSelf = self, let value = value else {
return
}
(strongSelf.navigationController as? NavigationController)?.pushViewController(strongSelf.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: strongSelf.context), environment: ShareControllerAppEnvironment(sharedContext: strongSelf.context.sharedContext), subject: .vcard(nil, id, value), completed: nil, cancelled: nil), completion: { [weak self] in
if let strongSelf = self {
strongSelf.contactsNode.contactListNode.listNode.clearHighlightAnimated(true)
}
})
})
}
}
}
self.contactsNode.requestDeactivateSearch = { [weak self] in
self?.deactivateSearch(animated: true)
}
self.contactsNode.requestOpenPeerFromSearch = { peer in
openPeer(peer, true)
}
self.contactsNode.contactListNode.openPrivacyPolicy = { [weak self] in
if let strongSelf = self {
strongSelf.context.sharedContext.openExternalUrl(context: strongSelf.context, urlContext: .generic, url: "https://telegram.org/privacy", forceExternal: true, presentationData: strongSelf.presentationData, navigationController: strongSelf.navigationController as? NavigationController, dismissInput: {})
}
}
self.contactsNode.contactListNode.suppressPermissionWarning = { [weak self] in
if let strongSelf = self {
strongSelf.context.sharedContext.presentContactsWarningSuppression(context: strongSelf.context, present: { c, a in
strongSelf.present(c, in: .window(.root), with: a)
})
}
}
self.contactsNode.contactListNode.activateSearch = { [weak self] in
self?.activateSearch()
}
self.contactsNode.contactListNode.openPeer = { [weak self] peer, _, _, _ in
guard let self else {
return
}
if let _ = self.contactsNode.contactListNode.selectionState {
self.contactsNode.contactListNode.updateSelectionState({ current in
if let updatedState = current?.withToggledPeerId(peer.id), !updatedState.selectedPeerIndices.isEmpty {
return updatedState
} else {
return nil
}
})
} else {
openPeer(peer, false)
}
}
self.contactsNode.requestAddContact = { [weak self] phoneNumber in
if let strongSelf = self {
strongSelf.view.endEditing(true)
strongSelf.context.sharedContext.openAddContact(context: strongSelf.context, firstName: "", lastName: "", phoneNumber: phoneNumber, label: defaultContactLabel, present: { [weak self] controller, arguments in
self?.present(controller, in: .window(.root), with: arguments)
}, pushController: { [weak self] controller in
(self?.navigationController as? NavigationController)?.pushViewController(controller)
}, completed: {
self?.deactivateSearch(animated: false)
})
}
}
self.contactsNode.openPeopleNearby = { [weak self] in
let _ = (DeviceAccess.authorizationStatus(subject: .location(.tracking))
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] status in
guard let strongSelf = self else {
return
}
let presentPeersNearby = {
let controller = strongSelf.context.sharedContext.makePeersNearbyController(context: strongSelf.context)
controller.navigationPresentation = .master
if let navigationController = strongSelf.context.sharedContext.mainWindow?.viewController as? NavigationController {
var controllers = navigationController.viewControllers.filter { !($0 is PermissionController) }
controllers.append(controller)
navigationController.setViewControllers(controllers, animated: true)
strongSelf.contactsNode.contactListNode.listNode.clearHighlightAnimated(true)
}
}
switch status {
case .allowed:
presentPeersNearby()
default:
let controller = PermissionController(context: strongSelf.context, splashScreen: false)
controller.setState(.permission(.nearbyLocation(status: PermissionRequestStatus(accessType: status))), animated: false)
controller.navigationPresentation = .master
controller.proceed = { result in
if result {
presentPeersNearby()
} else {
let _ = (strongSelf.navigationController as? NavigationController)?.popViewController(animated: true)
}
}
if let navigationController = strongSelf.context.sharedContext.mainWindow?.viewController as? NavigationController {
navigationController.pushViewController(controller, completion: { [weak self] in
if let strongSelf = self {
strongSelf.contactsNode.contactListNode.listNode.clearHighlightAnimated(true)
}
})
}
}
})
}
self.contactsNode.openInvite = { [weak self] in
let _ = (DeviceAccess.authorizationStatus(subject: .contacts)
|> take(1)
|> deliverOnMainQueue).start(next: { value in
guard let strongSelf = self else {
return
}
switch value {
case .allowed:
(strongSelf.navigationController as? NavigationController)?.pushViewController(InviteContactsController(context: strongSelf.context), completion: {
if let strongSelf = self {
strongSelf.contactsNode.contactListNode.listNode.clearHighlightAnimated(true)
}
})
case .notDetermined:
DeviceAccess.authorizeAccess(to: .contacts)
strongSelf.contactsNode.contactListNode.listNode.clearHighlightAnimated(true)
default:
let presentationData = strongSelf.presentationData
strongSelf.present(textAlertController(context: strongSelf.context, title: presentationData.strings.AccessDenied_Title, text: presentationData.strings.Contacts_AccessDeniedError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_NotNow, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.AccessDenied_Settings, action: {
self?.context.sharedContext.applicationBindings.openSettings()
})]), in: .window(.root))
strongSelf.contactsNode.contactListNode.listNode.clearHighlightAnimated(true)
}
})
}
self.contactsNode.openQrScan = { [weak self] in
if let strongSelf = self {
let context = strongSelf.context
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
DeviceAccess.authorizeAccess(to: .camera(.qrCode), presentationData: presentationData, present: { c, a in
c.presentationArguments = a
context.sharedContext.mainWindow?.present(c, on: .root)
}, openSettings: {
context.sharedContext.applicationBindings.openSettings()
}, { [weak self] granted in
guard let strongSelf = self else {
return
}
guard granted else {
strongSelf.contactsNode.contactListNode.listNode.clearHighlightAnimated(true)
return
}
let controller = QrCodeScanScreen(context: strongSelf.context, subject: .peer)
controller.showMyCode = { [weak self, weak controller] in
if let strongSelf = self {
let _ = (strongSelf.context.account.postbox.loadedPeerWithId(strongSelf.context.account.peerId)
|> deliverOnMainQueue).start(next: { [weak self, weak controller] peer in
if let strongSelf = self, let controller = controller {
controller.present(strongSelf.context.sharedContext.makeChatQrCodeScreen(context: strongSelf.context, peer: peer, threadId: nil, temporary: false), in: .window(.root))
}
})
}
}
(strongSelf.navigationController as? NavigationController)?.pushViewController(controller, completion: {
if let strongSelf = self {
strongSelf.contactsNode.contactListNode.listNode.clearHighlightAnimated(true)
}
})
})
}
}
self.sortButton.contextAction = { [weak self] sourceNode, gesture in
self?.presentSortMenu(sourceView: sourceNode.view, gesture: gesture)
}
let previousToolbarValue = Atomic<Toolbar?>(value: nil)
self.selectionDisposable = (self.contactsNode.contactListNode.selectionStateSignal
|> deliverOnMainQueue).start(next: { [weak self] state in
guard let self, let layout = self.validLayout else {
return
}
let toolbar: Toolbar?
if let state, state.selectedPeerIndices.count > 0 {
toolbar = Toolbar(leftAction: nil, rightAction: nil, middleAction: ToolbarAction(title: self.presentationData.strings.ContactList_DeleteConfirmation(Int32(state.selectedPeerIndices.count)), isEnabled: true, color: .custom(self.presentationData.theme.actionSheet.destructiveActionTextColor)))
} else {
toolbar = nil
}
let _ = self.contactsNode.updateNavigationBar(layout: layout, transition: .animated(duration: 0.2, curve: .easeInOut))
var transition: ContainedViewLayoutTransition = .immediate
let previousToolbar = previousToolbarValue.swap(toolbar)
if (previousToolbar == nil) != (toolbar == nil) {
transition = .animated(duration: 0.4, curve: .spring)
}
self.setToolbar(toolbar, transition: transition)
})
self.displayNodeDidLoad()
}
override public func toolbarActionSelected(action: ToolbarActionOption) {
guard case .middle = action, let selectionState = self.contactsNode.contactListNode.selectionState else {
return
}
var peerIds: [EnginePeer.Id] = []
for contactPeerId in selectionState.selectedPeerIndices.keys {
if case let .peer(peerId) = contactPeerId {
peerIds.append(peerId)
}
}
self.requestDeleteContacts(peerIds: peerIds)
}
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.contactsNode.didAppear = true
self.contactsNode.contactListNode.enableUpdates = true
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.contactsNode.contactListNode.enableUpdates = false
}
private func searchContentNode() -> NavigationBarSearchContentNode? {
if let navigationBarView = self.contactsNode.navigationBarView.view as? ChatListNavigationBar.View {
return navigationBarView.searchContentNode
}
return nil
}
private func chatListHeaderView() -> ChatListHeaderComponent.View? {
if let navigationBarView = self.contactsNode.navigationBarView.view as? ChatListNavigationBar.View {
if let componentView = navigationBarView.headerContent.view as? ChatListHeaderComponent.View {
return componentView
}
}
return nil
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.isInVoiceOver.set(layout.inVoiceOver)
self.validLayout = layout
self.contactsNode.containerLayoutUpdated(layout, navigationBarHeight: self.cleanNavigationHeight, actualNavigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
self.contactsNode.openStories = { [weak self] peer, sourceNode in
guard let self else {
return
}
if let itemNode = sourceNode as? ContactsPeerItemNode {
StoryContainerScreen.openPeerStories(context: self.context, peerId: peer.id, parentController: self, avatarNode: itemNode.avatarNode)
}
}
}
@objc private func sortPressed() {
self.sortButton.contextAction?(self.sortButton.containerNode, nil)
}
private func activateSearch() {
if let searchContentNode = self.searchContentNode() {
self.contactsNode.activateSearch(placeholderNode: searchContentNode.placeholderNode)
}
self.requestLayout(transition: .animated(duration: 0.5, curve: .spring))
}
private func deactivateSearch(animated: Bool) {
if let searchContentNode = self.searchContentNode() {
self.contactsNode.deactivateSearch(placeholderNode: searchContentNode.placeholderNode, animated: animated)
self.requestLayout(transition: .animated(duration: 0.5, curve: .spring))
}
}
func presentSortMenu(sourceView: UIView, gesture: ContextGesture?) {
let updateSortOrder: (ContactsSortOrder) -> Void = { [weak self] sortOrder in
if let strongSelf = self {
strongSelf.sortOrderPromise.set(.single(sortOrder))
let _ = updateContactSettingsInteractively(accountManager: strongSelf.context.sharedContext.accountManager, { current -> ContactSynchronizationSettings in
var updated = current
updated.sortOrder = sortOrder
return updated
}).start()
}
}
let presentationData = self.presentationData
let items: Signal<[ContextMenuItem], NoError> = self.context.sharedContext.accountManager.transaction { transaction in
return transaction.getSharedData(ApplicationSpecificSharedDataKeys.contactSynchronizationSettings)
}
|> map { entry -> [ContextMenuItem] in
let currentSettings: ContactSynchronizationSettings
if let entry = entry?.get(ContactSynchronizationSettings.self) {
currentSettings = entry
} else {
currentSettings = .defaultSettings
}
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: presentationData.strings.Contacts_Sort_ByLastSeen, icon: { theme in return currentSettings.sortOrder == .presence ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil }, action: { _, f in
f(.default)
updateSortOrder(.presence)
})))
items.append(.action(ContextMenuActionItem(text: presentationData.strings.Contacts_Sort_ByName, icon: { theme in return currentSettings.sortOrder == .natural ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil }, action: { _, f in
f(.default)
updateSortOrder(.natural)
})))
return items
}
let contextController = ContextController(presentationData: self.presentationData, source: .reference(HeaderContextReferenceContentSource(controller: self, sourceView: sourceView)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture)
self.presentInGlobalOverlay(contextController)
}
public func beginSelection(peerId: EnginePeer.Id) {
self.contactsNode.contactListNode.updateSelectionState { _ in
return ContactListNodeGroupSelectionState().withToggledPeerId(.peer(peerId))
}
}
public func requestDeleteContacts(peerIds: [EnginePeer.Id]) {
guard !peerIds.isEmpty else {
return
}
let actionSheet = ActionSheetController(presentationData: self.presentationData)
var items: [ActionSheetItem] = []
let actionTitle: String
if peerIds.count > 1 {
actionTitle = self.presentationData.strings.ContactList_DeleteConfirmation(Int32(peerIds.count))
} else {
actionTitle = self.presentationData.strings.ContactList_DeleteConfirmationSingle
}
items.append(ActionSheetButtonItem(title: actionTitle, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()
guard let self else {
return
}
self.contactsNode.contactListNode.updateSelectionState { _ in
return nil
}
self.contactsNode.contactListNode.updatePendingRemovalPeerIds { state in
var state = state
for peerId in peerIds {
state.insert(peerId)
}
return state
}
let text = self.presentationData.strings.ContactList_DeletedContacts(Int32(peerIds.count))
self.present(UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: self.context, title: NSAttributedString(string: text), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
guard let self else {
return false
}
if value == .commit {
let deleteContactsFromDevice: Signal<Never, NoError>
if let contactDataManager = self.context.sharedContext.contactDataManager {
deleteContactsFromDevice = combineLatest(peerIds.map { contactDataManager.deleteContactWithAppSpecificReference(peerId: $0) }
)
|> ignoreValues
} else {
deleteContactsFromDevice = .complete()
}
let deleteSignal = self.context.engine.contacts.deleteContacts(peerIds: peerIds)
|> then(deleteContactsFromDevice)
for peerId in peerIds {
deleteSendMessageIntents(peerId: peerId)
}
self.contactsNode.contactListNode.updatePendingRemovalPeerIds { state in
var state = state
for peerId in peerIds {
state.remove(peerId)
}
return state
}
let _ = deleteSignal.start()
return true
} else if value == .undo {
self.contactsNode.contactListNode.updatePendingRemovalPeerIds { state in
var state = state
for peerId in peerIds {
state.remove(peerId)
}
return state
}
return true
}
return false
}), in: .current)
}))
actionSheet.setItemGroups([
ActionSheetItemGroup(items: items),
ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])
])
self.present(actionSheet, in: .window(.root))
}
@objc func addPressed() {
let _ = (DeviceAccess.authorizationStatus(subject: .contacts)
|> take(1)
|> deliverOnMainQueue).start(next: { [weak self] status in
guard let strongSelf = self else {
return
}
switch status {
case .allowed:
if let navigationController = strongSelf.context.sharedContext.mainWindow?.viewController as? NavigationController {
let controller = strongSelf.context.sharedContext.makeNewContactScreen(
context: strongSelf.context,
peer: nil,
phoneNumber: nil,
shareViaException: false,
completion: { [weak self] peer, stableId, contactData in
guard let strongSelf = self else {
return
}
if let peer {
Queue.mainQueue().async {
if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) {
if let navigationController = strongSelf.context.sharedContext.mainWindow?.viewController as? NavigationController {
navigationController.pushViewController(infoController)
}
}
}
} else if let stableId, let contactData {
if let navigationController = strongSelf.context.sharedContext.mainWindow?.viewController as? NavigationController {
navigationController.pushViewController(strongSelf.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: strongSelf.context), environment: ShareControllerAppEnvironment(sharedContext: strongSelf.context.sharedContext), subject: .vcard(nil, stableId, contactData), completed: nil, cancelled: nil))
}
}
}
)
navigationController.pushViewController(controller)
}
case .notDetermined:
DeviceAccess.authorizeAccess(to: .contacts)
default:
let presentationData = strongSelf.presentationData
if let navigationController = strongSelf.context.sharedContext.mainWindow?.viewController as? NavigationController, let topController = navigationController.topViewController as? ViewController {
topController.present(textAlertController(context: strongSelf.context, title: presentationData.strings.AccessDenied_Title, text: presentationData.strings.Contacts_AccessDeniedError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_NotNow, action: {}), TextAlertAction(type: .genericAction, title: presentationData.strings.AccessDenied_Settings, action: {
self?.context.sharedContext.applicationBindings.openSettings()
})]), in: .window(.root))
}
}
})
}
override public func tabBarItemContextAction(sourceView: ContextExtractedContentContainingView, gesture: ContextGesture) {
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Contacts_AddContact, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddUser"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, f in
c?.dismiss(completion: { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.addPressed()
})
})))
let controller = ContextController(presentationData: self.presentationData, source: .reference(ContactsTabBarContextReferenceContentSource(controller: self, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), recognizer: nil, gesture: gesture)
self.context.sharedContext.mainWindow?.presentInGlobalOverlay(controller)
}
}
private final class ContactsTabBarContextReferenceContentSource: ContextReferenceContentSource {
let keepInPlace: Bool = true
private let controller: ViewController
private let sourceView: ContextExtractedContentContainingView
init(controller: ViewController, sourceView: ContextExtractedContentContainingView) {
self.controller = controller
self.sourceView = sourceView
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(
referenceView: self.sourceView.contentView,
contentAreaInScreenSpace: UIScreen.main.bounds,
actionsPosition: .top
)
}
}
private final class ChatListHeaderBarContextExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool
let ignoreContentTouches: Bool = true
let blurBackground: Bool = true
private let controller: ViewController
private let sourceNode: ContextExtractedContentContainingNode
init(controller: ViewController, sourceNode: ContextExtractedContentContainingNode, keepInPlace: Bool) {
self.controller = controller
self.sourceNode = sourceNode
self.keepInPlace = keepInPlace
}
func takeView() -> ContextControllerTakeViewInfo? {
return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds)
}
func putBack() -> ContextControllerPutBackViewInfo? {
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
@@ -0,0 +1,599 @@
import Display
import UIKit
import AsyncDisplayKit
import UIKit
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import DeviceAccess
import AccountContext
import SearchBarNode
import SearchUI
import AppBundle
import ContextUI
import ChatListHeaderComponent
import ChatListTitleView
import ComponentFlow
import SwiftUI
import ContactsUI
import EdgeEffect
private final class ContextControllerContentSourceImpl: ContextControllerContentSource {
let controller: ViewController
weak var sourceNode: ASDisplayNode?
let navigationController: NavigationController? = nil
let passthroughTouches: Bool = true
init(controller: ViewController, sourceNode: ASDisplayNode?) {
self.controller = controller
self.sourceNode = sourceNode
}
func transitionInfo() -> ContextControllerTakeControllerInfo? {
let sourceNode = self.sourceNode
return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceNode] in
if let sourceNode = sourceNode {
return (sourceNode.view, sourceNode.bounds)
} else {
return nil
}
})
}
func animatedIn() {
}
}
final class ContactsControllerNode: ASDisplayNode, ASGestureRecognizerDelegate {
let contactListNode: ContactListNode
private let edgeEffectView: EdgeEffectView
private let context: AccountContext
private(set) var searchDisplayController: SearchDisplayController?
private var isSearchDisplayControllerActive: Bool = false
private var storiesUnlocked: Bool = false
private var containerLayout: (ContainerViewLayout, CGFloat)?
var navigationBar: NavigationBar?
let navigationBarView = ComponentView<Empty>()
var requestDeactivateSearch: (() -> Void)?
var requestOpenPeerFromSearch: ((ContactListPeer) -> Void)?
var requestOpenDisabledPeerFromSearch: ((EnginePeer, ChatListDisabledPeerReason) -> Void)?
var requestAddContact: ((String) -> Void)?
var openPeopleNearby: (() -> Void)?
var openInvite: (() -> Void)?
var openQrScan: (() -> Void)?
var openStories: ((EnginePeer, ASDisplayNode) -> Void)?
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
private let stringsPromise = Promise<PresentationStrings>()
weak var controller: ContactsController?
private var initialScrollingOffset: CGFloat?
private var isSettingUpContentOffset: Bool = false
private var didSetupContentOffset: Bool = false
private var contentOffset: ListViewVisibleContentOffset?
private var ignoreStoryInsetAdjustment: Bool = false
var didAppear: Bool = false
private(set) var storySubscriptions: EngineStorySubscriptions?
private var storySubscriptionsDisposable: Disposable?
let storiesReady = Promise<Bool>()
private var panRecognizer: InteractiveTransitionGestureRecognizer?
init(context: AccountContext, sortOrder: Signal<ContactsSortOrder, NoError>, present: @escaping (ViewController, Any?) -> Void, controller: ContactsController) {
self.context = context
self.controller = controller
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.stringsPromise.set(.single(self.presentationData.strings))
var inviteImpl: (() -> Void)?
let presentation = combineLatest(sortOrder, self.stringsPromise.get())
|> map { sortOrder, strings -> ContactListPresentation in
let options = [ContactListAdditionalOption(title: strings.Contacts_InviteFriends, icon: .generic(UIImage(bundleImageName: "Contact List/AddMemberIcon")!), action: {
inviteImpl?()
})]
switch sortOrder {
case .presence:
return .orderedByPresence(options: options)
case .natural:
return .natural(options: options, includeChatList: false, topPeers: .none)
}
}
var contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?, Bool) -> Void)?
self.contactListNode = ContactListNode(context: context, presentation: presentation, onlyWriteable: false, isGroupInvitation: false, displaySortOptions: true, contextAction: { peer, node, gesture, location, isStories in
contextAction?(peer, node, gesture, location, isStories)
})
self.edgeEffectView = EdgeEffectView()
super.init()
self.setViewBlock({
return UITracingLayerView()
})
self.backgroundColor = self.presentationData.theme.chatList.backgroundColor
self.addSubnode(self.contactListNode)
self.view.addSubview(self.edgeEffectView)
self.presentationDataDisposable = (context.sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
if let strongSelf = self {
let previousTheme = strongSelf.presentationData.theme
let previousStrings = strongSelf.presentationData.strings
strongSelf.presentationData = presentationData
if previousStrings.baseLanguageCode != presentationData.strings.baseLanguageCode {
strongSelf.stringsPromise.set(.single(presentationData.strings))
}
if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings {
strongSelf.updateThemeAndStrings()
}
}
}).strict()
inviteImpl = { [weak self] in
if let strongSelf = self {
strongSelf.openInvite?()
}
}
contextAction = { [weak self] peer, node, gesture, location, isStories in
self?.contextAction(peer: peer, node: node, gesture: gesture, location: location, isStories: isStories)
}
self.contactListNode.contentOffsetChanged = { [weak self] offset in
guard let self else {
return
}
if self.isSettingUpContentOffset {
return
}
if !self.didSetupContentOffset, let initialScrollingOffset = self.initialScrollingOffset {
self.initialScrollingOffset = nil
self.didSetupContentOffset = true
self.isSettingUpContentOffset = true
let _ = self.contactListNode.listNode.scrollToOffsetFromTop(initialScrollingOffset, animated: false)
let offset = self.contactListNode.listNode.visibleContentOffset()
self.contentOffset = offset
self.contentOffsetChanged(offset: offset)
self.isSettingUpContentOffset = false
return
}
self.contentOffset = offset
self.contentOffsetChanged(offset: offset)
/*if self.contactListNode.listNode.isTracking {
if case let .known(value) = offset {
if !self.storiesUnlocked {
if value < -40.0 {
self.storiesUnlocked = true
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
HapticFeedback().impact()
self.contactListNode.ignoreStoryInsetAdjustment = true
self.contactListNode.listNode.allowInsetFixWhileTracking = true
self.onStoriesLockedUpdated(isLocked: true)
self.contactListNode.ignoreStoryInsetAdjustment = false
self.contactListNode.listNode.allowInsetFixWhileTracking = false
}
}
}
}
} else if self.storiesUnlocked {
switch offset {
case let .known(value):
if value >= ChatListNavigationBar.storiesScrollHeight {
self.storiesUnlocked = false
DispatchQueue.main.async { [weak self] in
self?.onStoriesLockedUpdated(isLocked: false)
}
}
default:
break
}
}*/
}
self.contactListNode.contentScrollingEnded = { [weak self] listView in
guard let self else {
return false
}
return self.contentScrollingEnded(listView: listView)
}
self.contactListNode.storySubscriptions.set(.single(nil))
self.storiesReady.set(.single(true))
/*self.storySubscriptionsDisposable = (self.context.engine.messages.storySubscriptions(isHidden: true)
|> deliverOnMainQueue).start(next: { [weak self] storySubscriptions in
guard let self else {
return
}
self.storySubscriptions = storySubscriptions
self.contactListNode.storySubscriptions.set(.single(storySubscriptions))
self.storiesReady.set(.single(true))
}).strict()*/
self.contactListNode.openStories = { [weak self] peer, sourceNode in
guard let self else {
return
}
self.openStories?(peer, sourceNode)
}
self.contactListNode.openContactAccessPicker = {
presentContactAccessPicker(context: context)
}
}
deinit {
self.presentationDataDisposable?.dispose()
self.storySubscriptionsDisposable?.dispose()
}
private func updateThemeAndStrings() {
self.backgroundColor = self.presentationData.theme.chatList.backgroundColor
self.searchDisplayController?.updatePresentationData(self.presentationData)
}
func scrollToTop() {
if let contentNode = self.searchDisplayController?.contentNode as? ContactsSearchContainerNode {
contentNode.scrollToTop()
} else {
self.contactListNode.scrollToTop()
}
}
private func onStoriesLockedUpdated(isLocked: Bool) {
self.controller?.requestLayout(transition: .animated(duration: 0.4, curve: .spring))
}
private func contentOffsetChanged(offset: ListViewVisibleContentOffset) {
self.updateNavigationScrolling(transition: .immediate)
}
private func contentScrollingEnded(listView: ListView) -> Bool {
if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View {
if let clippedScrollOffset = navigationBarComponentView.clippedScrollOffset {
if clippedScrollOffset > 0.0 && clippedScrollOffset < ChatListNavigationBar.searchScrollHeight {
if clippedScrollOffset < ChatListNavigationBar.searchScrollHeight * 0.5 {
let _ = listView.scrollToOffsetFromTop(0.0, animated: true)
} else {
let _ = listView.scrollToOffsetFromTop(ChatListNavigationBar.searchScrollHeight, animated: true)
}
return true
}
}
}
return false
}
func updateNavigationBar(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) -> (navigationHeight: CGFloat, storiesInset: CGFloat) {
let tabsNode: ASDisplayNode? = nil
let tabsNodeIsSearch = false
let title: String
let leftButton: AnyComponentWithIdentity<NavigationButtonComponentEnvironment>?
let rightButtons: [AnyComponentWithIdentity<NavigationButtonComponentEnvironment>]
if let selectionState = self.contactListNode.selectionState {
title = self.presentationData.strings.Contacts_SelectedContacts(Int32(selectionState.selectedPeerIndices.count))
leftButton = AnyComponentWithIdentity(id: "done", component: AnyComponent(NavigationButtonComponent(
content: .text(title: self.presentationData.strings.Common_Done, isBold: true),
pressed: { [weak self] sourceView in
guard let self else {
return
}
self.contactListNode.updateSelectionState { _ in
return nil
}
}
)))
rightButtons = []
} else {
title = self.presentationData.strings.Contacts_Title
leftButton = AnyComponentWithIdentity(id: "sort", component: AnyComponent(NavigationButtonComponent(
content: .text(title: self.presentationData.strings.Contacts_Sort, isBold: false),
pressed: { [weak self] sourceView in
guard let self else {
return
}
self.controller?.presentSortMenu(sourceView: sourceView, gesture: nil)
}
)))
rightButtons = [AnyComponentWithIdentity(id: "add", component: AnyComponent(NavigationButtonComponent(
content: .icon(imageName: "Chat List/AddIcon"),
pressed: { [weak self] _ in
guard let self else {
return
}
self.controller?.addPressed()
}
)))]
}
let primaryContent = ChatListHeaderComponent.Content(
title: self.presentationData.strings.Contacts_Title,
navigationBackTitle: nil,
titleComponent: nil,
chatListTitle: NetworkStatusTitle(text: title, activity: false, hasProxy: false, connectsViaProxy: false, isPasscodeSet: false, isManuallyLocked: false, peerStatus: nil),
leftButton: leftButton,
rightButtons: rightButtons,
backTitle: nil,
backPressed: nil
)
let navigationBarSize = self.navigationBarView.update(
transition: ComponentTransition(transition),
component: AnyComponent(ChatListNavigationBar(
context: self.context,
theme: self.presentationData.theme,
strings: self.presentationData.strings,
statusBarHeight: layout.statusBarHeight ?? 0.0,
sideInset: layout.safeInsets.left,
isSearchActive: self.isSearchDisplayControllerActive,
isSearchEnabled: true,
primaryContent: primaryContent,
secondaryContent: nil,
secondaryTransition: 0.0,
storySubscriptions: nil,
storiesIncludeHidden: true,
uploadProgress: [:],
tabsNode: tabsNode,
tabsNodeIsSearch: tabsNodeIsSearch,
accessoryPanelContainer: nil,
accessoryPanelContainerHeight: 0.0,
activateSearch: { [weak self] searchContentNode in
guard let self else {
return
}
self.contactListNode.activateSearch?()
},
openStatusSetup: { _ in
},
allowAutomaticOrder: {
}
)),
environment: {},
containerSize: layout.size
)
if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View {
navigationBarComponentView.deferScrollApplication = true
if navigationBarComponentView.superview == nil {
self.view.addSubview(navigationBarComponentView)
}
transition.updateFrame(view: navigationBarComponentView, frame: CGRect(origin: CGPoint(), size: navigationBarSize))
return (navigationBarSize.height, 0.0)
} else {
return (0.0, 0.0)
}
}
private func getEffectiveNavigationScrollingOffset() -> CGFloat {
let mainOffset: CGFloat
if let contentOffset = self.contentOffset, case let .known(value) = contentOffset {
mainOffset = value
} else {
mainOffset = 1000.0
}
return mainOffset
}
private func updateNavigationScrolling(transition: ContainedViewLayoutTransition) {
var offset = self.getEffectiveNavigationScrollingOffset()
if self.isSearchDisplayControllerActive {
offset = 0.0
}
if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View {
navigationBarComponentView.applyScroll(offset: offset, allowAvatarsExpansion: false, transition: ComponentTransition(transition))
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, actualNavigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.containerLayout = (layout, navigationBarHeight)
let navigationBarLayout = self.updateNavigationBar(layout: layout, transition: transition)
self.initialScrollingOffset = 0.0//ChatListNavigationBar.searchScrollHeight + navigationBarLayout.storiesInset
var insets = layout.insets(options: [.input])
insets.top += navigationBarLayout.navigationHeight
var headerInsets = layout.insets(options: [.input])
headerInsets.top = navigationBarLayout.navigationHeight - navigationBarLayout.storiesInset - ChatListNavigationBar.searchScrollHeight
let innerLayout = ContainerViewLayout(size: layout.size, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, intrinsicInsets: insets, safeInsets: layout.safeInsets, additionalInsets: layout.additionalInsets, statusBarHeight: layout.statusBarHeight, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: layout.inputHeightIsInteractivellyChanging, inVoiceOver: layout.inVoiceOver)
if let searchDisplayController = self.searchDisplayController {
searchDisplayController.containerLayoutUpdated(innerLayout, navigationBarHeight: navigationBarLayout.navigationHeight, transition: transition)
}
self.contactListNode.containerLayoutUpdated(innerLayout, headerInsets: headerInsets, storiesInset: navigationBarLayout.storiesInset, transition: transition)
self.contactListNode.frame = CGRect(origin: CGPoint(), size: layout.size)
let edgeEffectHeight: CGFloat = layout.intrinsicInsets.bottom
let edgeEffectFrame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - edgeEffectHeight), size: CGSize(width: layout.size.width, height: edgeEffectHeight))
transition.updateFrame(view: self.edgeEffectView, frame: edgeEffectFrame)
self.edgeEffectView.update(content: self.presentationData.theme.list.plainBackgroundColor, rect: edgeEffectFrame, edge: .bottom, edgeSize: edgeEffectFrame.height, transition: ComponentTransition(transition))
self.updateNavigationScrolling(transition: transition)
if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View {
navigationBarComponentView.deferScrollApplication = false
navigationBarComponentView.applyCurrentScroll(transition: ComponentTransition(transition))
}
}
private func contextAction(peer: EnginePeer, node: ASDisplayNode?, gesture: ContextGesture?, location: CGPoint?, isStories: Bool) {
guard let contactsController = self.controller else {
return
}
let items = contactContextMenuItems(context: self.context, peerId: peer.id, contactsController: contactsController, isStories: isStories) |> map { ContextController.Items(content: .list($0)) }
if isStories, let node = node?.subnodes?.first(where: { $0 is ContextExtractedContentContainingNode }) as? ContextExtractedContentContainingNode {
let controller = ContextController(presentationData: self.presentationData, source: .extracted(ContactContextExtractedContentSource(sourceNode: node, shouldBeDismissed: .single(false))), items: items, recognizer: nil, gesture: gesture)
contactsController.presentInGlobalOverlay(controller)
} else {
let chatController = self.context.sharedContext.makeChatController(context: self.context, chatLocation: .peer(id: peer.id), subject: nil, botStart: nil, mode: .standard(.previewing), params: nil)
chatController.canReadHistory.set(false)
let contextController = ContextController(presentationData: self.presentationData, source: .controller(ContextControllerContentSourceImpl(controller: chatController, sourceNode: node)), items: items, gesture: gesture)
contactsController.presentInGlobalOverlay(contextController)
}
}
func activateSearch(placeholderNode: SearchBarPlaceholderNode) {
guard let (containerLayout, navigationBarHeight) = self.containerLayout, self.searchDisplayController == nil else {
return
}
self.isSearchDisplayControllerActive = true
self.storiesUnlocked = false
self.searchDisplayController = SearchDisplayController(presentationData: self.presentationData, mode: .list, contentNode: ContactsSearchContainerNode(context: self.context, onlyWriteable: false, categories: [.cloudContacts, .global, .deviceContacts], addContact: { [weak self] phoneNumber in
if let requestAddContact = self?.requestAddContact {
requestAddContact(phoneNumber)
}
}, openPeer: { [weak self] peer, _ in
if let requestOpenPeerFromSearch = self?.requestOpenPeerFromSearch {
requestOpenPeerFromSearch(peer)
}
}, openDisabledPeer: { [weak self] peer, reason in
if let requestOpenDisabledPeerFromSearch = self?.requestOpenDisabledPeerFromSearch {
requestOpenDisabledPeerFromSearch(peer, reason)
}
}, contextAction: { [weak self] peer, node, gesture, location in
self?.contextAction(peer: peer, node: node, gesture: gesture, location: location, isStories: false)
}), cancel: { [weak self] in
if let requestDeactivateSearch = self?.requestDeactivateSearch {
requestDeactivateSearch()
}
})
self.searchDisplayController?.containerLayoutUpdated(containerLayout, navigationBarHeight: navigationBarHeight, transition: .immediate)
self.searchDisplayController?.activate(insertSubnode: { [weak self] subnode, isSearchBar in
if let strongSelf = self {
if isSearchBar {
if let navigationBarComponentView = strongSelf.navigationBarView.view as? ChatListNavigationBar.View {
navigationBarComponentView.addSubnode(subnode)
}
} else {
strongSelf.insertSubnode(subnode, aboveSubnode: strongSelf.contactListNode)
}
}
}, placeholder: placeholderNode)
}
func deactivateSearch(placeholderNode: SearchBarPlaceholderNode, animated: Bool) {
self.isSearchDisplayControllerActive = false
if let searchDisplayController = self.searchDisplayController {
let previousFrame = placeholderNode.frame
placeholderNode.frame = previousFrame.offsetBy(dx: 0.0, dy: 54.0)
searchDisplayController.deactivate(placeholder: placeholderNode, animated: animated)
self.searchDisplayController = nil
placeholderNode.frame = previousFrame
}
}
}
private final class ContactContextExtractedContentSource: ContextExtractedContentSource {
let keepInPlace: Bool = false
let ignoreContentTouches: Bool = true
let blurBackground: Bool = true
let shouldBeDismissed: Signal<Bool, NoError>
private let sourceNode: ContextExtractedContentContainingNode
init(sourceNode: ContextExtractedContentContainingNode, shouldBeDismissed: Signal<Bool, NoError>? = nil) {
self.sourceNode = sourceNode
self.shouldBeDismissed = shouldBeDismissed ?? .single(false)
}
func takeView() -> ContextControllerTakeViewInfo? {
return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds)
}
func putBack() -> ContextControllerPutBackViewInfo? {
return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
private func presentContactAccessPicker(context: AccountContext) {
if #available(iOS 18.0, *), let rootViewController = context.sharedContext.mainWindow?.viewController?.view.window?.rootViewController {
var dismissImpl: (() -> Void)?
let pickerView = ContactAccessPickerHostingView(completionHandler: { [weak rootViewController] ids in
DispatchQueue.main.async(execute: {
guard let presentedController = rootViewController?.presentedViewController, presentedController.isBeingDismissed == false else { return }
dismissImpl?()
})
})
let hostingController = UIHostingController(rootView: pickerView)
hostingController.view.isHidden = true
hostingController.modalPresentationStyle = .overCurrentContext
rootViewController.present(hostingController, animated: true)
dismissImpl = { [weak hostingController] in
Queue.mainQueue().after(0.4, {
hostingController?.dismiss(animated: false)
})
}
}
}
@available(iOS 18.0, *)
struct ContactAccessPickerHostingView: View {
@State var presented = true
var handler: ([String]) -> ()
init(completionHandler: @escaping ([String]) -> ()) {
self.handler = completionHandler
}
var body: some View {
Spacer()
.contactAccessPicker(isPresented: $presented, completionHandler: handler)
.onChange(of: presented) { newValue in
if newValue == false {
handler([])
}
}
}
}
@@ -0,0 +1,815 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import MergeLists
import AccountContext
import SearchUI
import ChatListSearchItemHeader
import ContactsPeerItem
import ContextUI
import PhoneNumberFormat
import ItemListUI
import AnimatedStickerNode
import TelegramAnimatedStickerNode
import ComponentFlow
import SearchInputPanelComponent
private enum ContactListSearchGroup {
case contacts
case global
case deviceContacts
}
private enum ContactListSearchEntryId: Hashable {
case addContact
case peerId(ContactListPeerId)
static func <(lhs: ContactListSearchEntryId, rhs: ContactListSearchEntryId) -> Bool {
return lhs.hashValue < rhs.hashValue
}
static func ==(lhs: ContactListSearchEntryId, rhs: ContactListSearchEntryId) -> Bool {
switch lhs {
case .addContact:
switch rhs {
case .addContact:
return true
default:
return false
}
case let .peerId(lhsId):
switch rhs {
case let .peerId(rhsId):
return lhsId == rhsId
default:
return false
}
}
}
}
private enum ContactListSearchEntry: Comparable, Identifiable {
case addContact(theme: PresentationTheme, strings: PresentationStrings, phoneNumber: String)
case peer(index: Int, theme: PresentationTheme, strings: PresentationStrings, peer: ContactListPeer, presence: EnginePeer.Presence?, group: ContactListSearchGroup, enabled: Bool, requiresPremiumForMessaging: Bool, displayCallIcons: Bool)
var stableId: ContactListSearchEntryId {
switch self {
case .addContact:
return .addContact
case let .peer(_, _, _, peer, _, _, _, _, _):
return .peerId(peer.id)
}
}
static func ==(lhs: ContactListSearchEntry, rhs: ContactListSearchEntry) -> Bool {
switch lhs {
case let .addContact(lhsTheme, lhsStrings, lhsPhoneNumber):
if case let .addContact(rhsTheme, rhsStrings, rhsPhoneNumber) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings, lhsPhoneNumber == rhsPhoneNumber {
return true
} else {
return false
}
case let .peer(lhsIndex, lhsTheme, lhsStrings, lhsPeer, lhsPresence, lhsGroup, lhsEnabled, lhsRequiresPremiumForMessaging, lhsDisplayCallIcons):
switch rhs {
case let .peer(rhsIndex, rhsTheme, rhsStrings, rhsPeer, rhsPresence, rhsGroup, rhsEnabled, rhsRequiresPremiumForMessaging, rhsDisplayCallIcons):
if lhsIndex != rhsIndex {
return false
}
if lhsTheme !== rhsTheme {
return false
}
if lhsStrings !== rhsStrings {
return false
}
if lhsPeer != rhsPeer {
return false
}
if let lhsPresence = lhsPresence, let rhsPresence = rhsPresence {
if lhsPresence != rhsPresence {
return false
}
} else if (lhsPresence != nil) != (rhsPresence != nil) {
return false
}
if lhsGroup != rhsGroup {
return false
}
if lhsEnabled != rhsEnabled {
return false
}
if lhsRequiresPremiumForMessaging != rhsRequiresPremiumForMessaging {
return false
}
if lhsDisplayCallIcons != rhsDisplayCallIcons {
return false
}
return true
default:
return false
}
}
}
static func <(lhs: ContactListSearchEntry, rhs: ContactListSearchEntry) -> Bool {
switch lhs {
case .addContact:
return true
case let .peer(lhsIndex, _, _, _, _, _, _, _, _):
switch rhs {
case .addContact:
return false
case let .peer(rhsIndex, _, _, _, _, _, _, _, _):
return lhsIndex < rhsIndex
}
}
}
func item(context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, timeFormat: PresentationDateTimeFormat, isPeerEnabled: @escaping (ContactListPeer) -> Bool, addContact: ((String) -> Void)?, openPeer: @escaping (ContactListPeer, ContactsSearchContainerNode.OpenPeerAction) -> Void, openDisabledPeer: @escaping (EnginePeer, ChatListDisabledPeerReason) -> Void, contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?) -> ListViewItem {
switch self {
case let .addContact(theme, strings, phoneNumber):
return ContactsAddItem(context: context, theme: theme, strings: strings, phoneNumber: phoneNumber, header: ChatListSearchItemHeader(type: .phoneNumber, theme: theme, strings: strings, actionTitle: nil, action: nil), action: {
addContact?(phoneNumber)
})
case let .peer(_, theme, strings, peer, presence, group, enabled, requiresPremiumForMessaging, displayCallIcons):
let header: ListViewItemHeader
let status: ContactsPeerItemStatus
switch group {
case .contacts:
header = ChatListSearchItemHeader(type: .contacts, theme: theme, strings: strings, actionTitle: nil, action: nil)
if let presence = presence {
status = .presence(presence, timeFormat)
} else {
status = .none
}
case .global:
header = ChatListSearchItemHeader(type: .globalPeers, theme: theme, strings: strings, actionTitle: nil, action: nil)
if case let .peer(peer, _, _) = peer, let _ = peer.addressName {
status = .addressName("")
} else {
status = .none
}
case .deviceContacts:
header = ChatListSearchItemHeader(type: .deviceContacts, theme: theme, strings: strings, actionTitle: nil, action: nil)
status = .none
}
var nativePeer: EnginePeer?
let peerItem: ContactsPeerItemPeer
switch peer {
case let .peer(peer, _, _):
peerItem = .peer(peer: EnginePeer(peer), chatPeer: EnginePeer(peer))
nativePeer = EnginePeer(peer)
case let .deviceContact(stableId, contact):
peerItem = .deviceContact(stableId: stableId, contact: contact)
}
var additionalActions: [ContactsPeerItemAction] = []
if displayCallIcons {
additionalActions = [ContactsPeerItemAction(icon: .voiceCall, action: { _, sourceNode, gesture in
openPeer(peer, .voiceCall)
}), ContactsPeerItemAction(icon: .videoCall, action: { _, sourceNode, gesture in
openPeer(peer, .videoCall)
})]
}
return ContactsPeerItem(presentationData: ItemListPresentationData(presentationData), sortOrder: nameSortOrder, displayOrder: nameDisplayOrder, context: context, peerMode: .peer, peer: peerItem, status: status, requiresPremiumForMessaging: requiresPremiumForMessaging, enabled: enabled && isPeerEnabled(peer), selection: .none, editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), additionalActions: additionalActions, index: nil, header: header, action: { _ in
openPeer(peer, .generic)
}, disabledAction: { _ in
if case let .peer(peer, _, _) = peer {
openDisabledPeer(EnginePeer(peer), requiresPremiumForMessaging ? .premiumRequired : .generic)
}
}, contextAction: contextAction.flatMap { contextAction in
return nativePeer.flatMap { nativePeer in
return { node, gesture, location in
contextAction(nativePeer, node, gesture, location)
}
}
})
}
}
}
struct ContactListSearchContainerTransition {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
let isSearching: Bool
let emptyResults: Bool
let query: String
}
private func contactListSearchContainerPreparedRecentTransition(from fromEntries: [ContactListSearchEntry], to toEntries: [ContactListSearchEntry], isSearching: Bool, emptyResults: Bool, query: String, context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, timeFormat: PresentationDateTimeFormat, isPeerEnabled: @escaping (ContactListPeer) -> Bool, addContact: ((String) -> Void)?, openPeer: @escaping (ContactListPeer, ContactsSearchContainerNode.OpenPeerAction) -> Void, openDisabledPeer: @escaping (EnginePeer, ChatListDisabledPeerReason) -> Void, contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?) -> ContactListSearchContainerTransition {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, timeFormat: timeFormat, isPeerEnabled: isPeerEnabled, addContact: addContact, openPeer: openPeer, openDisabledPeer: openDisabledPeer, contextAction: contextAction), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, timeFormat: timeFormat, isPeerEnabled: isPeerEnabled, addContact: addContact, openPeer: openPeer, openDisabledPeer: openDisabledPeer, contextAction: contextAction), directionHint: nil) }
return ContactListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, emptyResults: emptyResults, query: query)
}
public struct ContactsSearchCategories: OptionSet {
public var rawValue: Int32
public init(rawValue: Int32) {
self.rawValue = rawValue
}
public static let cloudContacts = ContactsSearchCategories(rawValue: 1 << 0)
public static let global = ContactsSearchCategories(rawValue: 1 << 1)
public static let deviceContacts = ContactsSearchCategories(rawValue: 1 << 2)
public static let channels = ContactsSearchCategories(rawValue: 1 << 3)
}
public final class ContactsSearchContainerNode: SearchDisplayControllerContentNode {
public enum OpenPeerAction {
case generic
case voiceCall
case videoCall
}
private let context: AccountContext
private let glass: Bool
private let isPeerEnabled: (ContactListPeer) -> Bool
private let addContact: ((String) -> Void)?
private let openPeer: (ContactListPeer, ContactsSearchContainerNode.OpenPeerAction) -> Void
private let openDisabledPeer: (EnginePeer, ChatListDisabledPeerReason) -> Void
private let contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?
private let dimNode: ASDisplayNode
private let backgroundNode: ASDisplayNode
public let listNode: ListView
private let emptyResultsTitleNode: ImmediateTextNode
private let emptyResultsTextNode: ImmediateTextNode
private let emptyResultsAnimationNode: AnimatedStickerNode
private var emptyResultsAnimationSize: CGSize = CGSize()
private let searchQuery = Promise<String?>()
private let searchDisposable = MetaDisposable()
private var presentationData: PresentationData
private let themeAndStringsPromise: Promise<(PresentationTheme, PresentationStrings)>
private var containerViewLayout: (ContainerViewLayout, CGFloat)?
private var enqueuedTransitions: [ContactListSearchContainerTransition] = []
private let searchInput = ComponentView<Empty>()
public override var hasDim: Bool {
return true
}
public init(
context: AccountContext,
glass: Bool = false,
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil,
onlyWriteable: Bool,
categories: ContactsSearchCategories,
filters: [ContactListFilter] = [.excludeSelf],
displayCallIcons: Bool = false,
isPeerEnabled: @escaping (ContactListPeer) -> Bool = { _ in true },
addContact: ((String) -> Void)?,
openPeer: @escaping (ContactListPeer, ContactsSearchContainerNode.OpenPeerAction) -> Void,
openDisabledPeer: @escaping (EnginePeer, ChatListDisabledPeerReason) -> Void,
contextAction: ((EnginePeer, ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?
) {
self.context = context
self.glass = glass
self.isPeerEnabled = isPeerEnabled
self.addContact = addContact
self.openPeer = openPeer
self.openDisabledPeer = openDisabledPeer
self.contextAction = contextAction
let presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 }
self.presentationData = presentationData
self.themeAndStringsPromise = Promise((self.presentationData.theme, self.presentationData.strings))
self.dimNode = ASDisplayNode()
self.dimNode.backgroundColor = glass ? .clear : UIColor.black.withAlphaComponent(0.5)
self.backgroundNode = ASDisplayNode()
self.backgroundNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.backgroundNode.alpha = 0.0
self.listNode = ListView()
self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.listNode.alpha = 0.0
self.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
self.emptyResultsTitleNode = ImmediateTextNode()
self.emptyResultsTitleNode.displaysAsynchronously = false
self.emptyResultsTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.Contacts_Search_NoResults, font: Font.semibold(17.0), textColor: self.presentationData.theme.list.freeTextColor)
self.emptyResultsTitleNode.textAlignment = .center
self.emptyResultsTitleNode.alpha = 0.0
self.emptyResultsTitleNode.isUserInteractionEnabled = false
self.emptyResultsTextNode = ImmediateTextNode()
self.emptyResultsTextNode.displaysAsynchronously = false
self.emptyResultsTextNode.maximumNumberOfLines = 0
self.emptyResultsTextNode.textAlignment = .center
self.emptyResultsTextNode.alpha = 0.0
self.emptyResultsTextNode.isUserInteractionEnabled = false
self.emptyResultsAnimationNode = DefaultAnimatedStickerNodeImpl()
self.emptyResultsAnimationNode.alpha = 0.0
self.emptyResultsAnimationNode.isUserInteractionEnabled = false
super.init()
self.emptyResultsAnimationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: "ChatListNoResults"), width: 256, height: 256, playbackMode: .once, mode: .direct(cachePathPrefix: nil))
self.emptyResultsAnimationSize = CGSize(width: 148.0, height: 148.0)
self.backgroundColor = nil
self.isOpaque = false
self.addSubnode(self.dimNode)
self.addSubnode(self.backgroundNode)
self.addSubnode(self.listNode)
self.addSubnode(self.emptyResultsAnimationNode)
self.addSubnode(self.emptyResultsTitleNode)
self.addSubnode(self.emptyResultsTextNode)
let themeAndStringsPromise = self.themeAndStringsPromise
let previousFoundRemoteContacts = Atomic<([FoundPeer], [FoundPeer])?>(value: nil)
let searchItems = self.searchQuery.get()
|> mapToSignal { query -> Signal<([ContactListSearchEntry]?, String), NoError> in
if let query = query, !query.isEmpty {
let foundLocalContacts: Signal<([EnginePeer], [EnginePeer.Id: EnginePeer.Presence]), NoError>
if categories.contains(.cloudContacts) {
foundLocalContacts = context.engine.contacts.searchContacts(query: query.lowercased())
} else {
foundLocalContacts = .single(([], [:]))
}
let foundRemoteContacts: Signal<([FoundPeer], [FoundPeer])?, NoError>
if categories.contains(.global) {
foundRemoteContacts = .single(previousFoundRemoteContacts.with({ $0 }))
|> then(
context.engine.contacts.searchRemotePeers(query: query)
|> map { ($0.0, $0.1) }
|> delay(0.2, queue: Queue.concurrentDefaultQueue())
)
} else {
foundRemoteContacts = .single(([], []))
}
let searchDeviceContacts = categories.contains(.deviceContacts)
let foundDeviceContacts: Signal<[DeviceContactStableId: (DeviceContactBasicData, EnginePeer.Id?)]?, NoError>
if searchDeviceContacts, let contactDataManager = context.sharedContext.contactDataManager {
foundDeviceContacts = contactDataManager.search(query: query)
|> map(Optional.init)
} else {
foundDeviceContacts = .single([:])
}
struct FoundPeers {
var foundLocalContacts: ([EnginePeer], [EnginePeer.Id: EnginePeer.Presence])
var foundRemoteContacts: ([FoundPeer], [FoundPeer])?
}
let foundPeers = Promise<FoundPeers>()
foundPeers.set(combineLatest(
foundLocalContacts,
foundRemoteContacts
)
|> map { foundLocalContacts, foundRemoteContacts -> FoundPeers in
return FoundPeers(
foundLocalContacts: foundLocalContacts,
foundRemoteContacts: foundRemoteContacts
)
})
let peerRequiresPremiumForMessaging: Signal<[EnginePeer.Id: Bool], NoError>
if onlyWriteable {
peerRequiresPremiumForMessaging = foundPeers.get()
|> map { foundPeers -> Set<EnginePeer.Id> in
var result = Set<EnginePeer.Id>()
for peer in foundPeers.foundLocalContacts.0 {
if case let .user(user) = peer, user.flags.contains(.requirePremium) {
result.insert(user.id)
}
}
if let foundRemoteContacts = foundPeers.foundRemoteContacts {
for peer in foundRemoteContacts.0 {
if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium) {
result.insert(user.id)
}
}
for peer in foundRemoteContacts.1 {
if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium) {
result.insert(user.id)
}
}
}
return result
}
|> distinctUntilChanged
|> mapToSignal { peerIds -> Signal<[EnginePeer.Id: Bool], NoError> in
return context.engine.data.subscribe(
EngineDataMap(
peerIds.map(TelegramEngine.EngineData.Item.Peer.IsPremiumRequiredForMessaging.init(id:))
)
)
}
} else {
peerRequiresPremiumForMessaging = .single([:])
}
return combineLatest(foundPeers.get(), peerRequiresPremiumForMessaging, foundDeviceContacts, themeAndStringsPromise.get())
|> delay(0.1, queue: Queue.concurrentDefaultQueue())
|> map { foundPeers, peerRequiresPremiumForMessaging, deviceContacts, themeAndStrings -> ([ContactListSearchEntry], String) in
let localPeersAndPresences = foundPeers.foundLocalContacts
let remotePeers = foundPeers.foundRemoteContacts
if !peerRequiresPremiumForMessaging.isEmpty {
context.account.viewTracker.refreshCanSendMessagesForPeerIds(peerIds: Array(peerRequiresPremiumForMessaging.keys))
}
let _ = previousFoundRemoteContacts.swap(remotePeers)
var entries: [ContactListSearchEntry] = []
var existingPeerIds = Set<EnginePeer.Id>()
var disabledPeerIds = Set<EnginePeer.Id>()
var requirePhoneNumbers = false
var excludeBots = false
for filter in filters {
switch filter {
case .excludeSelf:
existingPeerIds.insert(context.account.peerId)
case let .exclude(peerIds):
existingPeerIds = existingPeerIds.union(peerIds)
case let .disable(peerIds):
disabledPeerIds = disabledPeerIds.union(peerIds)
case .excludeWithoutPhoneNumbers:
requirePhoneNumbers = true
case .excludeBots:
excludeBots = true
}
}
var existingNormalizedPhoneNumbers = Set<DeviceContactNormalizedPhoneNumber>()
var index = 0
for peer in localPeersAndPresences.0 {
if existingPeerIds.contains(peer.id) {
continue
}
if case let .user(user) = peer {
if requirePhoneNumbers {
let phone = user.phone ?? ""
if phone.isEmpty {
continue
}
}
if excludeBots {
if user.botInfo != nil {
continue
}
}
}
existingPeerIds.insert(peer.id)
var enabled = true
var requiresPremiumForMessaging = false
if onlyWriteable {
enabled = canSendMessagesToPeer(peer._asPeer())
if let value = peerRequiresPremiumForMessaging[peer.id], value {
requiresPremiumForMessaging = true
enabled = false
}
}
entries.append(.peer(index: index, theme: themeAndStrings.0, strings: themeAndStrings.1, peer: .peer(peer: peer._asPeer(), isGlobal: false, participantCount: nil), presence: localPeersAndPresences.1[peer.id], group: .contacts, enabled: enabled, requiresPremiumForMessaging: requiresPremiumForMessaging, displayCallIcons: displayCallIcons))
if searchDeviceContacts, case let .user(user) = peer, let phone = user.phone {
existingNormalizedPhoneNumbers.insert(DeviceContactNormalizedPhoneNumber(rawValue: formatPhoneNumber(phone)))
}
index += 1
}
if let remotePeers = remotePeers {
for peer in remotePeers.0 {
if !(peer.peer is TelegramUser) {
if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info, categories.contains(.channels) {
} else {
continue
}
}
if let user = peer.peer as? TelegramUser {
if requirePhoneNumbers {
let phone = user.phone ?? ""
if phone.isEmpty {
continue
}
}
if excludeBots {
if user.botInfo != nil {
continue
}
}
}
if !existingPeerIds.contains(peer.peer.id) {
existingPeerIds.insert(peer.peer.id)
var enabled = true
var requiresPremiumForMessaging = false
if onlyWriteable {
enabled = canSendMessagesToPeer(peer.peer)
if let value = peerRequiresPremiumForMessaging[peer.peer.id], value {
requiresPremiumForMessaging = true
enabled = false
}
}
entries.append(.peer(index: index, theme: themeAndStrings.0, strings: themeAndStrings.1, peer: .peer(peer: peer.peer, isGlobal: true, participantCount: peer.subscribers), presence: nil, group: .global, enabled: enabled, requiresPremiumForMessaging: requiresPremiumForMessaging, displayCallIcons: displayCallIcons))
if searchDeviceContacts, let user = peer.peer as? TelegramUser, let phone = user.phone {
existingNormalizedPhoneNumbers.insert(DeviceContactNormalizedPhoneNumber(rawValue: formatPhoneNumber(phone)))
}
index += 1
}
}
for peer in remotePeers.1 {
if !(peer.peer is TelegramUser) {
if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info, categories.contains(.channels) {
} else {
continue
}
}
if let user = peer.peer as? TelegramUser, requirePhoneNumbers {
let phone = user.phone ?? ""
if phone.isEmpty {
continue
}
}
if !existingPeerIds.contains(peer.peer.id) {
existingPeerIds.insert(peer.peer.id)
var enabled = true
var requiresPremiumForMessaging = false
if onlyWriteable {
enabled = canSendMessagesToPeer(peer.peer)
if let value = peerRequiresPremiumForMessaging[peer.peer.id], value {
requiresPremiumForMessaging = true
enabled = false
}
}
entries.append(.peer(index: index, theme: themeAndStrings.0, strings: themeAndStrings.1, peer: .peer(peer: peer.peer, isGlobal: true, participantCount: peer.subscribers), presence: nil, group: .global, enabled: enabled, requiresPremiumForMessaging: requiresPremiumForMessaging, displayCallIcons: displayCallIcons))
if searchDeviceContacts, let user = peer.peer as? TelegramUser, let phone = user.phone {
existingNormalizedPhoneNumbers.insert(DeviceContactNormalizedPhoneNumber(rawValue: formatPhoneNumber(phone)))
}
index += 1
}
}
}
if let _ = remotePeers, let deviceContacts = deviceContacts {
outer: for (stableId, contact) in deviceContacts {
inner: for phoneNumber in contact.0.phoneNumbers {
let normalizedNumber = DeviceContactNormalizedPhoneNumber(rawValue: formatPhoneNumber(phoneNumber.value))
if existingNormalizedPhoneNumbers.contains(normalizedNumber) {
continue outer
}
}
if let peerId = contact.1 {
if existingPeerIds.contains(peerId) {
continue outer
}
}
entries.append(.peer(index: index, theme: themeAndStrings.0, strings: themeAndStrings.1, peer: .deviceContact(stableId, contact.0), presence: nil, group: .deviceContacts, enabled: true, requiresPremiumForMessaging: false, displayCallIcons: displayCallIcons))
index += 1
}
}
if let _ = addContact, isViablePhoneNumber(query) {
entries.append(.addContact(theme: themeAndStrings.0, strings: themeAndStrings.1, phoneNumber: query))
}
return (entries, query)
}
} else {
let _ = previousFoundRemoteContacts.swap(nil)
return .single((nil, ""))
}
}
let previousSearchItems = Atomic<[ContactListSearchEntry]>(value: [])
self.searchDisposable.set((searchItems
|> deliverOnMainQueue).start(next: { [weak self] items, query in
if let strongSelf = self {
let previousItems = previousSearchItems.swap(items ?? [])
var addContact: ((String) -> Void)?
if let originalAddContact = strongSelf.addContact {
addContact = { [weak self] phoneNumber in
self?.listNode.clearHighlightAnimated(true)
originalAddContact(phoneNumber)
}
}
let transition = contactListSearchContainerPreparedRecentTransition(from: previousItems, to: items ?? [], isSearching: items != nil, emptyResults: items?.isEmpty ?? false, query: query, context: context, presentationData: strongSelf.presentationData, nameSortOrder: strongSelf.presentationData.nameSortOrder, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder, timeFormat: strongSelf.presentationData.dateTimeFormat, isPeerEnabled: strongSelf.isPeerEnabled, addContact: addContact, openPeer: { peer, action in
self?.listNode.clearHighlightAnimated(true)
self?.openPeer(peer, action)
}, openDisabledPeer: { peer, reason in
guard let self else {
return
}
self.listNode.clearHighlightAnimated(true)
self.openDisabledPeer(peer, reason)
}, contextAction: strongSelf.contextAction)
strongSelf.enqueueTransition(transition)
}
}))
self.listNode.beganInteractiveDragging = { [weak self] _ in
self?.dismissInput?()
}
}
deinit {
self.searchDisposable.dispose()
}
override public func scrollToTop() {
if self.listNode.alpha > 0.0 {
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
}
}
override public func didLoad() {
super.didLoad()
self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
}
override public func updatePresentationData(_ presentationData: PresentationData) {
super.updatePresentationData(presentationData)
self.presentationData = presentationData
self.themeAndStringsPromise.set(.single((presentationData.theme, presentationData.strings)))
self.backgroundNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
}
override public func searchTextUpdated(text: String) {
if text.isEmpty {
self.searchQuery.set(.single(nil))
} else {
self.searchQuery.set(.single(text))
}
}
private func deactivateInput() {
if let (layout, _) = self.containerViewLayout, let searchInputView = self.searchInput.view as? SearchInputPanelComponent.View {
let transition = ComponentTransition.spring(duration: 0.4)
transition.setFrame(view: searchInputView, frame: CGRect(origin: CGPoint(x: searchInputView.frame.minX, y: layout.size.height), size: searchInputView.frame.size))
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
let hadValidLayout = self.containerViewLayout != nil
self.containerViewLayout = (layout, navigationBarHeight)
let topInset = navigationBarHeight
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: layout.size.width, height: layout.size.height - topInset)))
self.backgroundNode.frame = CGRect(origin: .zero, size: CGSize(width: layout.size.width, height: navigationBarHeight))
self.listNode.frame = CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - topInset))
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: UIEdgeInsets(top: 0.0, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right), duration: 0.0, curve: .Default(duration: nil)), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
let size = layout.size
let sideInset = layout.safeInsets.left
let visibleHeight = layout.size.height
let bottomInset = layout.insets(options: .input).bottom
let padding: CGFloat = 16.0
let emptyTitleSize = self.emptyResultsTitleNode.updateLayout(CGSize(width: size.width - sideInset * 2.0 - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
let emptyTextSize = self.emptyResultsTextNode.updateLayout(CGSize(width: size.width - sideInset * 2.0 - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
let emptyAnimationHeight = self.emptyResultsAnimationSize.height
let emptyAnimationSpacing: CGFloat = 8.0
let emptyTextSpacing: CGFloat = 8.0
let emptyTotalHeight = emptyAnimationHeight + emptyAnimationSpacing + emptyTitleSize.height + emptyTextSize.height + emptyTextSpacing
let emptyAnimationY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0)
let textTransition = ContainedViewLayoutTransition.immediate
textTransition.updateFrame(node: self.emptyResultsAnimationNode, frame: CGRect(origin: CGPoint(x: sideInset + padding + (size.width - sideInset * 2.0 - padding * 2.0 - self.emptyResultsAnimationSize.width) / 2.0, y: emptyAnimationY), size: self.emptyResultsAnimationSize))
textTransition.updateFrame(node: self.emptyResultsTitleNode, frame: CGRect(origin: CGPoint(x: sideInset + padding + (size.width - sideInset * 2.0 - padding * 2.0 - emptyTitleSize.width) / 2.0, y: emptyAnimationY + emptyAnimationHeight + emptyAnimationSpacing), size: emptyTitleSize))
textTransition.updateFrame(node: self.emptyResultsTextNode, frame: CGRect(origin: CGPoint(x: sideInset + padding + (size.width - sideInset * 2.0 - padding * 2.0 - emptyTextSize.width) / 2.0, y: emptyAnimationY + emptyAnimationHeight + emptyAnimationSpacing + emptyTitleSize.height + emptyTextSpacing), size: emptyTextSize))
self.emptyResultsAnimationNode.updateLayout(size: self.emptyResultsAnimationSize)
if self.glass {
let searchInputSize = self.searchInput.update(
transition: .immediate,
component: AnyComponent(
SearchInputPanelComponent(
theme: self.presentationData.theme,
strings: self.presentationData.strings,
metrics: layout.metrics,
safeInsets: layout.safeInsets,
updated: { [weak self] query in
guard let self else {
return
}
self.searchTextUpdated(text: query)
},
cancel: { [weak self] in
guard let self else {
return
}
self.cancel?()
self.deactivateInput()
}
)
),
environment: {},
containerSize: CGSize(width: layout.size.width, height: layout.size.height)
)
let bottomInset: CGFloat = layout.insets(options: .input).bottom
let searchInputFrame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - bottomInset - searchInputSize.height), size: searchInputSize)
if let searchInputView = self.searchInput.view as? SearchInputPanelComponent.View {
if searchInputView.superview == nil {
self.view.addSubview(searchInputView)
searchInputView.frame = CGRect(origin: CGPoint(x: searchInputFrame.minX, y: layout.size.height), size: searchInputFrame.size)
searchInputView.activateInput()
}
transition.updateFrame(view: searchInputView, frame: searchInputFrame)
}
}
if !hadValidLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func enqueueTransition(_ transition: ContactListSearchContainerTransition) {
self.enqueuedTransitions.append(transition)
if self.containerViewLayout != nil {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
if let transition = self.enqueuedTransitions.first {
self.enqueuedTransitions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
options.insert(.PreferSynchronousDrawing)
options.insert(.PreferSynchronousResourceLoading)
let isSearching = transition.isSearching
let emptyResults = transition.emptyResults
let query = transition.query
self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.emptyResultsTextNode.attributedText = NSAttributedString(string: strongSelf.presentationData.strings.Contacts_Search_NoResultsQueryDescription(query).string, font: Font.regular(15.0), textColor: strongSelf.presentationData.theme.list.freeTextColor)
if let (layout, navigationBarHeight) = strongSelf.containerViewLayout {
strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
let containerTransition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut)
containerTransition.updateAlpha(node: strongSelf.listNode, alpha: isSearching ? 1.0 : 0.0)
containerTransition.updateAlpha(node: strongSelf.backgroundNode, alpha: isSearching ? 1.0 : 0.0)
strongSelf.dimNode.isHidden = isSearching
containerTransition.updateAlpha(node: strongSelf.emptyResultsAnimationNode, alpha: emptyResults ? 1.0 : 0.0)
containerTransition.updateAlpha(node: strongSelf.emptyResultsTitleNode, alpha: emptyResults ? 1.0 : 0.0)
containerTransition.updateAlpha(node: strongSelf.emptyResultsTextNode, alpha: emptyResults ? 1.0 : 0.0)
strongSelf.emptyResultsAnimationNode.visibility = emptyResults
})
}
}
@objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
self.cancel?()
}
}
}
@@ -0,0 +1,239 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import MessageUI
import TelegramPresentationData
import AccountContext
import ShareController
import AlertUI
import PresentationDataUtils
import SearchUI
public class InviteContactsController: ViewController, MFMessageComposeViewControllerDelegate, UINavigationControllerDelegate {
private let context: AccountContext
private var contactsNode: InviteContactsControllerNode {
return self.displayNode as! InviteContactsControllerNode
}
private var _ready = Promise<Bool>()
override public var ready: Promise<Bool> {
return self._ready
}
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
private var composer: MFMessageComposeViewController?
private var searchContentNode: NavigationBarSearchContentNode?
public init(context: AccountContext) {
self.context = context
self.presentationData = context.sharedContext.currentPresentationData.with { $0 }
super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData))
self.navigationPresentation = .modal
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
self.title = self.presentationData.strings.Contacts_InviteFriends
self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil)
self.scrollToTop = { [weak self] in
if let strongSelf = self {
if let searchContentNode = strongSelf.searchContentNode {
searchContentNode.updateExpansionProgress(1.0, animated: true)
}
strongSelf.contactsNode.scrollToTop()
}
}
self.presentationDataDisposable = (context.sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
if let strongSelf = self {
let previousTheme = strongSelf.presentationData.theme
let previousStrings = strongSelf.presentationData.strings
strongSelf.presentationData = presentationData
if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings {
strongSelf.updateThemeAndStrings()
}
}
}).strict()
self.searchContentNode = NavigationBarSearchContentNode(theme: self.presentationData.theme, placeholder: self.presentationData.strings.Common_Search, activate: { [weak self] in
self?.activateSearch()
})
self.searchContentNode?.setIsEnabled(false)
self.navigationBar?.setContentNode(self.searchContentNode, animated: false)
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.presentationDataDisposable?.dispose()
}
private func updateThemeAndStrings() {
self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style
self.navigationBar?.updatePresentationData(NavigationBarPresentationData(presentationData: self.presentationData))
self.searchContentNode?.updateThemeAndPlaceholder(theme: self.presentationData.theme, placeholder: self.presentationData.strings.Common_Search)
self.title = self.presentationData.strings.Contacts_InviteFriends
self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil)
self.updateRightBarButtonItem()
}
private func updateRightBarButtonItem() {
let currentContacts = self.contactsNode.currentSortedContacts.with { $0 }
let title: String
if self.contactsNode.selectionState.selectedContactIndices.count == currentContacts?.count {
title = self.presentationData.strings.Contacts_DeselectAll
} else {
title = self.presentationData.strings.Contacts_SelectAll
}
self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: title, style: .plain, target: self, action: #selector(self.selectAllPressed))
}
override public func loadDisplayNode() {
self.displayNode = InviteContactsControllerNode(context: self.context)
self._ready.set(self.contactsNode.ready)
self.contactsNode.navigationBar = self.navigationBar
self.contactsNode.loadedContacts = { [weak self] in
if let strongSelf = self {
self?.searchContentNode?.setIsEnabled(true)
strongSelf.updateRightBarButtonItem()
}
}
self.contactsNode.requestDeactivateSearch = { [weak self] in
self?.deactivateSearch()
}
self.contactsNode.requestActivateSearch = { [weak self] in
self?.activateSearch()
}
self.contactsNode.requestShareTelegram = { [weak self] in
if let strongSelf = self {
let url = strongSelf.presentationData.strings.InviteText_URL
let body = strongSelf.presentationData.strings.InviteText_SingleContact(url).string
presentExternalShare(context: strongSelf.context, text: body, parentController: strongSelf)
strongSelf.contactsNode.listNode.clearHighlightAnimated(true)
}
}
self.contactsNode.requestShare = { [weak self] numbers in
let recipients: [String] = Array(numbers.map {
return $0.0.phoneNumbers.map { $0.value }
}.joined())
let f: () -> Void = {
if let strongSelf = self, MFMessageComposeViewController.canSendText() {
let composer = MFMessageComposeViewController()
composer.messageComposeDelegate = strongSelf
composer.recipients = Array(Set(recipients))
let url = strongSelf.presentationData.strings.InviteText_URL
var body = strongSelf.presentationData.strings.InviteText_SingleContact(url).string
if numbers.count == 1, numbers[0].1 > 0 {
body = strongSelf.presentationData.strings.InviteText_ContactsCountText(numbers[0].1)
body = body.replacingOccurrences(of: "{url}", with: url)
}
composer.body = body
strongSelf.composer = composer
if let window = strongSelf.view.window {
window.rootViewController?.present(composer, animated: true)
}
}
}
if recipients.count < 100 {
f()
} else if let strongSelf = self {
strongSelf.present(textAlertController(context: strongSelf.context, title: nil, text: strongSelf.presentationData.strings.Invite_LargeRecipientsCountWarning, actions: [TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: f)]), in: .window(.root))
}
}
self.contactsNode.selectionChanged = { [weak self] in
self?.updateRightBarButtonItem()
}
self.contactsNode.listNode.visibleContentOffsetChanged = { [weak self] offset in
if let strongSelf = self, let searchContentNode = strongSelf.searchContentNode {
searchContentNode.updateListVisibleContentOffset(offset)
}
}
self.contactsNode.listNode.didEndScrolling = { [weak self] _ in
if let strongSelf = self, let searchContentNode = strongSelf.searchContentNode {
let _ = fixNavigationSearchableListNodeScrolling(strongSelf.contactsNode.listNode, searchNode: searchContentNode)
}
}
self.displayNodeDidLoad()
}
override public func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
self.contactsNode.containerLayoutUpdated(layout, navigationBarHeight: self.cleanNavigationHeight, actualNavigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
private func activateSearch() {
if self.displayNavigationBar {
if let scrollToTop = self.scrollToTop {
scrollToTop()
}
if let searchContentNode = self.searchContentNode {
self.contactsNode.activateSearch(placeholderNode: searchContentNode.placeholderNode)
}
self.setDisplayNavigationBar(false, transition: .animated(duration: 0.5, curve: .spring))
}
}
private func deactivateSearch() {
if !self.displayNavigationBar {
self.setDisplayNavigationBar(true, transition: .animated(duration: 0.5, curve: .spring))
if let searchContentNode = self.searchContentNode {
self.contactsNode.deactivateSearch(placeholderNode: searchContentNode.placeholderNode)
}
}
}
@objc func selectAllPressed() {
self.contactsNode.selectAll()
}
@objc public func messageComposeViewController(_ controller: MFMessageComposeViewController, didFinishWith result: MessageComposeResult) {
self.composer = nil
controller.dismiss(animated: true, completion: nil)
guard case .sent = result else {
return
}
self.contactsNode.selectionState = self.contactsNode.selectionState.withClearedSelection()
}
}
@@ -0,0 +1,600 @@
import Display
import UIKit
import AsyncDisplayKit
import UIKit
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import MergeLists
import ActivityIndicator
import AccountContext
import SearchBarNode
import SearchUI
import ContactsPeerItem
import ChatListSearchItemHeader
import AppBundle
import PhoneNumberFormat
import ItemListUI
private enum InviteContactsEntryId: Hashable {
case option(index: Int)
case contactId(String)
}
private final class InviteContactsInteraction {
let toggleContact: (String) -> Void
let shareTelegram: () -> Void
init(toggleContact: @escaping (String) -> Void, shareTelegram: @escaping () -> Void) {
self.toggleContact = toggleContact
self.shareTelegram = shareTelegram
}
}
private enum InviteContactsEntry: Comparable, Identifiable {
case option(Int, ContactListAdditionalOption, PresentationTheme, PresentationStrings)
case peer(Int, DeviceContactStableId, DeviceContactBasicData, Int32, ContactsPeerItemSelection, PresentationTheme, PresentationStrings, PresentationPersonNameOrder, PresentationPersonNameOrder)
var stableId: InviteContactsEntryId {
switch self {
case let .option(index, _, _, _):
return .option(index: index)
case let .peer(_, id, _, _, _, _, _, _, _):
return .contactId(id)
}
}
func item(context: AccountContext, presentationData: PresentationData, interaction: InviteContactsInteraction) -> ListViewItem {
switch self {
case let .option(_, option, _, _):
return ContactListActionItem(presentationData: ItemListPresentationData(presentationData), title: option.title, icon: option.icon, header: nil, action: option.action)
case let .peer(_, id, contact, count, selection, theme, strings, nameSortOrder, nameDisplayOrder):
let status: ContactsPeerItemStatus
if count != 0 {
status = .custom(string: NSAttributedString(string: strings.Contacts_ImportersCount(count)), multiline: false, isActive: false, icon: nil)
} else {
status = .none
}
let peer: EnginePeer = .user(TelegramUser(id: EnginePeer.Id(namespace: .max, id: EnginePeer.Id.Id._internalFromInt64Value(0)), accessHash: nil, firstName: contact.firstName, lastName: contact.lastName, username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil))
return ContactsPeerItem(presentationData: ItemListPresentationData(presentationData), sortOrder: nameSortOrder, displayOrder: nameDisplayOrder, context: context, peerMode: .peer, peer: .peer(peer: peer, chatPeer: peer), status: status, enabled: true, selection: selection, editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), index: nil, header: ChatListSearchItemHeader(type: .contacts, theme: theme, strings: strings, actionTitle: nil, action: nil), action: { _ in
interaction.toggleContact(id)
})
}
}
static func ==(lhs: InviteContactsEntry, rhs: InviteContactsEntry) -> Bool {
switch lhs {
case let .option(lhsIndex, lhsOption, lhsTheme, lhsStrings):
if case let .option(rhsIndex, rhsOption, rhsTheme, rhsStrings) = rhs, lhsIndex == rhsIndex, lhsOption == rhsOption, lhsTheme === rhsTheme, lhsStrings === rhsStrings {
return true
} else {
return false
}
case let .peer(lhsIndex, lhsId, lhsContact, lhsCount, lhsSelection, lhsTheme, lhsStrings, lhsSortOrder, lhsDisplayOrder):
switch rhs {
case let .peer(rhsIndex, rhsId, rhsContact, rhsCount, rhsSelection, rhsTheme, rhsStrings, rhsSortOrder, rhsDisplayOrder):
if lhsIndex != rhsIndex {
return false
}
if lhsId != rhsId {
return false
}
if lhsContact != rhsContact {
return false
}
if lhsCount != rhsCount {
return false
}
if lhsSelection != rhsSelection {
return false
}
if lhsTheme !== rhsTheme {
return false
}
if lhsStrings !== rhsStrings {
return false
}
if lhsSortOrder != rhsSortOrder {
return false
}
if lhsDisplayOrder != rhsDisplayOrder {
return false
}
return true
default:
return false
}
}
}
static func <(lhs: InviteContactsEntry, rhs: InviteContactsEntry) -> Bool {
switch lhs {
case let .option(lhsIndex, _, _, _):
switch rhs {
case let .option(rhsIndex, _, _, _):
return lhsIndex < rhsIndex
case .peer:
return true
}
case let .peer(lhsIndex, _, _, _, _, _, _, _, _):
switch rhs {
case .option:
return false
case let .peer(rhsIndex, _, _, _, _, _, _, _, _):
return lhsIndex < rhsIndex
}
}
}
}
struct InviteContactsGroupSelectionState: Equatable {
let selectedContactIndices: [String: Int]
let nextSelectionIndex: Int
private init(selectedContactIndices: [String: Int], nextSelectionIndex: Int) {
self.selectedContactIndices = selectedContactIndices
self.nextSelectionIndex = nextSelectionIndex
}
init() {
self.selectedContactIndices = [:]
self.nextSelectionIndex = 0
}
func withReplacedSelectedContactIds(_ contactIds: [String]) -> InviteContactsGroupSelectionState {
var selectedContactIndices: [String: Int] = [:]
var nextSelectionIndex: Int = self.nextSelectionIndex
for contactId in contactIds {
selectedContactIndices[contactId] = nextSelectionIndex
nextSelectionIndex += 1
}
return InviteContactsGroupSelectionState(selectedContactIndices: selectedContactIndices, nextSelectionIndex: nextSelectionIndex)
}
func withToggledContactId(_ contactId: String) -> InviteContactsGroupSelectionState {
var updatedIndices = self.selectedContactIndices
if let _ = updatedIndices[contactId] {
updatedIndices.removeValue(forKey: contactId)
return InviteContactsGroupSelectionState(selectedContactIndices: updatedIndices, nextSelectionIndex: self.nextSelectionIndex)
} else {
updatedIndices[contactId] = self.nextSelectionIndex
return InviteContactsGroupSelectionState(selectedContactIndices: updatedIndices, nextSelectionIndex: self.nextSelectionIndex + 1)
}
}
func withSelectedContactId(_ contactId: String) -> InviteContactsGroupSelectionState {
var updatedIndices = self.selectedContactIndices
if let _ = updatedIndices[contactId] {
return self
} else {
updatedIndices[contactId] = self.nextSelectionIndex
return InviteContactsGroupSelectionState(selectedContactIndices: updatedIndices, nextSelectionIndex: self.nextSelectionIndex + 1)
}
}
func withClearedSelection() -> InviteContactsGroupSelectionState {
return InviteContactsGroupSelectionState(selectedContactIndices: [:], nextSelectionIndex: self.nextSelectionIndex)
}
static func ==(lhs: InviteContactsGroupSelectionState, rhs: InviteContactsGroupSelectionState) -> Bool {
return lhs.selectedContactIndices == rhs.selectedContactIndices && lhs.nextSelectionIndex == rhs.nextSelectionIndex
}
}
private func inviteContactsEntries(accountPeer: EnginePeer?, sortedContacts: [(DeviceContactStableId, DeviceContactBasicData, Int32)]?, selectionState: InviteContactsGroupSelectionState, theme: PresentationTheme, strings: PresentationStrings, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, interaction: InviteContactsInteraction) -> [InviteContactsEntry] {
var entries: [InviteContactsEntry] = []
entries.append(.option(0, ContactListAdditionalOption(title: strings.Contacts_ShareTelegram, icon: .generic(UIImage(bundleImageName: "Contact List/InviteActionIcon")!), action: {
interaction.shareTelegram()
}), theme, strings))
var index = 0
if let sortedContacts = sortedContacts {
for (id, contact, count) in sortedContacts {
entries.append(.peer(index, id, contact, count, .selectable(selected: selectionState.selectedContactIndices[id] != nil), theme, strings, nameSortOrder, nameDisplayOrder))
index += 1
}
}
return entries
}
private func preparedInviteContactsTransition(context: AccountContext, presentationData: PresentationData, from fromEntries: [InviteContactsEntry], to toEntries: [InviteContactsEntry], sortedContacts: [(DeviceContactStableId, DeviceContactBasicData, Int32)]?, interaction: InviteContactsInteraction, isLoading: Bool, firstTime: Bool, crossfade: Bool) -> InviteContactsTransition {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) }
return InviteContactsTransition(deletions: deletions, insertions: insertions, updates: updates, sortedContacts: sortedContacts, isLoading: isLoading, firstTime: firstTime, crossfade: crossfade)
}
private struct InviteContactsTransition {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
let sortedContacts: [(DeviceContactStableId, DeviceContactBasicData, Int32)]?
let isLoading: Bool
let firstTime: Bool
let crossfade: Bool
}
final class InviteContactsControllerNode: ASDisplayNode {
let listNode: ListView
private var activityIndicator: ActivityIndicator?
private let context: AccountContext
private var searchDisplayController: SearchDisplayController?
private var validLayout: (ContainerViewLayout, CGFloat, CGFloat)?
var navigationBar: NavigationBar?
private let countPanelNode: InviteContactsCountPanelNode
var requestActivateSearch: (() -> Void)?
var requestDeactivateSearch: (() -> Void)?
var requestShareTelegram: (() -> Void)?
var requestShare: (([(DeviceContactBasicData, Int32)]) -> Void)?
var selectionChanged: (() -> Void)?
let currentSortedContacts = Atomic<[(DeviceContactStableId, DeviceContactBasicData, Int32)]?>(value: nil)
var selectionState = InviteContactsGroupSelectionState() {
didSet {
if self.selectionState != oldValue {
self.selectionStatePromise.set(.single(self.selectionState))
self.countPanelNode.count = self.selectionState.selectedContactIndices.count
if oldValue.selectedContactIndices.isEmpty != self.selectionState.selectedContactIndices.isEmpty {
if let (layout, navigationHeight, actualNavigationHeight) = self.validLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, actualNavigationBarHeight: actualNavigationHeight, transition: .animated(duration: 0.3, curve: .spring))
}
}
self.selectionChanged?()
}
}
}
private let selectionStatePromise = Promise<InviteContactsGroupSelectionState>(InviteContactsGroupSelectionState())
private var queuedTransitions: [InviteContactsTransition] = []
private var presentationData: PresentationData
private var presentationDataDisposable: Disposable?
private let presentationDataPromise: Promise<PresentationData>
private let _ready = Promise<Bool>()
private var readyValue = false {
didSet {
if self.readyValue, self.readyValue != oldValue {
self._ready.set(.single(self.readyValue))
}
}
}
var ready: Signal<Bool, NoError> {
return self._ready.get()
}
var loadedContacts: (() -> Void)?
private var disposable: Disposable?
private let currentContactIds = Atomic<[String]>(value: [])
init(context: AccountContext) {
self.context = context
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
self.presentationData = presentationData
self.presentationDataPromise = Promise(self.presentationData)
self.listNode = ListView()
self.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
var shareImpl: (() -> Void)?
self.countPanelNode = InviteContactsCountPanelNode(theme: self.presentationData.theme, strings: self.presentationData.strings, action: {
shareImpl?()
})
super.init()
self.setViewBlock({
return UITracingLayerView()
})
self.backgroundColor = self.presentationData.theme.chatList.backgroundColor
self.listNode.backgroundColor = self.presentationData.theme.chatList.backgroundColor
self.addSubnode(self.listNode)
self.addSubnode(self.countPanelNode)
self.presentationDataDisposable = (context.sharedContext.presentationData
|> deliverOnMainQueue).start(next: { [weak self] presentationData in
if let strongSelf = self {
let previousTheme = strongSelf.presentationData.theme
let previousStrings = strongSelf.presentationData.strings
strongSelf.presentationData = presentationData
strongSelf.presentationDataPromise.set(.single(presentationData))
if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings {
strongSelf.updateThemeAndStrings()
}
}
}).strict()
let selectionStateSignal = self.selectionStatePromise.get()
let transition: Signal<InviteContactsTransition, NoError>
let presentationDataPromise = self.presentationDataPromise
let previousEntries = Atomic<[InviteContactsEntry]?>(value: nil)
let interaction = InviteContactsInteraction(toggleContact: { [weak self] id in
if let strongSelf = self {
strongSelf.selectionState = strongSelf.selectionState.withToggledContactId(id)
}
}, shareTelegram: { [weak self] in
self?.requestShareTelegram?()
})
let existingNumbers: Signal<(Set<String>, Set<EnginePeer.Id>), NoError> = context.engine.data.subscribe(
TelegramEngine.EngineData.Item.Contacts.List(includePresences: false)
)
|> map { view -> (Set<String>, Set<EnginePeer.Id>) in
var existingNumbers = Set<String>()
var existingPeerIds = Set<EnginePeer.Id>()
for peer in view.peers {
if case let .user(peer) = peer, let phone = peer.phone {
existingNumbers.insert(formatPhoneNumber(phone))
}
existingPeerIds.insert(peer.id)
}
return (existingNumbers, existingPeerIds)
}
let currentSortedContacts = self.currentSortedContacts
let sortedContacts: Signal<[(DeviceContactStableId, DeviceContactBasicData, Int32)]?, NoError> = combineLatest(existingNumbers, (context.sharedContext.contactDataManager?.basicData() ?? .single([:])) |> take(1))
|> mapToSignal { existingNumbersAndPeerIds, contacts -> Signal<[(DeviceContactStableId, DeviceContactBasicData, Int32)]?, NoError> in
var mappedContacts: [(String, [DeviceContactNormalizedPhoneNumber])] = []
for (id, basicData) in contacts {
mappedContacts.append((id: id, basicData.phoneNumbers.map({ phoneNumber in
return DeviceContactNormalizedPhoneNumber(rawValue: formatPhoneNumber(phoneNumber.value))
})))
}
return context.engine.contacts.deviceContactsImportedByCount(contacts: mappedContacts)
|> map { counts -> [(DeviceContactStableId, DeviceContactBasicData, Int32)]? in
var result: [(DeviceContactStableId, DeviceContactBasicData, Int32)] = []
var contactValues: [DeviceContactStableId: DeviceContactBasicData] = [:]
var existing = Set<String>()
for (id, basicData) in contacts {
var found = false
if basicData.phoneNumbers.isEmpty {
existing.insert(id)
continue
}
for number in basicData.phoneNumbers {
if existingNumbersAndPeerIds.0.contains(formatPhoneNumber(number.value)) {
existing.insert(id)
found = true
}
}
if !found {
contactValues[id] = basicData
}
}
var countValues: [(String, Int32)] = []
for (id, count) in counts {
countValues.append((id, count))
}
countValues.sort(by: { $0.1 > $1.1 })
for (id, value) in countValues {
existing.insert(id)
if let contact = contactValues[id] {
result.append((id, contact, value))
}
}
for (id, contact) in contacts {
if !existing.contains(id) {
result.append((id, contact, 0))
}
}
return result
}
}
let processingQueue = Queue()
transition = (combineLatest(.single(nil) |> then(sortedContacts), selectionStateSignal, presentationDataPromise.get(), .single(true) |> delay(0.2, queue: Queue.mainQueue()))
|> mapToQueue { sortedContacts, selectionState, presentationData, ready -> Signal<InviteContactsTransition, NoError> in
guard sortedContacts != nil || ready else {
return .never()
}
let signal = deferred { () -> Signal<InviteContactsTransition, NoError> in
let entries = inviteContactsEntries(accountPeer: nil, sortedContacts: sortedContacts, selectionState: selectionState, theme: presentationData.theme, strings: presentationData.strings, nameSortOrder: presentationData.nameSortOrder, nameDisplayOrder: presentationData.nameDisplayOrder, interaction: interaction)
let previous = previousEntries.swap(entries)
let previousContacts = currentSortedContacts.with { $0 }
let crossfade = previous != nil && previousContacts == nil
return .single(preparedInviteContactsTransition(context: context, presentationData: presentationData, from: previous ?? [], to: entries, sortedContacts: sortedContacts, interaction: interaction, isLoading: sortedContacts == nil, firstTime: previous == nil, crossfade: crossfade))
}
return signal
|> runOn(processingQueue)
})
|> deliverOnMainQueue
self.disposable = transition.start(next: { [weak self] transition in
self?.enqueueTransition(transition)
}).strict()
shareImpl = { [weak self] in
if let strongSelf = self {
var result: [(DeviceContactBasicData, Int32)] = []
for contact in (strongSelf.currentSortedContacts.with { $0 } ?? []) {
if strongSelf.selectionState.selectedContactIndices[contact.0] != nil {
result.append((contact.1, contact.2))
}
}
if !result.isEmpty {
self?.requestShare?(result)
}
}
}
}
deinit {
self.disposable?.dispose()
self.presentationDataDisposable?.dispose()
}
private func updateThemeAndStrings() {
self.backgroundColor = self.presentationData.theme.chatList.backgroundColor
self.listNode.backgroundColor = self.presentationData.theme.chatList.backgroundColor
self.searchDisplayController?.updatePresentationData(self.presentationData)
}
func scrollToTop() {
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, actualNavigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
let hadValidLayout = self.validLayout != nil
self.validLayout = (layout, navigationBarHeight, actualNavigationBarHeight)
var insets = layout.insets(options: [.input])
insets.top += navigationBarHeight
if let searchDisplayController = self.searchDisplayController {
searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
}
insets.left += layout.safeInsets.left
insets.right += layout.safeInsets.right
var headerInsets = layout.insets(options: [.input])
headerInsets.top += actualNavigationBarHeight
let countPanelHeight = self.countPanelNode.updateLayout(width: layout.size.width, sideInset: layout.safeInsets.left, bottomInset: layout.intrinsicInsets.bottom, transition: transition)
if self.selectionState.selectedContactIndices.isEmpty {
transition.updateFrame(node: self.countPanelNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height), size: CGSize(width: layout.size.width, height: countPanelHeight)))
} else {
insets.bottom += countPanelHeight
transition.updateFrame(node: self.countPanelNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - countPanelHeight), size: CGSize(width: layout.size.width, height: countPanelHeight)))
}
self.listNode.bounds = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height)
self.listNode.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0)
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, headerInsets: headerInsets, duration: duration, curve: curve)
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
if let activityIndicator = self.activityIndicator {
let indicatorSize = activityIndicator.measure(CGSize(width: 100.0, height: 100.0))
transition.updateFrame(node: activityIndicator, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - indicatorSize.width) / 2.0), y: updateSizeAndInsets.insets.top + 50.0 + floor((layout.size.height - updateSizeAndInsets.insets.top - updateSizeAndInsets.insets.bottom - indicatorSize.height - 50.0) / 2.0)), size: indicatorSize))
}
if !hadValidLayout {
self.dequeueTransitions()
}
}
func activateSearch(placeholderNode: SearchBarPlaceholderNode) {
guard let (containerLayout, navigationBarHeight, _) = self.validLayout, let navigationBar = self.navigationBar, self.searchDisplayController == nil else {
return
}
self.searchDisplayController = SearchDisplayController(presentationData: self.presentationData, contentNode: ContactsSearchContainerNode(context: self.context, onlyWriteable: false, categories: [.deviceContacts], addContact: nil, openPeer: { [weak self] peer, _ in
if let strongSelf = self, case let .deviceContact(id, _) = peer {
strongSelf.selectionState = strongSelf.selectionState.withSelectedContactId(id)
strongSelf.requestDeactivateSearch?()
}
}, openDisabledPeer: { _, _ in
}, contextAction: nil), cancel: { [weak self] in
if let requestDeactivateSearch = self?.requestDeactivateSearch {
requestDeactivateSearch()
}
})
self.searchDisplayController?.containerLayoutUpdated(containerLayout, navigationBarHeight: navigationBarHeight, transition: .immediate)
self.searchDisplayController?.activate(insertSubnode: { [weak self, weak placeholderNode] subnode, isSearchBar in
if let strongSelf = self, let strongPlaceholderNode = placeholderNode {
if isSearchBar {
strongPlaceholderNode.supernode?.insertSubnode(subnode, aboveSubnode: strongPlaceholderNode)
} else {
strongSelf.insertSubnode(subnode, belowSubnode: navigationBar)
}
}
}, placeholder: placeholderNode)
}
func deactivateSearch(placeholderNode: SearchBarPlaceholderNode) {
if let searchDisplayController = self.searchDisplayController {
self.searchDisplayController = nil
searchDisplayController.deactivate(placeholder: placeholderNode)
}
}
private func enqueueTransition(_ transition: InviteContactsTransition) {
self.queuedTransitions.append(transition)
if self.validLayout != nil {
self.dequeueTransitions()
}
}
private func dequeueTransitions() {
if self.validLayout != nil {
while !self.queuedTransitions.isEmpty {
let transition = self.queuedTransitions.removeFirst()
var options = ListViewDeleteAndInsertOptions()
if transition.firstTime {
options.insert(.Synchronous)
options.insert(.LowLatency)
} else if transition.crossfade {
options.insert(.AnimateCrossfade)
}
self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.readyValue = true
if transition.isLoading, strongSelf.activityIndicator == nil {
let activityIndicator = ActivityIndicator(type: .custom(strongSelf.presentationData.theme.list.itemAccentColor, 22.0, 1.0, false))
strongSelf.activityIndicator = activityIndicator
strongSelf.insertSubnode(activityIndicator, aboveSubnode: strongSelf.listNode)
if let (layout, navigationHeight, actualNavigationBarHeight) = strongSelf.validLayout {
strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, actualNavigationBarHeight: actualNavigationBarHeight, transition: .immediate)
}
} else if !transition.isLoading, let activityIndicator = strongSelf.activityIndicator {
strongSelf.activityIndicator = nil
activityIndicator.removeFromSupernode()
}
let previous = strongSelf.currentSortedContacts.swap(transition.sortedContacts)
if previous == nil && transition.sortedContacts != nil {
strongSelf.loadedContacts?()
}
}
})
}
}
}
func selectAll() {
let ids = self.currentSortedContacts.with { $0 }?.map { $0.0 } ?? []
var allSelected = true
for id in ids {
if self.selectionState.selectedContactIndices[id] == nil {
allSelected = false
break
}
}
self.selectionState = self.selectionState.withReplacedSelectedContactIds(allSelected ? [] : ids)
}
}
@@ -0,0 +1,65 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramPresentationData
import SolidRoundedButtonNode
final class InviteContactsCountPanelNode: ASDisplayNode {
private let theme: PresentationTheme
private let strings: PresentationStrings
private let separatorNode: ASDisplayNode
private let button: SolidRoundedButtonNode
private var validLayout: (CGFloat, CGFloat, CGFloat)?
var count: Int = 0 {
didSet {
if self.count != oldValue && self.count > 0 {
self.button.title = self.strings.Contacts_InviteContacts(Int32(self.count))
if let (width, sideInset, bottomInset) = self.validLayout {
let _ = self.updateLayout(width: width, sideInset: sideInset, bottomInset: bottomInset, transition: .immediate)
}
}
}
}
init(theme: PresentationTheme, strings: PresentationStrings, action: @escaping () -> Void) {
self.theme = theme
self.strings = strings
self.separatorNode = ASDisplayNode()
self.separatorNode.backgroundColor = theme.rootController.navigationBar.separatorColor
self.button = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(theme: theme), height: 48.0, cornerRadius: 10.0)
super.init()
self.backgroundColor = theme.rootController.navigationBar.opaqueBackgroundColor
self.addSubnode(self.button)
self.addSubnode(self.separatorNode)
self.button.pressed = {
action()
}
}
func updateLayout(width: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat {
self.validLayout = (width, sideInset, bottomInset)
let topInset: CGFloat = 9.0
var bottomInset = bottomInset
bottomInset += topInset - (bottomInset.isZero ? 0.0 : 4.0)
let buttonInset: CGFloat = 16.0 + sideInset
let buttonWidth = width - buttonInset * 2.0
let buttonHeight = self.button.updateLayout(width: buttonWidth, transition: transition)
transition.updateFrame(node: self.button, frame: CGRect(x: buttonInset, y: topInset, width: buttonWidth, height: buttonHeight))
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)))
return topInset + buttonHeight + bottomInset
}
}
@@ -0,0 +1,255 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import TextFormat
import Markdown
import ItemListUI
public class LimitedPermissionItem: ListViewItem {
public let selectable: Bool = false
let presentationData: ItemListPresentationData
let text: String
let action: (() -> Void)?
public init(
presentationData: ItemListPresentationData,
text: String,
action: (() -> Void)?
) {
self.presentationData = presentationData
self.text = text
self.action = action
}
public 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 = LimitedPermissionItemNode()
let (layout, apply) = node.asyncLayout()(self, params, nil)
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
public 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 {
if let nodeValue = node() as? LimitedPermissionItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, nil)
Queue.mainQueue().async {
completion(layout, { _ in
apply()
})
}
}
}
}
}
}
public class LimitedPermissionItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let actionButton: HighlightableButtonNode
private let actionButtonTitleNode: TextNode
private let actionButtonBackgroundNode: ASImageNode
private let textNode: TextNode
private let activateArea: AccessibilityAreaNode
private var item: LimitedPermissionItem?
public override var canBeSelected: Bool {
return false
}
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.backgroundNode.backgroundColor = .white
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.textNode = TextNode()
self.textNode.isUserInteractionEnabled = false
self.activateArea = AccessibilityAreaNode()
self.activateArea.accessibilityTraits = .staticText
self.actionButton = HighlightableButtonNode()
self.actionButtonBackgroundNode = ASImageNode()
self.actionButtonBackgroundNode.displaysAsynchronously = false
self.actionButtonTitleNode = TextNode()
self.actionButtonTitleNode.isUserInteractionEnabled = false
super.init(layerBacked: false, dynamicBounce: false)
self.addSubnode(self.textNode)
self.addSubnode(self.activateArea)
self.addSubnode(self.actionButtonBackgroundNode)
self.addSubnode(self.actionButtonTitleNode)
self.addSubnode(self.actionButton)
self.actionButton.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.actionButtonBackgroundNode.layer.removeAnimation(forKey: "opacity")
strongSelf.actionButtonBackgroundNode.alpha = 0.4
strongSelf.actionButtonTitleNode.layer.removeAnimation(forKey: "opacity")
strongSelf.actionButtonTitleNode.alpha = 0.4
} else {
strongSelf.actionButtonBackgroundNode.alpha = 1.0
strongSelf.actionButtonBackgroundNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
strongSelf.actionButtonTitleNode.alpha = 1.0
strongSelf.actionButtonTitleNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.actionButton.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
}
func asyncLayout() -> (_ item: LimitedPermissionItem, _ params: ListViewItemLayoutParams, _ insets: ItemListNeighbors?) -> (ListViewItemNodeLayout, () -> Void) {
let makeTextLayout = TextNode.asyncLayout(self.textNode)
let makeButtonTitleLayout = TextNode.asyncLayout(self.actionButtonTitleNode)
let currentItem = self.item
return { item, params, neighbors in
let leftInset: CGFloat = 16.0 + params.leftInset
let rightInset: CGFloat = 16.0 + params.rightInset
let textFont = Font.regular(15.0)
var updatedTheme: PresentationTheme?
if currentItem?.presentationData.theme !== item.presentationData.theme {
updatedTheme = item.presentationData.theme
}
let insets: UIEdgeInsets
if let neighbors = neighbors {
insets = itemListNeighborsGroupedInsets(neighbors, params)
} else {
insets = UIEdgeInsets()
}
let separatorHeight = UIScreenPixel
let itemBackgroundColor = item.presentationData.theme.list.plainBackgroundColor
let itemSeparatorColor = item.presentationData.theme.list.itemBlocksSeparatorColor
let attributedText = NSAttributedString(string: item.text, font: textFont, textColor: item.presentationData.theme.list.itemSecondaryTextColor)
let (buttonTextLayout, buttonTextApply) = makeButtonTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.presentationData.strings.Contacts_LimitedAccess_Manage, font: Font.semibold(15.0), textColor: item.presentationData.theme.list.itemCheckColors.foregroundColor), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - buttonTextLayout.size.width - 20.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let contentSize = CGSize(width: params.width, height: textLayout.size.height + 20.0)
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.item = item
strongSelf.accessibilityLabel = attributedText.string
strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: params.width - params.leftInset - params.rightInset, height: layout.contentSize.height))
strongSelf.activateArea.accessibilityLabel = strongSelf.accessibilityLabel
if let _ = updatedTheme {
strongSelf.topStripeNode.backgroundColor = itemSeparatorColor
strongSelf.bottomStripeNode.backgroundColor = itemSeparatorColor
strongSelf.backgroundNode.backgroundColor = itemBackgroundColor
strongSelf.actionButtonBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 14.0 * 2.0, color: item.presentationData.theme.list.itemCheckColors.fillColor, strokeColor: nil, strokeWidth: nil, backgroundColor: nil)
}
let _ = textApply()
let _ = buttonTextApply()
if strongSelf.backgroundNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0)
}
if strongSelf.topStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1)
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2)
}
if let neighbors = neighbors {
switch neighbors.top {
case .sameSection(false):
strongSelf.topStripeNode.isHidden = true
default:
strongSelf.topStripeNode.isHidden = true
}
}
let bottomStripeInset: CGFloat
if let neighbors = neighbors {
switch neighbors.bottom {
case .sameSection(false):
bottomStripeInset = leftInset
strongSelf.bottomStripeNode.isHidden = false
default:
bottomStripeInset = 0.0
strongSelf.bottomStripeNode.isHidden = true
}
} else {
bottomStripeInset = leftInset
strongSelf.topStripeNode.isHidden = true
}
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: separatorHeight))
strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: params.width - bottomStripeInset, height: separatorHeight))
strongSelf.textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 10.0), size: textLayout.size)
let actionButtonSize = CGSize(width: max(buttonTextLayout.size.width + 26.0, 40.0), height: 28.0)
let actionButtonFrame = CGRect(origin: CGPoint(x: params.width - params.rightInset - actionButtonSize.width - 10.0, y: floor((layout.size.height - actionButtonSize.height) / 2.0)), size: actionButtonSize)
strongSelf.actionButton.frame = actionButtonFrame
strongSelf.actionButtonBackgroundNode.frame = actionButtonFrame
strongSelf.actionButtonTitleNode.frame = CGRect(origin: CGPoint(x: actionButtonFrame.minX + floorToScreenPixels((actionButtonFrame.width - buttonTextLayout.size.width) / 2.0), y: actionButtonFrame.minY + floorToScreenPixels((actionButtonFrame.height - buttonTextLayout.size.height) / 2.0) + 1.0 - UIScreenPixel), size: buttonTextLayout.size)
}
})
}
}
public override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
public override func animateAdded(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
public override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
@objc func buttonPressed() {
if let item = self.item {
item.action?()
}
}
}