import Foundation import UIKit import AsyncDisplayKit import Display import Postbox import TelegramCore import SyncCore import SwiftSignalKit import TelegramPresentationData import TelegramUIPreferences import MergeLists import AccountContext import TemporaryCachedPeerDataManager import SearchBarNode import ContactsPeerItem import SearchUI import ItemListUI import ContactListUI import ChatListSearchItemHeader private final class ChannelMembersSearchInteraction { let openPeer: (Peer, RenderedChannelParticipant?) -> Void let copyInviteLink: () -> Void init( openPeer: @escaping (Peer, RenderedChannelParticipant?) -> Void, copyInviteLink: @escaping () -> Void ) { self.openPeer = openPeer self.copyInviteLink = copyInviteLink } } private enum ChannelMembersSearchEntryId: Hashable { case copyInviteLink case peer(PeerId) } private enum ChannelMembersSearchEntry: Comparable, Identifiable { case copyInviteLink case peer(Int, RenderedChannelParticipant, ContactsPeerItemEditing, String?, Bool) var stableId: ChannelMembersSearchEntryId { switch self { case .copyInviteLink: return .copyInviteLink case let .peer(_, participant, _, _, _): return .peer(participant.peer.id) } } static func ==(lhs: ChannelMembersSearchEntry, rhs: ChannelMembersSearchEntry) -> Bool { switch lhs { case .copyInviteLink: if case .copyInviteLink = rhs { return true } else { return false } case let .peer(lhsIndex, lhsParticipant, lhsEditing, lhsLabel, lhsEnabled): if case .peer(lhsIndex, lhsParticipant, lhsEditing, lhsLabel, lhsEnabled) = rhs { return true } else { return false } } } static func <(lhs: ChannelMembersSearchEntry, rhs: ChannelMembersSearchEntry) -> Bool { switch lhs { case .copyInviteLink: if case .copyInviteLink = rhs { return false } else { return true } case let .peer(lhsIndex, _, _, _, _): if case .copyInviteLink = rhs { return false } else if case let .peer(rhsIndex, _, _, _, _) = rhs { return lhsIndex < rhsIndex } else { return false } } } func item(context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, interaction: ChannelMembersSearchInteraction) -> ListViewItem { switch self { case .copyInviteLink: let icon: ContactListActionItemIcon if let iconImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: presentationData.theme.list.itemAccentColor) { icon = .generic(iconImage) } else { icon = .none } return ContactListActionItem(presentationData: ItemListPresentationData(presentationData), title: presentationData.strings.VoiceChat_CopyInviteLink, icon: icon, clearHighlightAutomatically: true, header: nil, action: { interaction.copyInviteLink() }) case let .peer(_, participant, editing, label, enabled): let status: ContactsPeerItemStatus if let label = label { status = .custom(string: label, multiline: false) } else { status = .none } return ContactsPeerItem(presentationData: ItemListPresentationData(presentationData), sortOrder: nameSortOrder, displayOrder: nameDisplayOrder, context: context, peerMode: .peer, peer: .peer(peer: participant.peer, chatPeer: nil), status: status, enabled: enabled, selection: .none, editing: editing, index: nil, header: ChatListSearchItemHeader(type: .members, theme: presentationData.theme, strings: presentationData.strings), action: { _ in interaction.openPeer(participant.peer, participant) }) } } } private struct ChannelMembersSearchTransition { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] let initial: Bool } private func preparedTransition(from fromEntries: [ChannelMembersSearchEntry]?, to toEntries: [ChannelMembersSearchEntry], context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, interaction: ChannelMembersSearchInteraction) -> ChannelMembersSearchTransition { 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, interaction: interaction), 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, interaction: interaction), directionHint: nil) } return ChannelMembersSearchTransition(deletions: deletions, insertions: insertions, updates: updates, initial: fromEntries == nil) } class ChannelMembersSearchControllerNode: ASDisplayNode { private let context: AccountContext private let peerId: PeerId private let mode: ChannelMembersSearchControllerMode private let filters: [ChannelMembersSearchFilter] let listNode: ListView var navigationBar: NavigationBar? private var enqueuedTransitions: [ChannelMembersSearchTransition] = [] private(set) var searchDisplayController: SearchDisplayController? private var containerLayout: (ContainerViewLayout, CGFloat)? var requestActivateSearch: (() -> Void)? var requestDeactivateSearch: (() -> Void)? var requestOpenPeerFromSearch: ((Peer, RenderedChannelParticipant?) -> Void)? var requestCopyInviteLink: (() -> Void)? var pushController: ((ViewController) -> Void)? private let forceTheme: PresentationTheme? var presentationData: PresentationData private var disposable: Disposable? private var listControl: PeerChannelMemberCategoryControl? init(context: AccountContext, presentationData: PresentationData, forceTheme: PresentationTheme?, peerId: PeerId, mode: ChannelMembersSearchControllerMode, filters: [ChannelMembersSearchFilter]) { self.context = context self.listNode = ListView() self.peerId = peerId self.mode = mode self.filters = filters self.presentationData = presentationData self.forceTheme = forceTheme if let forceTheme = forceTheme { self.presentationData = self.presentationData.withUpdated(theme: forceTheme) } super.init() self.setViewBlock({ return UITracingLayerView() }) self.backgroundColor = self.presentationData.theme.chatList.backgroundColor self.addSubnode(self.listNode) let interaction = ChannelMembersSearchInteraction( openPeer: { [weak self] peer, participant in self?.requestOpenPeerFromSearch?(peer, participant) self?.listNode.clearHighlightAnimated(true) }, copyInviteLink: { [weak self] in self?.requestCopyInviteLink?() self?.listNode.clearHighlightAnimated(true) } ) let previousEntries = Atomic<[ChannelMembersSearchEntry]?>(value: nil) let disposableAndLoadMoreControl: (Disposable, PeerChannelMemberCategoryControl?) if peerId.namespace == Namespaces.Peer.CloudGroup { let disposable = (context.account.postbox.peerView(id: peerId) |> deliverOnMainQueue).start(next: { [weak self] peerView in guard let strongSelf = self else { return } guard let cachedData = peerView.cachedData as? CachedGroupData, let participants = cachedData.participants else { return } var creatorPeer: Peer? for participant in participants.participants { if let peer = peerView.peers[participant.peerId] { switch participant { case .creator: creatorPeer = peer default: break } } } guard let creator = creatorPeer else { return } var entries: [ChannelMembersSearchEntry] = [] var index = 0 for participant in participants.participants { guard let peer = peerView.peers[participant.peerId] else { continue } if peer.isDeleted { continue } var label: String? var enabled = true switch mode { case .ban: if peer.id == context.account.peerId { continue } for filter in filters { switch filter { case let .exclude(ids): if ids.contains(peer.id) { continue } case let .disable(ids): if ids.contains(peer.id) { enabled = false } case .excludeNonMembers: break case .excludeBots: if let user = peer as? TelegramUser, user.botInfo != nil { continue } } } case .promote: if peer.id == context.account.peerId { continue } for filter in filters { switch filter { case let .exclude(ids): if ids.contains(peer.id) { continue } case let .disable(ids): if ids.contains(peer.id) { enabled = false } case .excludeNonMembers: break case .excludeBots: if let user = peer as? TelegramUser, user.botInfo != nil { continue } } } if case .creator = participant { label = strongSelf.presentationData.strings.Channel_Management_LabelOwner enabled = false } case .inviteToCall: if peer.id == context.account.peerId { continue } for filter in filters { switch filter { case let .exclude(ids): if ids.contains(peer.id) { continue } case let .disable(ids): if ids.contains(peer.id) { enabled = false } case .excludeNonMembers: break case .excludeBots: if let user = peer as? TelegramUser, user.botInfo != nil { continue } } } } let renderedParticipant: RenderedChannelParticipant switch participant { case .creator: renderedParticipant = RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: nil), peer: peer) case .admin: var peers: [PeerId: Peer] = [:] peers[creator.id] = creator peers[peer.id] = peer renderedParticipant = RenderedChannelParticipant(participant: .member(id: peer.id, invitedAt: 0, adminInfo: ChannelParticipantAdminInfo(rights: TelegramChatAdminRights(flags: .groupSpecific), promotedBy: creator.id, canBeEditedByAccountPeer: creator.id == context.account.peerId), banInfo: nil, rank: nil), peer: peer, peers: peers) case .member: var peers: [PeerId: Peer] = [:] peers[peer.id] = peer renderedParticipant = RenderedChannelParticipant(participant: .member(id: peer.id, invitedAt: 0, adminInfo: nil, banInfo: nil, rank: nil), peer: peer, peers: peers) } entries.append(.peer(index, renderedParticipant, ContactsPeerItemEditing(editable: false, editing: false, revealed: false), label, enabled)) index += 1 } let previous = previousEntries.swap(entries) strongSelf.enqueueTransition(preparedTransition(from: previous, to: entries, context: context, presentationData: strongSelf.presentationData, nameSortOrder: strongSelf.presentationData.nameSortOrder, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder, interaction: interaction)) }) disposableAndLoadMoreControl = (disposable, nil) } else { disposableAndLoadMoreControl = context.peerChannelMemberCategoriesContextsManager.recent(postbox: context.account.postbox, network: context.account.network, accountPeerId: context.account.peerId, peerId: peerId, updated: { [weak self] state in guard let strongSelf = self else { return } var entries: [ChannelMembersSearchEntry] = [] if case .inviteToCall = mode, !filters.contains(where: { filter in if case .excludeNonMembers = filter { return true } else { return false } }) { entries.append(.copyInviteLink) } var index = 0 participantsLoop: for participant in state.list { if participant.peer.isDeleted { continue participantsLoop } var label: String? var enabled = true switch mode { case .ban: if participant.peer.id == context.account.peerId { continue participantsLoop } for filter in filters { switch filter { case let .exclude(ids): if ids.contains(participant.peer.id) { continue participantsLoop } case let .disable(ids): if ids.contains(participant.peer.id) { enabled = false } case .excludeNonMembers: break case .excludeBots: if let user = participant.peer as? TelegramUser, user.botInfo != nil { continue participantsLoop } } } case .promote: if participant.peer.id == context.account.peerId { continue } for filter in filters { switch filter { case let .exclude(ids): if ids.contains(participant.peer.id) { continue participantsLoop } case let .disable(ids): if ids.contains(participant.peer.id) { enabled = false } case .excludeNonMembers: break case .excludeBots: if let user = participant.peer as? TelegramUser, user.botInfo != nil { continue participantsLoop } } } if case .creator = participant.participant { label = strongSelf.presentationData.strings.Channel_Management_LabelOwner enabled = false } case .inviteToCall: if participant.peer.id == context.account.peerId { continue } for filter in filters { switch filter { case let .exclude(ids): if ids.contains(participant.peer.id) { continue participantsLoop } case let .disable(ids): if ids.contains(participant.peer.id) { enabled = false } case .excludeNonMembers: break case .excludeBots: if let user = participant.peer as? TelegramUser, user.botInfo != nil { continue participantsLoop } } } } entries.append(.peer(index, participant, ContactsPeerItemEditing(editable: false, editing: false, revealed: false), label, enabled)) index += 1 } let previous = previousEntries.swap(entries) strongSelf.enqueueTransition(preparedTransition(from: previous, to: entries, context: context, presentationData: strongSelf.presentationData, nameSortOrder: strongSelf.presentationData.nameSortOrder, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder, interaction: interaction)) }) } self.disposable = disposableAndLoadMoreControl.0 self.listControl = disposableAndLoadMoreControl.1 if peerId.namespace == Namespaces.Peer.CloudChannel { self.listNode.visibleBottomContentOffsetChanged = { offset in if case let .known(value) = offset, value < 40.0 { context.peerChannelMemberCategoriesContextsManager.loadMore(peerId: peerId, control: disposableAndLoadMoreControl.1) } } } self.listNode.beganInteractiveDragging = { [weak self] in self?.view.endEditing(true) } } deinit { self.disposable?.dispose() } func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData if let forceTheme = forceTheme { self.presentationData = self.presentationData.withUpdated(theme: forceTheme) } self.searchDisplayController?.updatePresentationData(self.presentationData) } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { let hadValidLayout = self.containerLayout != nil self.containerLayout = (layout, navigationBarHeight) var insets = layout.insets(options: [.input]) insets.top += navigationBarHeight 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, 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 searchDisplayController = self.searchDisplayController { searchDisplayController.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) } if !hadValidLayout { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } } func activateSearch(placeholderNode: SearchBarPlaceholderNode) { guard let (containerLayout, navigationBarHeight) = self.containerLayout, let navigationBar = self.navigationBar, self.searchDisplayController == nil else { return } self.searchDisplayController = SearchDisplayController(presentationData: self.presentationData, contentNode: ChannelMembersSearchContainerNode(context: self.context, forceTheme: self.forceTheme, peerId: self.peerId, mode: .banAndPromoteActions, filters: self.filters, searchContext: nil, openPeer: { [weak self] peer, participant in self?.requestOpenPeerFromSearch?(peer, participant) }, updateActivity: { value in }, pushController: { [weak self] c in self?.pushController?(c) }), 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, animated: Bool) { if let searchDisplayController = self.searchDisplayController { searchDisplayController.deactivate(placeholder: placeholderNode) self.searchDisplayController = nil } } func animateIn() { self.layer.animatePosition(from: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), to: self.layer.position, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring) } func animateOut(completion: (() -> Void)? = nil) { self.layer.animatePosition(from: self.layer.position, to: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), duration: 0.2, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { _ in completion?() }) } private func enqueueTransition(_ transition: ChannelMembersSearchTransition) { enqueuedTransitions.append(transition) if self.containerLayout != nil { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } } private func dequeueTransition() { if let transition = self.enqueuedTransitions.first { self.enqueuedTransitions.remove(at: 0) let options = ListViewDeleteAndInsertOptions() if transition.initial { //options.insert(.Synchronous) //options.insert(.LowLatency) } else { //options.insert(.AnimateTopItemPosition) //options.insert(.AnimateCrossfade) } self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { _ in }) } } 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 }) } }