import Foundation import UIKit import Display import ComponentFlow import MultilineTextComponent import TelegramPresentationData import TelegramCore import AccountContext import ListSectionComponent import ListActionItemComponent import PeerListItemComponent import ViewControllerComponent import BundleIconComponent import AvatarNode import SwiftSignalKit final class BusinessRecipientListScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let initialData: BusinessRecipientListScreen.InitialData let mode: BusinessRecipientListScreen.Mode let update: (BusinessRecipientListScreenComponent.PeerList) -> Void init( context: AccountContext, initialData: BusinessRecipientListScreen.InitialData, mode: BusinessRecipientListScreen.Mode, update: @escaping (BusinessRecipientListScreenComponent.PeerList) -> Void ) { self.context = context self.initialData = initialData self.mode = mode self.update = update } static func ==(lhs: BusinessRecipientListScreenComponent, rhs: BusinessRecipientListScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } return true } private final class ScrollView: UIScrollView { override func touchesShouldCancel(in view: UIView) -> Bool { return true } } struct PeerList { enum Category: Int { case newChats = 0 case existingChats = 1 case contacts = 2 case nonContacts = 3 } struct Peer { var peer: EnginePeer var isContact: Bool init(peer: EnginePeer, isContact: Bool) { self.peer = peer self.isContact = isContact } } var categories: Set var peers: [Peer] init(categories: Set, peers: [Peer]) { self.categories = categories self.peers = peers } } final class View: UIView, UIScrollViewDelegate { private let topOverscrollLayer = SimpleLayer() private let scrollView: ScrollView private let navigationTitle = ComponentView() private let excludedSection = ComponentView() private let clearSection = ComponentView() private var isUpdating: Bool = false private var component: BusinessRecipientListScreenComponent? private(set) weak var state: EmptyComponentState? private var environment: EnvironmentType? private var peerList = PeerList( categories: Set(), peers: [] ) override init(frame: CGRect) { self.scrollView = ScrollView() self.scrollView.showsVerticalScrollIndicator = true self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.scrollsToTop = false self.scrollView.delaysContentTouches = false self.scrollView.canCancelContentTouches = true self.scrollView.contentInsetAdjustmentBehavior = .never if #available(iOS 13.0, *) { self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false } self.scrollView.alwaysBounceVertical = true super.init(frame: frame) self.scrollView.delegate = self self.addSubview(self.scrollView) self.scrollView.layer.addSublayer(self.topOverscrollLayer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { } func scrollToTop() { self.scrollView.setContentOffset(CGPoint(), animated: true) } func attemptNavigation(complete: @escaping () -> Void) -> Bool { guard let component = self.component else { return true } component.update(self.peerList) return true } func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateScrolling(transition: .immediate) } var scrolledUp = true private func updateScrolling(transition: ComponentTransition) { let navigationRevealOffsetY: CGFloat = 0.0 let navigationAlphaDistance: CGFloat = 16.0 let navigationAlpha: CGFloat = max(0.0, min(1.0, (self.scrollView.contentOffset.y - navigationRevealOffsetY) / navigationAlphaDistance)) if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { transition.setAlpha(layer: navigationBar.backgroundNode.layer, alpha: navigationAlpha) transition.setAlpha(layer: navigationBar.stripeNode.layer, alpha: navigationAlpha) } var scrolledUp = false if navigationAlpha < 0.5 { scrolledUp = true } else if navigationAlpha > 0.5 { scrolledUp = false } if self.scrolledUp != scrolledUp { self.scrolledUp = scrolledUp if !self.isUpdating { self.state?.updated() } } if let navigationTitleView = self.navigationTitle.view { transition.setAlpha(view: navigationTitleView, alpha: 1.0) } } static func makePeerListSetupScreen(context: AccountContext, mode: BusinessRecipientListScreen.Mode, initialPeerList: BusinessRecipientListScreenComponent.PeerList, completion: @escaping (BusinessRecipientListScreenComponent.PeerList) -> Void) -> ViewController { let presentationData = context.sharedContext.currentPresentationData.with({ $0 }) enum AdditionalCategoryId: Int { case existingChats case newChats case contacts case nonContacts } let hasAccessToAllChatsByDefault: Bool let isExclude: Bool switch mode { case .excludeExceptions: hasAccessToAllChatsByDefault = true isExclude = false case .includeExceptions: hasAccessToAllChatsByDefault = false isExclude = false case .excludeUsers: hasAccessToAllChatsByDefault = false isExclude = true } let additionalCategories: [ChatListNodeAdditionalCategory] var selectedCategories = Set() if isExclude { additionalCategories = [] } else { additionalCategories = [ ChatListNodeAdditionalCategory( id: hasAccessToAllChatsByDefault ? AdditionalCategoryId.existingChats.rawValue : AdditionalCategoryId.newChats.rawValue, icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: hasAccessToAllChatsByDefault ? "Chat List/Filters/Chats" : "Chat List/Filters/NewChats"), color: .white), cornerRadius: 12.0, color: .purple), smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: hasAccessToAllChatsByDefault ? "Chat List/Filters/Chats" : "Chat List/Filters/NewChats"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .purple), title: hasAccessToAllChatsByDefault ? presentationData.strings.BusinessMessageSetup_Recipients_CategoryExistingChats : presentationData.strings.BusinessMessageSetup_Recipients_CategoryNewChats ), ChatListNodeAdditionalCategory( id: AdditionalCategoryId.contacts.rawValue, icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), cornerRadius: 12.0, color: .blue), smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/Contact"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .blue), title: presentationData.strings.BusinessMessageSetup_Recipients_CategoryContacts ), ChatListNodeAdditionalCategory( id: AdditionalCategoryId.nonContacts.rawValue, icon: generateAvatarImage(size: CGSize(width: 40.0, height: 40.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), cornerRadius: 12.0, color: .yellow), smallIcon: generateAvatarImage(size: CGSize(width: 22.0, height: 22.0), icon: generateTintedImage(image: UIImage(bundleImageName: "Chat List/Filters/User"), color: .white), iconScale: 0.6, cornerRadius: 6.0, circleCorners: true, color: .yellow), title: presentationData.strings.BusinessMessageSetup_Recipients_CategoryNonContacts ) ] } if !isExclude { for category in initialPeerList.categories { switch category { case .existingChats: selectedCategories.insert(AdditionalCategoryId.existingChats.rawValue) case .newChats: selectedCategories.insert(AdditionalCategoryId.newChats.rawValue) case .contacts: selectedCategories.insert(AdditionalCategoryId.contacts.rawValue) case .nonContacts: selectedCategories.insert(AdditionalCategoryId.nonContacts.rawValue) } } } let controller = context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams(context: context, mode: .chatSelection(ContactMultiselectionControllerMode.ChatSelection( title: presentationData.strings.ChatbotSetup_Recipients_SelectionTitle, searchPlaceholder: presentationData.strings.ChatListFilter_AddChatsSearchPlaceholder, selectedChats: Set(initialPeerList.peers.map(\.peer.id)), additionalCategories: ContactMultiselectionControllerAdditionalCategories(categories: additionalCategories, selectedCategories: selectedCategories), chatListFilters: nil, onlyUsers: true )), filters: [], alwaysEnabled: true, limit: 100, reachedLimit: { _ in })) controller.navigationPresentation = .modal let _ = (controller.result |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak controller] result in guard case let .result(rawPeerIds, additionalCategoryIds) = result else { controller?.dismiss() return } let peerIds = rawPeerIds.compactMap { id -> EnginePeer.Id? in switch id { case let .peer(id): return id case .deviceContact: return nil } } let _ = (context.engine.data.get( EngineDataMap( peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:)) ), EngineDataMap( peerIds.map(TelegramEngine.EngineData.Item.Peer.IsContact.init(id:)) ) ) |> deliverOnMainQueue).start(next: { peerMap, isContactMap in var peerList = BusinessRecipientListScreenComponent.PeerList(categories: Set(), peers: []) if !isExclude { let mappedCategories = additionalCategoryIds.compactMap { item -> PeerList.Category? in switch item { case AdditionalCategoryId.existingChats.rawValue: return .existingChats case AdditionalCategoryId.newChats.rawValue: return .newChats case AdditionalCategoryId.contacts.rawValue: return .contacts case AdditionalCategoryId.nonContacts.rawValue: return .nonContacts default: return nil } } peerList.categories = Set(mappedCategories) peerList.peers.removeAll() for id in peerIds { guard let maybePeer = peerMap[id], let peer = maybePeer else { continue } peerList.peers.append(PeerList.Peer( peer: peer, isContact: isContactMap[id] ?? false )) } peerList.peers.sort(by: { lhs, rhs in return lhs.peer.debugDisplayTitle < rhs.peer.debugDisplayTitle }) } else { peerList.peers.removeAll() for id in peerIds { guard let maybePeer = peerMap[id], let peer = maybePeer else { continue } peerList.peers.append(PeerList.Peer( peer: peer, isContact: isContactMap[id] ?? false )) } peerList.peers.sort(by: { lhs, rhs in return lhs.peer.debugDisplayTitle < rhs.peer.debugDisplayTitle }) } controller?.dismiss() completion(peerList) }) }) return controller } private func openPeerListSetup() { guard let component = self.component, let environment = self.environment else { return } let controller = BusinessRecipientListScreenComponent.View.makePeerListSetupScreen( context: component.context, mode: component.mode, initialPeerList: self.peerList, completion: { [weak self] peerList in guard let self else { return } self.peerList = peerList self.state?.updated(transition: .immediate) } ) environment.controller()?.push(controller) } func update(component: BusinessRecipientListScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } if self.component == nil { self.peerList = component.initialData.peerList } let environment = environment[EnvironmentType.self].value let themeUpdated = self.environment?.theme !== environment.theme self.environment = environment self.component = component self.state = state let alphaTransition = transition.animation.isImmediate ? transition : .easeInOut(duration: 0.25) if themeUpdated { self.backgroundColor = environment.theme.list.blocksBackgroundColor } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } let title: String switch component.mode { case .excludeExceptions, .excludeUsers: title = environment.strings.ChatbotSetup_Recipients_ExcludedListTitle case .includeExceptions: title = environment.strings.ChatbotSetup_Recipients_IncludedListTitle } let navigationTitleSize = self.navigationTitle.update( transition: transition, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: title, font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), horizontalAlignment: .center )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 100.0) ) let navigationTitleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - navigationTitleSize.width) / 2.0), y: environment.statusBarHeight + floor((environment.navigationHeight - environment.statusBarHeight - navigationTitleSize.height) / 2.0)), size: navigationTitleSize) if let navigationTitleView = self.navigationTitle.view { if navigationTitleView.superview == nil { if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { navigationBar.view.addSubview(navigationTitleView) } } transition.setFrame(view: navigationTitleView, frame: navigationTitleFrame) } let bottomContentInset: CGFloat = 24.0 let sideInset: CGFloat = 16.0 + environment.safeInsets.left let sectionSpacing: CGFloat = 32.0 var contentHeight: CGFloat = 0.0 contentHeight += environment.navigationHeight contentHeight += 16.0 var excludedSectionItems: [AnyComponentWithIdentity] = [] excludedSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: environment.strings.ChatbotSetup_Recipients_AddUsers, font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemAccentColor )), maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), leftIcon: .custom(AnyComponentWithIdentity(id: 0, component: AnyComponent(BundleIconComponent( name: "Chat List/AddIcon", tintColor: environment.theme.list.itemAccentColor ))), false), accessory: nil, action: { [weak self] _ in guard let self else { return } self.openPeerListSetup() } )))) for category in self.peerList.categories.sorted(by: { $0.rawValue < $1.rawValue }) { let title: String let icon: String let color: AvatarBackgroundColor switch category { case .newChats: title = environment.strings.BusinessMessageSetup_Recipients_CategoryNewChats icon = "Chat List/Filters/NewChats" color = .purple case .existingChats: title = environment.strings.BusinessMessageSetup_Recipients_CategoryExistingChats icon = "Chat List/Filters/Chats" color = .purple case .contacts: title = environment.strings.BusinessMessageSetup_Recipients_CategoryContacts icon = "Chat List/Filters/Contact" color = .blue case .nonContacts: title = environment.strings.BusinessMessageSetup_Recipients_CategoryNonContacts icon = "Chat List/Filters/User" color = .yellow } excludedSectionItems.append(AnyComponentWithIdentity(id: category, component: AnyComponent(PeerListItemComponent( context: component.context, theme: environment.theme, strings: environment.strings, style: .generic, sideInset: 0.0, title: title, avatar: PeerListItemComponent.Avatar( icon: icon, color: color, clipStyle: .roundedRect ), peer: nil, subtitle: nil, subtitleAccessory: .none, presence: nil, selectionState: .none, hasNext: false, action: { peer, _, _ in }, inlineActions: PeerListItemComponent.InlineActionsState( actions: [PeerListItemComponent.InlineAction( id: AnyHashable(0), title: environment.strings.Common_Delete, color: .destructive, action: { [weak self] in guard let self else { return } self.peerList.categories.remove(category) self.state?.updated(transition: .spring(duration: 0.4)) if self.peerList.categories.isEmpty && self.peerList.peers.isEmpty { let _ = self.attemptNavigation(complete: {}) self.environment?.controller()?.dismiss() } } )] ) )))) } for peer in self.peerList.peers { excludedSectionItems.append(AnyComponentWithIdentity(id: peer.peer.id, component: AnyComponent(PeerListItemComponent( context: component.context, theme: environment.theme, strings: environment.strings, style: .generic, sideInset: 0.0, title: peer.peer.displayTitle(strings: environment.strings, displayOrder: .firstLast), peer: peer.peer, subtitle: PeerListItemComponent.Subtitle(text: peer.isContact ? environment.strings.ChatList_PeerTypeContact : environment.strings.ChatList_PeerTypeNonContactUser, color: .neutral), subtitleAccessory: .none, presence: nil, selectionState: .none, hasNext: false, action: { peer, _, _ in }, inlineActions: PeerListItemComponent.InlineActionsState( actions: [PeerListItemComponent.InlineAction( id: AnyHashable(0), title: environment.strings.Common_Delete, color: .destructive, action: { [weak self] in guard let self else { return } self.peerList.peers.removeAll(where: { $0.peer.id == peer.peer.id }) self.state?.updated(transition: .spring(duration: 0.4)) if self.peerList.categories.isEmpty && self.peerList.peers.isEmpty { let _ = self.attemptNavigation(complete: {}) self.environment?.controller()?.dismiss() } } )] ) )))) } let excludedSectionSize = self.excludedSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: nil, footer: nil, items: excludedSectionItems )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let excludedSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: excludedSectionSize) if let excludedSectionView = self.excludedSection.view { if excludedSectionView.superview == nil { self.scrollView.addSubview(excludedSectionView) } transition.setFrame(view: excludedSectionView, frame: excludedSectionFrame) } contentHeight += excludedSectionSize.height contentHeight += sectionSpacing let clearSectionSize = self.clearSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: nil, footer: nil, items: [AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: environment.strings.ChatbotSetup_Recipients_RemoveAll, font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemDestructiveColor )), maximumNumberOfLines: 1 ))), ], alignment: .center, spacing: 2.0, fillWidth: true)), leftIcon: nil, icon: nil, accessory: .none, action: { [weak self] _ in guard let self else { return } self.peerList.categories.removeAll() self.peerList.peers.removeAll() self.state?.updated(transition: .spring(duration: 0.4)) if self.peerList.categories.isEmpty && self.peerList.peers.isEmpty { let _ = self.attemptNavigation(complete: {}) self.environment?.controller()?.dismiss() } } )))] )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let clearSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: clearSectionSize) if let clearSectionView = self.clearSection.view { if clearSectionView.superview == nil { self.scrollView.addSubview(clearSectionView) } transition.setFrame(view: clearSectionView, frame: clearSectionFrame) alphaTransition.setAlpha(view: clearSectionView, alpha: (self.peerList.categories.isEmpty && self.peerList.peers.isEmpty) ? 0.0 : 1.0) } if !self.peerList.categories.isEmpty || !self.peerList.peers.isEmpty { contentHeight += clearSectionSize.height } contentHeight += bottomContentInset contentHeight += environment.safeInsets.bottom let previousBounds = self.scrollView.bounds let contentSize = CGSize(width: availableSize.width, height: contentHeight) if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize) } if self.scrollView.contentSize != contentSize { self.scrollView.contentSize = contentSize } let scrollInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: 0.0, right: 0.0) if self.scrollView.scrollIndicatorInsets != scrollInsets { self.scrollView.scrollIndicatorInsets = scrollInsets } if !previousBounds.isEmpty, !transition.animation.isImmediate { let bounds = self.scrollView.bounds if bounds.maxY != previousBounds.maxY { let offsetY = previousBounds.maxY - bounds.maxY transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) } } self.topOverscrollLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -3000.0), size: CGSize(width: availableSize.width, height: 3000.0)) self.updateScrolling(transition: transition) return availableSize } } func makeView() -> View { return View() } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } final class BusinessRecipientListScreen: ViewControllerComponentContainer { final class InitialData { fileprivate let peerList: BusinessRecipientListScreenComponent.PeerList fileprivate init( peerList: BusinessRecipientListScreenComponent.PeerList ) { self.peerList = peerList } } enum Mode { case includeExceptions case excludeExceptions case excludeUsers } private let context: AccountContext init(context: AccountContext, peerList: BusinessRecipientListScreenComponent.PeerList, mode: Mode, update: @escaping (BusinessRecipientListScreenComponent.PeerList) -> Void) { self.context = context super.init(context: context, component: BusinessRecipientListScreenComponent( context: context, initialData: InitialData(peerList: peerList), mode: mode, update: update ), navigationBarAppearance: .default, theme: .default, updatedPresentationData: nil) let presentationData = context.sharedContext.currentPresentationData.with { $0 } self.title = "" self.navigationItem.backBarButtonItem = UIBarButtonItem(title: presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) self.scrollToTop = { [weak self] in guard let self, let componentView = self.node.hostView.componentView as? BusinessRecipientListScreenComponent.View else { return } componentView.scrollToTop() } self.attemptNavigation = { [weak self] complete in guard let self, let componentView = self.node.hostView.componentView as? BusinessRecipientListScreenComponent.View else { return true } return componentView.attemptNavigation(complete: complete) } } required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { } @objc private func cancelPressed() { self.dismiss() } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) } }