import Foundation import UIKit import Display import AsyncDisplayKit import SwiftSignalKit import Postbox import TelegramCore import TelegramPresentationData import TelegramUIPreferences import PresentationDataUtils import AccountContext import ComponentFlow import ViewControllerComponent import MergeLists import ComponentDisplayAdapters import ItemListPeerItem import ItemListUI import ChatListHeaderComponent import PlainButtonComponent import MultilineTextComponent import SearchBarNode import BalancedTextComponent import ChatListSearchItemHeader final class MiniAppListScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let initialData: MiniAppListScreen.InitialData init( context: AccountContext, initialData: MiniAppListScreen.InitialData ) { self.context = context self.initialData = initialData } static func ==(lhs: MiniAppListScreenComponent, rhs: MiniAppListScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } return true } private enum ContentEntry: Comparable, Identifiable { enum Id: Hashable { case item(EnginePeer.Id) } var stableId: Id { switch self { case let .item(peer, _): return .item(peer.id) } } case item(peer: EnginePeer, sortIndex: Int) static func <(lhs: ContentEntry, rhs: ContentEntry) -> Bool { switch lhs { case let .item(lhsPeer, lhsSortIndex): switch rhs { case let .item(rhsPeer, rhsSortIndex): if lhsSortIndex != rhsSortIndex { return lhsSortIndex < rhsSortIndex } return lhsPeer.id < rhsPeer.id } } } func item(listNode: ContentListNode) -> ListViewItem { switch self { case let .item(peer, _): let text: ItemListPeerItemText if case let .user(user) = peer, let subscriberCount = user.subscriberCount { text = .text(listNode.presentationData.strings.Conversation_StatusBotSubscribers(subscriberCount), .secondary) } else { text = .none } return ItemListPeerItem( presentationData: ItemListPresentationData(listNode.presentationData), dateTimeFormat: listNode.presentationData.dateTimeFormat, nameDisplayOrder: listNode.presentationData.nameDisplayOrder, context: listNode.context, peer: peer, presence: nil, text: text, label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: nil), enabled: true, selectable: true, sectionId: 0, action: { [weak listNode] in guard let listNode else { return } if let view = listNode.parentView { view.openItem(peer: peer) } }, setPeerIdWithRevealedOptions: { _, _ in }, removePeer: { _ in }, noInsets: true, header: nil ) } } } private final class ContentListNode: ListView { weak var parentView: View? let context: AccountContext var presentationData: PresentationData private var currentEntries: [ContentEntry] = [] private var originalEntries: [ContentEntry] = [] init(parentView: View, context: AccountContext) { self.parentView = parentView self.context = context self.presentationData = context.sharedContext.currentPresentationData.with({ $0 }) super.init() } func update(size: CGSize, insets: UIEdgeInsets, transition: ComponentTransition) { let (listViewDuration, listViewCurve) = listViewAnimationDurationAndCurve(transition: transition.containedViewLayoutTransition) self.transaction( deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency, .PreferSynchronousResourceLoading], additionalScrollDistance: 0.0, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: size, insets: insets, duration: listViewDuration, curve: listViewCurve), updateOpaqueState: nil ) } func setEntries(entries: [ContentEntry], animated: Bool) { self.originalEntries = entries let entries = entries let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: self.currentEntries, rightList: entries) self.currentEntries = entries let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(listNode: self), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(listNode: self), directionHint: nil) } var options: ListViewDeleteAndInsertOptions = [.Synchronous, .LowLatency] if animated { options.insert(.AnimateInsertion) } else { options.insert(.PreferSynchronousResourceLoading) } self.transaction( deleteIndices: deletions, insertIndicesAndItems: insertions, updateIndicesAndItems: updates, options: options, scrollToItem: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in } ) } } final class View: UIView { private var contentListNode: ContentListNode? private var ignoreVisibleContentOffsetChanged: Bool = false private var emptySearchState: ComponentView? private let navigationBarView = ComponentView() private var navigationHeight: CGFloat? private let sectionHeader = ComponentView() private var searchBarNode: SearchBarNode? private var isUpdating: Bool = false private var component: MiniAppListScreenComponent? private(set) weak var state: EmptyComponentState? private var environment: EnvironmentType? private var recommendedAppPeers: [EnginePeer]? private var recommendedAppPeersDisposable: Disposable? private var keepUpdatedDisposable: Disposable? private var isSearchDisplayControllerActive: Bool = false private var searchQuery: String = "" override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.recommendedAppPeersDisposable?.dispose() self.keepUpdatedDisposable?.dispose() } func scrollToTop() { } func attemptNavigation(complete: @escaping () -> Void) -> Bool { return true } func openItem(peer: EnginePeer) { guard let component = self.component else { return } guard let environment = self.environment, let controller = environment.controller() else { return } if let peerInfoScreen = component.context.sharedContext.makePeerInfoController(context: component.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { peerInfoScreen.navigationPresentation = .modal controller.push(peerInfoScreen) } } private func updateNavigationBar( component: MiniAppListScreenComponent, theme: PresentationTheme, strings: PresentationStrings, size: CGSize, insets: UIEdgeInsets, statusBarHeight: CGFloat, isModal: Bool, transition: ComponentTransition, deferScrollApplication: Bool ) -> CGFloat { let rightButtons: [AnyComponentWithIdentity] = [] let titleText: String = strings.MiniAppList_Title let closeTitle: String = strings.Common_Close let headerContent: ChatListHeaderComponent.Content? = ChatListHeaderComponent.Content( title: titleText, navigationBackTitle: nil, titleComponent: nil, chatListTitle: nil, leftButton: isModal ? AnyComponentWithIdentity(id: "close", component: AnyComponent(NavigationButtonComponent( content: .text(title: closeTitle, isBold: false), pressed: { [weak self] _ in guard let self else { return } if self.attemptNavigation(complete: {}) { self.environment?.controller()?.dismiss() } } ))) : nil, rightButtons: rightButtons, backTitle: isModal ? nil : strings.Common_Back, backPressed: { [weak self] in guard let self else { return } if self.attemptNavigation(complete: {}) { self.environment?.controller()?.dismiss() } } ) let navigationBarSize = self.navigationBarView.update( transition: transition, component: AnyComponent(ChatListNavigationBar( context: component.context, theme: theme, strings: strings, statusBarHeight: statusBarHeight, sideInset: insets.left, isSearchActive: self.isSearchDisplayControllerActive, isSearchEnabled: true, primaryContent: headerContent, secondaryContent: nil, secondaryTransition: 0.0, storySubscriptions: nil, storiesIncludeHidden: false, uploadProgress: [:], tabsNode: nil, tabsNodeIsSearch: false, accessoryPanelContainer: nil, accessoryPanelContainerHeight: 0.0, activateSearch: { [weak self] _ in guard let self else { return } self.isSearchDisplayControllerActive = true self.state?.updated(transition: .spring(duration: 0.4)) }, openStatusSetup: { _ in }, allowAutomaticOrder: { } )), environment: {}, containerSize: size ) let sectionHeaderSize = self.sectionHeader.update( transition: transition, component: AnyComponent(ListHeaderComponent( theme: theme, title: strings.MiniAppList_ListSectionHeader )), environment: {}, containerSize: CGSize(width: size.width, height: 1000.0) ) if let sectionHeaderView = self.sectionHeader.view { if sectionHeaderView.superview == nil { sectionHeaderView.layer.anchorPoint = CGPoint() self.addSubview(sectionHeaderView) } transition.setBounds(view: sectionHeaderView, bounds: CGRect(origin: CGPoint(), size: sectionHeaderSize)) } if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { if deferScrollApplication { navigationBarComponentView.deferScrollApplication = true } if navigationBarComponentView.superview == nil { self.addSubview(navigationBarComponentView) } transition.setFrame(view: navigationBarComponentView, frame: CGRect(origin: CGPoint(), size: navigationBarSize)) return navigationBarSize.height } else { return 0.0 } } private func updateNavigationScrolling(navigationHeight: CGFloat, transition: ComponentTransition) { var mainOffset: CGFloat if let recommendedAppPeers = self.recommendedAppPeers, !recommendedAppPeers.isEmpty { if let contentListNode = self.contentListNode { switch contentListNode.visibleContentOffset() { case .none: mainOffset = 0.0 case .unknown: mainOffset = navigationHeight case let .known(value): mainOffset = value } } else { mainOffset = navigationHeight } } else { mainOffset = navigationHeight } mainOffset = min(mainOffset, ChatListNavigationBar.searchScrollHeight) if abs(mainOffset) < 0.1 { mainOffset = 0.0 } let resultingOffset = mainOffset var offset = resultingOffset if self.isSearchDisplayControllerActive { offset = 0.0 } if let sectionHeaderView = self.sectionHeader.view { transition.setPosition(view: sectionHeaderView, position: CGPoint(x: 0.0, y: navigationHeight - offset)) } if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { navigationBarComponentView.applyScroll(offset: offset, allowAvatarsExpansion: false, forceUpdate: false, transition: transition.withUserData(ChatListNavigationBar.AnimationHint( disableStoriesAnimations: false, crossfadeStoryPeers: false ))) } } func update(component: MiniAppListScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } if self.component == nil { self.recommendedAppPeers = component.initialData.recommendedAppPeers /*self.shortcutMessageListDisposable = (component.context.engine.accountData.shortcutMessageList(onlyRemote: false) |> deliverOnMainQueue).startStrict(next: { [weak self] shortcutMessageList in guard let self else { return } self.shortcutMessageList = shortcutMessageList if !self.isUpdating { self.state?.updated(transition: .immediate) } })*/ self.keepUpdatedDisposable = component.context.engine.peers.requestRecommendedAppsIfNeeded().startStrict() } let environment = environment[EnvironmentType.self].value let themeUpdated = self.environment?.theme !== environment.theme self.environment = environment self.component = component self.state = state if themeUpdated { self.backgroundColor = environment.theme.list.plainBackgroundColor } var isModal = false if let controller = environment.controller(), controller.navigationPresentation == .modal { isModal = true } var statusBarHeight = environment.statusBarHeight if isModal { statusBarHeight = max(statusBarHeight, 1.0) } let listBottomInset = environment.safeInsets.bottom + environment.additionalInsets.bottom let navigationHeight = self.updateNavigationBar( component: component, theme: environment.theme, strings: environment.strings, size: availableSize, insets: environment.safeInsets, statusBarHeight: statusBarHeight, isModal: isModal, transition: transition, deferScrollApplication: true ) self.navigationHeight = navigationHeight var removedSearchBar: SearchBarNode? if self.isSearchDisplayControllerActive { let searchBarNode: SearchBarNode var searchBarTransition = transition if let current = self.searchBarNode { searchBarNode = current } else { searchBarTransition = .immediate let searchBarTheme = SearchBarNodeTheme(theme: environment.theme, hasSeparator: false) searchBarNode = SearchBarNode( theme: searchBarTheme, strings: environment.strings, fieldStyle: .modern, displayBackground: false ) searchBarNode.placeholderString = NSAttributedString(string: environment.strings.Common_Search, font: Font.regular(17.0), textColor: searchBarTheme.placeholder) self.searchBarNode = searchBarNode searchBarNode.cancel = { [weak self] in guard let self else { return } self.isSearchDisplayControllerActive = false self.state?.updated(transition: .spring(duration: 0.4)) } searchBarNode.textUpdated = { [weak self] query, _ in guard let self else { return } if self.searchQuery != query { self.searchQuery = query.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) self.state?.updated(transition: .immediate) } } DispatchQueue.main.async { [weak self, weak searchBarNode] in guard let self, let searchBarNode, self.searchBarNode === searchBarNode else { return } searchBarNode.activate() } } var searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: navigationHeight - 54.0 + 2.0), size: CGSize(width: availableSize.width, height: 54.0)) if isModal { searchBarFrame.origin.y += 2.0 } searchBarNode.updateLayout(boundingSize: searchBarFrame.size, leftInset: environment.safeInsets.left + 6.0, rightInset: environment.safeInsets.right, transition: searchBarTransition.containedViewLayoutTransition) searchBarTransition.setFrame(view: searchBarNode.view, frame: searchBarFrame) if searchBarNode.view.superview == nil { self.addSubview(searchBarNode.view) if case let .curve(duration, curve) = transition.animation, let navigationBarView = self.navigationBarView.view as? ChatListNavigationBar.View, let placeholderNode = navigationBarView.searchContentNode?.placeholderNode { let timingFunction: String switch curve { case .easeInOut: timingFunction = CAMediaTimingFunctionName.easeOut.rawValue case .linear: timingFunction = CAMediaTimingFunctionName.linear.rawValue case .spring: timingFunction = kCAMediaTimingFunctionSpring case .custom: timingFunction = kCAMediaTimingFunctionSpring } searchBarNode.animateIn(from: placeholderNode, duration: duration, timingFunction: timingFunction) } } } else { self.searchQuery = "" if let searchBarNode = self.searchBarNode { self.searchBarNode = nil removedSearchBar = searchBarNode } } let contentListNode: ContentListNode if let current = self.contentListNode { contentListNode = current } else { contentListNode = ContentListNode(parentView: self, context: component.context) self.contentListNode = contentListNode contentListNode.visibleContentOffsetChanged = { [weak self] offset in guard let self else { return } guard let navigationHeight = self.navigationHeight else { return } if self.ignoreVisibleContentOffsetChanged { return } self.updateNavigationScrolling(navigationHeight: navigationHeight, transition: .immediate) } if let sectionHeaderView = self.sectionHeader.view { self.insertSubview(contentListNode.view, belowSubview: sectionHeaderView) } else if let navigationBarComponentView = self.navigationBarView.view { self.insertSubview(contentListNode.view, belowSubview: navigationBarComponentView) } else { self.addSubview(contentListNode.view) } } var contentTopInset = navigationHeight if let sectionHeaderView = self.sectionHeader.view { contentTopInset += sectionHeaderView.bounds.height } transition.setFrame(view: contentListNode.view, frame: CGRect(origin: CGPoint(), size: availableSize)) self.ignoreVisibleContentOffsetChanged = true contentListNode.update(size: availableSize, insets: UIEdgeInsets(top: contentTopInset, left: environment.safeInsets.left, bottom: listBottomInset, right: environment.safeInsets.right), transition: transition) self.ignoreVisibleContentOffsetChanged = false var entries: [ContentEntry] = [] if let recommendedAppPeers = self.recommendedAppPeers { let normalizedSearchQuery = self.searchQuery.lowercased().trimmingTrailingSpaces() for peer in recommendedAppPeers { if !self.searchQuery.isEmpty { var matches = false if peer.indexName.matchesByTokens(normalizedSearchQuery) { matches = true } if !matches { continue } } entries.append(.item(peer: peer, sortIndex: entries.count)) } } contentListNode.setEntries(entries: entries, animated: !transition.animation.isImmediate) if let sectionHeaderView = self.sectionHeader.view { sectionHeaderView.isHidden = entries.isEmpty } if !self.searchQuery.isEmpty && entries.isEmpty { var emptySearchStateTransition = transition let emptySearchState: ComponentView if let current = self.emptySearchState { emptySearchState = current } else { emptySearchStateTransition = emptySearchStateTransition.withAnimation(.none) emptySearchState = ComponentView() self.emptySearchState = emptySearchState } let emptySearchStateSize = emptySearchState.update( transition: .immediate, component: AnyComponent(BalancedTextComponent( text: .plain(NSAttributedString(string: environment.strings.Conversation_SearchNoResults, font: Font.regular(17.0), textColor: environment.theme.list.freeTextColor, paragraphAlignment: .center)), horizontalAlignment: .center, maximumNumberOfLines: 0 )), environment: {}, containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: availableSize.height) ) var emptySearchStateBottomInset = listBottomInset emptySearchStateBottomInset = max(emptySearchStateBottomInset, environment.inputHeight) let emptySearchStateFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - emptySearchStateSize.width) * 0.5), y: navigationHeight + floor((availableSize.height - emptySearchStateBottomInset - navigationHeight) * 0.5)), size: emptySearchStateSize) if let emptySearchStateView = emptySearchState.view { if emptySearchStateView.superview == nil { if let navigationBarComponentView = self.navigationBarView.view { self.insertSubview(emptySearchStateView, belowSubview: navigationBarComponentView) } else { self.addSubview(emptySearchStateView) } } emptySearchStateTransition.containedViewLayoutTransition.updatePosition(layer: emptySearchStateView.layer, position: emptySearchStateFrame.center) emptySearchStateView.bounds = CGRect(origin: CGPoint(), size: emptySearchStateFrame.size) } } else if let emptySearchState = self.emptySearchState { self.emptySearchState = nil emptySearchState.view?.removeFromSuperview() } if let recommendedAppPeers = self.recommendedAppPeers, !recommendedAppPeers.isEmpty { contentListNode.isHidden = false } else { contentListNode.isHidden = true } self.updateNavigationScrolling(navigationHeight: navigationHeight, transition: transition) if let navigationBarComponentView = self.navigationBarView.view as? ChatListNavigationBar.View { navigationBarComponentView.deferScrollApplication = false navigationBarComponentView.applyCurrentScroll(transition: transition) } if let removedSearchBar { if !transition.animation.isImmediate, let navigationBarView = self.navigationBarView.view as? ChatListNavigationBar.View, let placeholderNode = navigationBarView.searchContentNode?.placeholderNode { removedSearchBar.transitionOut(to: placeholderNode, transition: transition.containedViewLayoutTransition, completion: { [weak removedSearchBar] in removedSearchBar?.view.removeFromSuperview() }) } else { removedSearchBar.view.removeFromSuperview() } } 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) } } public final class MiniAppListScreen: ViewControllerComponentContainer { public final class InitialData: MiniAppListScreenInitialData { let recommendedAppPeers: [EnginePeer] init( recommendedAppPeers: [EnginePeer] ) { self.recommendedAppPeers = recommendedAppPeers } } private let context: AccountContext public init(context: AccountContext, initialData: InitialData) { self.context = context super.init(context: context, component: MiniAppListScreenComponent( context: context, initialData: initialData ), navigationBarAppearance: .none, theme: .default, updatedPresentationData: nil) self.navigationPresentation = .modal self.scrollToTop = { [weak self] in guard let self, let componentView = self.node.hostView.componentView as? MiniAppListScreenComponent.View else { return } componentView.scrollToTop() } self.attemptNavigation = { [weak self] complete in guard let self, let componentView = self.node.hostView.componentView as? MiniAppListScreenComponent.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) } public static func initialData(context: AccountContext) -> Signal { let recommendedAppPeers = context.engine.peers.recommendedAppPeerIds() |> take(1) |> mapToSignal { peerIds -> Signal<[EnginePeer], NoError> in guard let peerIds else { return .single([]) } return context.engine.data.get( EngineDataList(peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) ) |> map { peers -> [EnginePeer] in return peers.compactMap { $0 } } } return recommendedAppPeers |> map { recommendedAppPeers -> MiniAppListScreenInitialData in return InitialData( recommendedAppPeers: recommendedAppPeers ) } } } private final class ListHeaderComponent: Component { let theme: PresentationTheme let title: String init( theme: PresentationTheme, title: String ) { self.theme = theme self.title = title } static func ==(lhs: ListHeaderComponent, rhs: ListHeaderComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.title != rhs.title { return false } return true } final class View: UIView { private let title = ComponentView() private var component: ListHeaderComponent? override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: ListHeaderComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { if self.component?.theme !== component.theme { self.backgroundColor = component.theme.chatList.sectionHeaderFillColor } let insets = UIEdgeInsets(top: 7.0, left: 16.0, bottom: 7.0, right: 16.0) let titleString = component.title let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: titleString, font: Font.regular(13.0), textColor: component.theme.chatList.sectionHeaderTextColor)) )), environment: {}, containerSize: CGSize(width: availableSize.width - insets.left - insets.right, height: 100.0) ) if let titleView = self.title.view { if titleView.superview == nil { self.addSubview(titleView) } titleView.frame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: titleSize) } return CGSize(width: availableSize.width, height: titleSize.height + insets.top + insets.bottom) } } func makeView() -> View { return View(frame: CGRect()) } 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) } }