import Foundation import UIKit import Photos 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 ItemListPeerActionItem import ItemListUI import ChatListUI import QuickReplyNameAlertController import ChatListHeaderComponent import PlainButtonComponent import MultilineTextComponent import AttachmentUI import SearchBarNode import BalancedTextComponent final class QuickReplySetupScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let initialData: QuickReplySetupScreen.InitialData let mode: QuickReplySetupScreen.Mode init( context: AccountContext, initialData: QuickReplySetupScreen.InitialData, mode: QuickReplySetupScreen.Mode ) { self.context = context self.initialData = initialData self.mode = mode } static func ==(lhs: QuickReplySetupScreenComponent, rhs: QuickReplySetupScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } return true } private enum ContentEntry: Comparable, Identifiable { enum Id: Hashable { case add case item(Int32) case pendingItem(String) } var stableId: Id { switch self { case .add: return .add case let .item(item, _, _, _, _): if let itemId = item.id { return .item(itemId) } else { return .pendingItem(item.shortcut) } } } case add case item(item: ShortcutMessageList.Item, accountPeer: EnginePeer, sortIndex: Int, isEditing: Bool, isSelected: Bool) static func <(lhs: ContentEntry, rhs: ContentEntry) -> Bool { switch lhs { case .add: return false case let .item(lhsItem, _, lhsSortIndex, _, _): switch rhs { case .add: return false case let .item(rhsItem, _, rhsSortIndex, _, _): if lhsSortIndex != rhsSortIndex { return lhsSortIndex < rhsSortIndex } return lhsItem.shortcut < rhsItem.shortcut } } } func item(listNode: ContentListNode) -> ListViewItem { switch self { case .add: return ItemListPeerActionItem( presentationData: ItemListPresentationData(listNode.presentationData), icon: PresentationResourcesItemList.plusIconImage(listNode.presentationData.theme), iconSignal: nil, title: listNode.presentationData.strings.QuickReply_InlineCreateAction, additionalBadgeIcon: nil, alwaysPlain: true, hasSeparator: true, sectionId: 0, height: .generic, color: .accent, editing: false, action: { [weak listNode] in guard let listNode, let parentView = listNode.parentView else { return } parentView.openQuickReplyChat(shortcut: nil, shortcutId: nil) } ) case let .item(item, accountPeer, _, isEditing, isSelected): let chatListNodeInteraction = ChatListNodeInteraction( context: listNode.context, animationCache: listNode.context.animationCache, animationRenderer: listNode.context.animationRenderer, activateSearch: { }, peerSelected: { [weak listNode] _, _, _, _ in guard let listNode, let parentView = listNode.parentView else { return } parentView.openQuickReplyChat(shortcut: item.shortcut, shortcutId: item.id) }, disabledPeerSelected: { _, _, _ in }, togglePeerSelected: { [weak listNode] _, _ in guard let listNode, let parentView = listNode.parentView else { return } if let itemId = item.id { parentView.toggleShortcutSelection(id: itemId) } }, togglePeersSelection: { [weak listNode] _, _ in guard let listNode, let parentView = listNode.parentView else { return } if let itemId = item.id { parentView.toggleShortcutSelection(id: itemId) } }, additionalCategorySelected: { _ in }, messageSelected: { [weak listNode] _, _, _, _ in guard let listNode, let parentView = listNode.parentView else { return } parentView.openQuickReplyChat(shortcut: item.shortcut, shortcutId: item.id) }, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { [weak listNode] _, _ in guard let listNode, let parentView = listNode.parentView else { return } if let itemId = item.id { parentView.openDeleteShortcuts(ids: [itemId]) } }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, setPeerThreadHidden: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in }, toggleArchivedFolderHiddenByDefault: { }, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { _, _, _, _, _ in }, present: { _ in }, openForumThread: { _, _ in }, openStorageManagement: { }, openPasswordSetup: { }, openPremiumIntro: { }, openPremiumGift: { _ in }, openPremiumManagement: { }, openActiveSessions: { }, openBirthdaySetup: { }, performActiveSessionAction: { _, _ in }, openChatFolderUpdates: { }, hideChatFolderUpdates: { }, openStories: { _, _ in }, dismissNotice: { _ in }, editPeer: { [weak listNode] _ in guard let listNode, let parentView = listNode.parentView else { return } if let itemId = item.id { parentView.openEditShortcut(id: itemId, currentValue: item.shortcut) } } ) let presentationData = listNode.context.sharedContext.currentPresentationData.with({ $0 }) let chatListPresentationData = ChatListPresentationData( theme: presentationData.theme, fontSize: presentationData.listsFontSize, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameSortOrder: presentationData.nameSortOrder, nameDisplayOrder: presentationData.nameDisplayOrder, disableAnimations: false ) return ChatListItem( presentationData: chatListPresentationData, context: listNode.context, chatListLocation: .chatList(groupId: .root), filterData: nil, index: EngineChatList.Item.Index.chatList(ChatListIndex(pinningIndex: nil, messageIndex: MessageIndex(id: MessageId(peerId: listNode.context.account.peerId, namespace: 0, id: 0), timestamp: 0))), content: .peer(ChatListItemContent.PeerData( messages: [item.topMessage], peer: EngineRenderedPeer(peer: accountPeer), threadInfo: nil, combinedReadState: nil, isRemovedFromTotalUnreadCount: false, presence: nil, hasUnseenMentions: false, hasUnseenReactions: false, draftState: nil, mediaDraftContentType: nil, inputActivities: nil, promoInfo: nil, ignoreUnreadBadge: false, displayAsMessage: false, hasFailedMessages: false, forumTopicData: nil, topForumTopicItems: [], autoremoveTimeout: nil, storyState: nil, requiresPremiumForMessaging: false, displayAsTopicList: false, tags: [], customMessageListData: ChatListItemContent.CustomMessageListData( commandPrefix: "/\(item.shortcut)", searchQuery: nil, messageCount: item.totalCount, hideSeparator: false, hideDate: true, hidePeerStatus: true ) )), editing: isEditing, hasActiveRevealControls: false, selected: isSelected, header: nil, enableContextActions: true, hiddenOffset: false, interaction: chatListNodeInteraction ) } } } private final class ContentListNode: ListView { weak var parentView: View? let context: AccountContext var presentationData: PresentationData private var currentEntries: [ContentEntry] = [] private var originalEntries: [ContentEntry] = [] private var tempOrder: [Int32]? private var pendingRemoveItems: [Int32]? private var resetTempOrderOnNextUpdate: Bool = false init(parentView: View, context: AccountContext) { self.parentView = parentView self.context = context self.presentationData = context.sharedContext.currentPresentationData.with({ $0 }) super.init() self.reorderBegan = { [weak self] in guard let self else { return } self.tempOrder = nil } self.reorderCompleted = { [weak self] _ in guard let self, let tempOrder = self.tempOrder else { return } self.resetTempOrderOnNextUpdate = true self.context.engine.accountData.reorderMessageShortcuts(ids: tempOrder, completion: {}) } self.reorderItem = { [weak self] fromIndex, toIndex, transactionOpaqueState -> Signal in guard let self else { return .single(false) } guard fromIndex >= 0 && fromIndex < self.currentEntries.count && toIndex >= 0 && toIndex < self.currentEntries.count else { return .single(false) } let fromEntry = self.currentEntries[fromIndex] let toEntry = self.currentEntries[toIndex] var referenceId: Int32? var beforeAll = false switch toEntry { case let .item(item, _, _, _, _): referenceId = item.id case .add: beforeAll = true } if case let .item(item, _, _, _, _) = fromEntry { var itemIds = self.currentEntries.compactMap { entry -> Int32? in switch entry { case .add: return nil case let .item(item, _, _, _, _): return item.id } } let itemId: Int32? = item.id if let itemId { itemIds = itemIds.filter({ $0 != itemId }) if let referenceId { var inserted = false for i in 0 ..< itemIds.count { if itemIds[i] == referenceId { if fromIndex < toIndex { itemIds.insert(itemId, at: i + 1) } else { itemIds.insert(itemId, at: i) } inserted = true break } } if !inserted { itemIds.append(itemId) } } else if beforeAll { itemIds.insert(itemId, at: 0) } else { itemIds.append(itemId) } if self.tempOrder != itemIds { self.tempOrder = itemIds self.setEntries(entries: self.originalEntries, animated: true) } return .single(true) } else { return .single(false) } } else { return .single(false) } } } 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 setPendingRemoveItems(itemIds: [Int32]) { self.pendingRemoveItems = itemIds self.setEntries(entries: self.originalEntries, animated: true) } func setEntries(entries: [ContentEntry], animated: Bool) { if self.resetTempOrderOnNextUpdate { self.resetTempOrderOnNextUpdate = false self.tempOrder = nil } let pendingRemoveItems = self.pendingRemoveItems self.pendingRemoveItems = nil self.originalEntries = entries var entries = entries if let pendingRemoveItems { entries = entries.filter { entry in switch entry.stableId { case .add: return true case let .item(id): return !pendingRemoveItems.contains(id) case .pendingItem: return true } } } if let tempOrder = self.tempOrder { let originalList = entries entries.removeAll() if let entry = originalList.first(where: { entry in if case .add = entry { return true } else { return false } }) { entries.append(entry) } for id in tempOrder { if let entry = originalList.first(where: { entry in if case let .item(listId) = entry.stableId, listId == id { return true } else { return false } }) { entries.append(entry) } } for entry in originalList { if !entries.contains(where: { listEntry in listEntry.stableId == entry.stableId }) { entries.append(entry) } } } 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 emptyState: ComponentView? private var contentListNode: ContentListNode? private var emptySearchState: ComponentView? private let navigationBarView = ComponentView() private var navigationHeight: CGFloat? private var searchBarNode: SearchBarNode? private var selectionPanel: ComponentView? private var isUpdating: Bool = false private var component: QuickReplySetupScreenComponent? private(set) weak var state: EmptyComponentState? private var environment: EnvironmentType? private var shortcutMessageList: ShortcutMessageList? private var shortcutMessageListDisposable: Disposable? private var keepUpdatedDisposable: Disposable? private var selectedIds = Set() private var isEditing: Bool = false private var isSearchDisplayControllerActive: Bool = false private var searchQuery: String = "" private let searchQueryComponentSeparationCharacterSet: CharacterSet private var accountPeer: EnginePeer? override init(frame: CGRect) { self.searchQueryComponentSeparationCharacterSet = CharacterSet(charactersIn: " _.:/") super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.shortcutMessageListDisposable?.dispose() self.keepUpdatedDisposable?.dispose() } func scrollToTop() { } func attemptNavigation(complete: @escaping () -> Void) -> Bool { return true } func openQuickReplyChat(shortcut: String?, shortcutId: Int32?) { guard let component = self.component, let environment = self.environment, let controller = self.environment?.controller() as? QuickReplySetupScreen else { return } self.contentListNode?.clearHighlightAnimated(true) if let shortcut { if let shortcutId, case let .select(completion) = component.mode { completion(shortcutId) return } let shortcutType: ChatQuickReplyShortcutType if shortcut == "hello" { shortcutType = .greeting } else if shortcut == "away" { shortcutType = .away } else { shortcutType = .generic } let contents = AutomaticBusinessMessageSetupChatContents( context: component.context, kind: .quickReplyMessageInput(shortcut: shortcut, shortcutType: shortcutType), shortcutId: shortcutId ) let chatController = component.context.sharedContext.makeChatController( context: component.context, chatLocation: .customChatContents, subject: .customChatContents(contents: contents), botStart: nil, mode: .standard(.default) ) chatController.navigationPresentation = .modal if controller.navigationController != nil { controller.push(chatController) } else if let attachmentContainer = controller.parentController() { attachmentContainer.push(chatController) } } else { var completion: ((String?) -> Void)? let alertController = quickReplyNameAlertController( context: component.context, text: environment.strings.QuickReply_CreateShortcutTitle, subtext: environment.strings.QuickReply_CreateShortcutText, value: "", characterLimit: 32, apply: { value in completion?(value) } ) completion = { [weak self, weak alertController] value in guard let self, let environment = self.environment else { alertController?.dismissAnimated() return } if let value, !value.isEmpty { guard let shortcutMessageList = self.shortcutMessageList else { alertController?.dismissAnimated() return } if shortcutMessageList.items.contains(where: { $0.shortcut.lowercased() == value.lowercased() }) { if let contentNode = alertController?.contentNode as? QuickReplyNameAlertContentNode { contentNode.setErrorText(errorText: environment.strings.QuickReply_ShortcutExistsInlineError) } return } alertController?.view.endEditing(true) alertController?.dismissAnimated() self.openQuickReplyChat(shortcut: value, shortcutId: nil) } } self.environment?.controller()?.present(alertController, in: .window(.root)) } } func openEditShortcut(id: Int32, currentValue: String) { guard let component = self.component, let environment = self.environment else { return } var completion: ((String?) -> Void)? let alertController = quickReplyNameAlertController( context: component.context, text: environment.strings.QuickReply_EditShortcutTitle, subtext: environment.strings.QuickReply_EditShortcutText, value: currentValue, characterLimit: 32, apply: { value in completion?(value) } ) completion = { [weak self, weak alertController] value in guard let self, let component = self.component, let environment = self.environment else { alertController?.dismissAnimated() return } if let value, !value.isEmpty { if value == currentValue { alertController?.dismissAnimated() return } guard let shortcutMessageList = self.shortcutMessageList else { alertController?.dismissAnimated() return } if shortcutMessageList.items.contains(where: { $0.shortcut.lowercased() == value.lowercased() }) { if let contentNode = alertController?.contentNode as? QuickReplyNameAlertContentNode { contentNode.setErrorText(errorText: environment.strings.QuickReply_ShortcutExistsInlineError) } } else { component.context.engine.accountData.editMessageShortcut(id: id, shortcut: value) alertController?.view.endEditing(true) alertController?.dismissAnimated() } } } self.environment?.controller()?.present(alertController, in: .window(.root)) } func toggleShortcutSelection(id: Int32) { if self.selectedIds.contains(id) { self.selectedIds.remove(id) } else { self.selectedIds.insert(id) } self.state?.updated(transition: .spring(duration: 0.4)) } func openDeleteShortcuts(ids: [Int32]) { guard let component = self.component else { return } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } let actionSheet = ActionSheetController(presentationData: presentationData) var items: [ActionSheetItem] = [] items.append(ActionSheetButtonItem(title: ids.count == 1 ? presentationData.strings.QuickReply_DeleteConfirmationSingle : presentationData.strings.QuickReply_DeleteConfirmationMultiple, color: .destructive, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() guard let self, let component = self.component else { return } for id in ids { self.selectedIds.remove(id) } self.contentListNode?.setPendingRemoveItems(itemIds: ids) component.context.engine.accountData.deleteMessageShortcuts(ids: ids) self.state?.updated(transition: .spring(duration: 0.4)) })) actionSheet.setItemGroups([ ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ]) ]) self.environment?.controller()?.present(actionSheet, in: .window(.root)) } private func updateNavigationBar( component: QuickReplySetupScreenComponent, theme: PresentationTheme, strings: PresentationStrings, size: CGSize, insets: UIEdgeInsets, statusBarHeight: CGFloat, isModal: Bool, transition: ComponentTransition, deferScrollApplication: Bool ) -> CGFloat { var rightButtons: [AnyComponentWithIdentity] = [] if let shortcutMessageList = self.shortcutMessageList, !shortcutMessageList.items.isEmpty { if self.isEditing { rightButtons.append(AnyComponentWithIdentity(id: "done", component: AnyComponent(NavigationButtonComponent( content: .text(title: strings.Common_Done, isBold: true), pressed: { [weak self] _ in guard let self else { return } self.isEditing = false self.selectedIds.removeAll() self.state?.updated(transition: .spring(duration: 0.4)) } )))) } else { rightButtons.append(AnyComponentWithIdentity(id: "edit", component: AnyComponent(NavigationButtonComponent( content: .text(title: strings.Common_Edit, isBold: false), pressed: { [weak self] _ in guard let self else { return } self.isEditing = true self.state?.updated(transition: .spring(duration: 0.4)) } )))) } } let titleText: String if !self.selectedIds.isEmpty { titleText = strings.QuickReply_SelectedTitle(Int32(self.selectedIds.count)) } else { titleText = strings.QuickReply_Title } let closeTitle: String switch component.mode { case .manage: closeTitle = strings.Common_Close case .select: closeTitle = strings.Common_Cancel } 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: !self.isEditing, 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 ) 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 shortcutMessageList = self.shortcutMessageList, !shortcutMessageList.items.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 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: QuickReplySetupScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } if self.component == nil { self.accountPeer = component.initialData.accountPeer self.shortcutMessageList = component.initialData.shortcutMessageList 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.accountData.keepShortcutMessageListUpdated().startStrict() } let environment = environment[EnvironmentType.self].value let themeUpdated = self.environment?.theme !== environment.theme self.environment = environment self.component = component self.state = state let alphaTransition: ComponentTransition = transition.animation.isImmediate ? transition : transition.withAnimation(.curve(duration: 0.25, curve: .easeInOut)) let _ = alphaTransition if themeUpdated { self.backgroundColor = environment.theme.list.plainBackgroundColor } if let shortcutMessageList = self.shortcutMessageList, !shortcutMessageList.items.isEmpty { if let emptyState = self.emptyState { self.emptyState = nil emptyState.view?.removeFromSuperview() } } else { let emptyState: ComponentView var emptyStateTransition = transition if let current = self.emptyState { emptyState = current } else { emptyState = ComponentView() self.emptyState = emptyState emptyStateTransition = emptyStateTransition.withAnimation(.none) } let emptyStateFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height)) let _ = emptyState.update( transition: emptyStateTransition, component: AnyComponent(QuickReplyEmptyStateComponent( theme: environment.theme, strings: environment.strings, insets: UIEdgeInsets(top: environment.navigationHeight, left: environment.safeInsets.left, bottom: environment.safeInsets.bottom + environment.additionalInsets.bottom, right: environment.safeInsets.right), action: { [weak self] in guard let self else { return } self.openQuickReplyChat(shortcut: nil, shortcutId: nil) } )), environment: {}, containerSize: emptyStateFrame.size ) if let emptyStateView = emptyState.view { if emptyStateView.superview == nil { if let navigationBarComponentView = self.navigationBarView.view { self.insertSubview(emptyStateView, belowSubview: navigationBarComponentView) } else { self.addSubview(emptyStateView) } } emptyStateTransition.setFrame(view: emptyStateView, frame: emptyStateFrame) } } var isModal = false if let controller = environment.controller(), controller.navigationPresentation == .modal { isModal = true } if case .select = component.mode { isModal = true } var statusBarHeight = environment.statusBarHeight if isModal { statusBarHeight = max(statusBarHeight, 1.0) } var 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() if let controller = self.environment?.controller() as? QuickReplySetupScreen { controller.requestAttachmentMenuExpansion() } } } 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 } } if !self.selectedIds.isEmpty { let selectionPanel: ComponentView var selectionPanelTransition = transition if let current = self.selectionPanel { selectionPanel = current } else { selectionPanelTransition = selectionPanelTransition.withAnimation(.none) selectionPanel = ComponentView() self.selectionPanel = selectionPanel } let buttonTitle: String = environment.strings.QuickReply_DeleteAction(Int32(self.selectedIds.count)) let selectionPanelSize = selectionPanel.update( transition: selectionPanelTransition, component: AnyComponent(BottomPanelComponent( theme: environment.theme, content: AnyComponentWithIdentity(id: 0, component: AnyComponent(PlainButtonComponent( content: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: buttonTitle, font: Font.regular(17.0), textColor: environment.theme.list.itemDestructiveColor)) )), background: nil, effectAlignment: .center, minSize: CGSize(width: availableSize.width - environment.safeInsets.left - environment.safeInsets.right, height: 44.0), contentInsets: UIEdgeInsets(), action: { [weak self] in guard let self else { return } if self.selectedIds.isEmpty { return } self.openDeleteShortcuts(ids: Array(self.selectedIds)) }, animateAlpha: true, animateScale: false, animateContents: false ))), insets: UIEdgeInsets(top: 4.0, left: environment.safeInsets.left, bottom: environment.safeInsets.bottom + environment.additionalInsets.bottom, right: environment.safeInsets.right) )), environment: {}, containerSize: availableSize ) let selectionPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - selectionPanelSize.height), size: selectionPanelSize) listBottomInset = selectionPanelSize.height if let selectionPanelView = selectionPanel.view { var animateIn = false if selectionPanelView.superview == nil { animateIn = true self.addSubview(selectionPanelView) } selectionPanelTransition.setFrame(view: selectionPanelView, frame: selectionPanelFrame) if animateIn { transition.animatePosition(view: selectionPanelView, from: CGPoint(x: 0.0, y: selectionPanelFrame.height), to: CGPoint(), additive: true) } } } else { if let selectionPanel = self.selectionPanel { self.selectionPanel = nil if let selectionPanelView = selectionPanel.view { transition.setPosition(view: selectionPanelView, position: CGPoint(x: selectionPanelView.center.x, y: availableSize.height + selectionPanelView.bounds.height * 0.5), completion: { [weak selectionPanelView] _ in selectionPanelView?.removeFromSuperview() }) } } } 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 } self.updateNavigationScrolling(navigationHeight: navigationHeight, transition: .immediate) } if let selectionPanelView = self.selectionPanel?.view { self.insertSubview(contentListNode.view, belowSubview: selectionPanelView) } else if let navigationBarComponentView = self.navigationBarView.view { self.insertSubview(contentListNode.view, belowSubview: navigationBarComponentView) } else { self.addSubview(contentListNode.view) } } transition.setFrame(view: contentListNode.view, frame: CGRect(origin: CGPoint(), size: availableSize)) contentListNode.update(size: availableSize, insets: UIEdgeInsets(top: navigationHeight, left: environment.safeInsets.left, bottom: listBottomInset, right: environment.safeInsets.right), transition: transition) var entries: [ContentEntry] = [] if let shortcutMessageList = self.shortcutMessageList, let accountPeer = self.accountPeer { if self.searchQuery.isEmpty { entries.append(.add) } for item in shortcutMessageList.items { if !self.searchQuery.isEmpty { var matches = false inner: for nameComponent in item.shortcut.lowercased().components(separatedBy: self.searchQueryComponentSeparationCharacterSet) { if nameComponent.lowercased().hasPrefix(self.searchQuery) { matches = true break inner } } if !matches { continue } } var isItemSelected = false if let itemId = item.id { isItemSelected = self.selectedIds.contains(itemId) } entries.append(.item(item: item, accountPeer: accountPeer, sortIndex: entries.count, isEditing: self.isEditing, isSelected: isItemSelected)) } } contentListNode.setEntries(entries: entries, animated: !transition.animation.isImmediate) 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 shortcutMessageList = self.shortcutMessageList, !shortcutMessageList.items.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 QuickReplySetupScreen: ViewControllerComponentContainer, AttachmentContainable { public final class InitialData: QuickReplySetupScreenInitialData { let accountPeer: EnginePeer? let shortcutMessageList: ShortcutMessageList init( accountPeer: EnginePeer?, shortcutMessageList: ShortcutMessageList ) { self.accountPeer = accountPeer self.shortcutMessageList = shortcutMessageList } } public enum Mode { case manage case select(completion: (Int32) -> Void) } private let context: AccountContext public var requestAttachmentMenuExpansion: () -> Void = { } public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in } public var parentController: () -> ViewController? = { return nil } public var updateTabBarAlpha: (CGFloat, ContainedViewLayoutTransition) -> Void = { _, _ in } public var updateTabBarVisibility: (Bool, ContainedViewLayoutTransition) -> Void = { _, _ in } public var cancelPanGesture: () -> Void = { } public var isContainerPanning: () -> Bool = { return false } public var isContainerExpanded: () -> Bool = { return false } public var mediaPickerContext: AttachmentMediaPickerContext? public init(context: AccountContext, initialData: InitialData, mode: Mode) { self.context = context super.init(context: context, component: QuickReplySetupScreenComponent( context: context, initialData: initialData, mode: mode ), navigationBarAppearance: .none, theme: .default, updatedPresentationData: nil) self.scrollToTop = { [weak self] in guard let self, let componentView = self.node.hostView.componentView as? QuickReplySetupScreenComponent.View else { return } componentView.scrollToTop() } self.attemptNavigation = { [weak self] complete in guard let self, let componentView = self.node.hostView.componentView as? QuickReplySetupScreenComponent.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 { return combineLatest( context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId) ), context.engine.accountData.shortcutMessageList(onlyRemote: false) |> take(1) ) |> map { accountPeer, shortcutMessageList -> QuickReplySetupScreenInitialData in return InitialData( accountPeer: accountPeer, shortcutMessageList: shortcutMessageList ) } } public func isContainerPanningUpdated(_ panning: Bool) { } public func resetForReuse() { } public func prepareForReuse() { } public func requestDismiss(completion: @escaping () -> Void) { completion() } public func shouldDismissImmediately() -> Bool { return true } }