import Foundation import UIKit import AsyncDisplayKit import Display import Postbox import TelegramCore import SwiftSignalKit import TelegramPresentationData import TelegramUIPreferences import ItemListUI import AccountContext import TelegramNotices private struct CallListNodeListViewTransition { let callListView: CallListNodeView let deleteItems: [ListViewDeleteItem] let insertItems: [ListViewInsertItem] let updateItems: [ListViewUpdateItem] let options: ListViewDeleteAndInsertOptions let scrollToItem: ListViewScrollToItem? let stationaryItemRange: (Int, Int)? } private extension CallListViewEntry { var lowestIndex: MessageIndex { switch self { case let .hole(index): return index case let .message(_, messages): var lowest = messages[0].index for i in 1 ..< messages.count { let index = messages[i].index if index < lowest { lowest = index } } return lowest } } var highestIndex: MessageIndex { switch self { case let .hole(index): return index case let .message(_, messages): var highest = messages[0].index for i in 1 ..< messages.count { let index = messages[i].index if index > highest { highest = index } } return highest } } } final class CallListNodeInteraction { let setMessageIdWithRevealedOptions: (MessageId?, MessageId?) -> Void let call: (PeerId) -> Void let openInfo: (PeerId, [Message]) -> Void let delete: ([MessageId]) -> Void let updateShowCallsTab: (Bool) -> Void init(setMessageIdWithRevealedOptions: @escaping (MessageId?, MessageId?) -> Void, call: @escaping (PeerId) -> Void, openInfo: @escaping (PeerId, [Message]) -> Void, delete: @escaping ([MessageId]) -> Void, updateShowCallsTab: @escaping (Bool) -> Void) { self.setMessageIdWithRevealedOptions = setMessageIdWithRevealedOptions self.call = call self.openInfo = openInfo self.delete = delete self.updateShowCallsTab = updateShowCallsTab } } struct CallListNodeState: Equatable { let theme: PresentationTheme let strings: PresentationStrings let dateTimeFormat: PresentationDateTimeFormat let disableAnimations: Bool let editing: Bool let messageIdWithRevealedOptions: MessageId? func withUpdatedPresentationData(theme: PresentationTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, disableAnimations: Bool) -> CallListNodeState { return CallListNodeState(theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, disableAnimations: disableAnimations, editing: self.editing, messageIdWithRevealedOptions: self.messageIdWithRevealedOptions) } func withUpdatedEditing(_ editing: Bool) -> CallListNodeState { return CallListNodeState(theme: self.theme, strings: self.strings, dateTimeFormat: self.dateTimeFormat, disableAnimations: self.disableAnimations, editing: editing, messageIdWithRevealedOptions: self.messageIdWithRevealedOptions) } func withUpdatedMessageIdWithRevealedOptions(_ messageIdWithRevealedOptions: MessageId?) -> CallListNodeState { return CallListNodeState(theme: self.theme, strings: self.strings, dateTimeFormat: self.dateTimeFormat, disableAnimations: self.disableAnimations, editing: self.editing, messageIdWithRevealedOptions: messageIdWithRevealedOptions) } static func ==(lhs: CallListNodeState, rhs: CallListNodeState) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.strings !== rhs.strings { return false } if lhs.dateTimeFormat != rhs.dateTimeFormat { return false } if lhs.editing != rhs.editing { return false } if lhs.messageIdWithRevealedOptions != rhs.messageIdWithRevealedOptions { return false } return true } } private func mappedInsertEntries(account: Account, showSettings: Bool, nodeInteraction: CallListNodeInteraction, entries: [CallListNodeViewTransitionInsertEntry]) -> [ListViewInsertItem] { return entries.map { entry -> ListViewInsertItem in switch entry.entry { case let .displayTab(theme, text, value): return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ItemListSwitchItem(theme: theme, title: text, value: value, enabled: true, sectionId: 0, style: .blocks, updated: { value in nodeInteraction.updateShowCallsTab(value) }), directionHint: entry.directionHint) case let .displayTabInfo(theme, text): return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ItemListTextItem(theme: theme, text: .plain(text), sectionId: 0), directionHint: entry.directionHint) case let .messageEntry(topMessage, messages, theme, strings, dateTimeFormat, editing, hasActiveRevealControls): return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: CallListCallItem(theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, account: account, style: showSettings ? .blocks : .plain, topMessage: topMessage, messages: messages, editing: editing, revealed: hasActiveRevealControls, interaction: nodeInteraction), directionHint: entry.directionHint) case let .holeEntry(_, theme): return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: CallListHoleItem(theme: theme), directionHint: entry.directionHint) } } } private func mappedUpdateEntries(account: Account, showSettings: Bool, nodeInteraction: CallListNodeInteraction, entries: [CallListNodeViewTransitionUpdateEntry]) -> [ListViewUpdateItem] { return entries.map { entry -> ListViewUpdateItem in switch entry.entry { case let .displayTab(theme, text, value): return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ItemListSwitchItem(theme: theme, title: text, value: value, enabled: true, sectionId: 0, style: .blocks, updated: { value in nodeInteraction.updateShowCallsTab(value) }), directionHint: entry.directionHint) case let .displayTabInfo(theme, text): return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ItemListTextItem(theme: theme, text: .plain(text), sectionId: 0), directionHint: entry.directionHint) case let .messageEntry(topMessage, messages, theme, strings, dateTimeFormat, editing, hasActiveRevealControls): return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: CallListCallItem(theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, account: account, style: showSettings ? .blocks : .plain, topMessage: topMessage, messages: messages, editing: editing, revealed: hasActiveRevealControls, interaction: nodeInteraction), directionHint: entry.directionHint) case let .holeEntry(_, theme): return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: CallListHoleItem(theme: theme), directionHint: entry.directionHint) } } } private func mappedCallListNodeViewListTransition(account: Account, showSettings: Bool, nodeInteraction: CallListNodeInteraction, transition: CallListNodeViewTransition) -> CallListNodeListViewTransition { return CallListNodeListViewTransition(callListView: transition.callListView, deleteItems: transition.deleteItems, insertItems: mappedInsertEntries(account: account, showSettings: showSettings, nodeInteraction: nodeInteraction, entries: transition.insertEntries), updateItems: mappedUpdateEntries(account: account, showSettings: showSettings, nodeInteraction: nodeInteraction, entries: transition.updateEntries), options: transition.options, scrollToItem: transition.scrollToItem, stationaryItemRange: transition.stationaryItemRange) } private final class CallListOpaqueTransactionState { let callListView: CallListNodeView init(callListView: CallListNodeView) { self.callListView = callListView } } final class CallListControllerNode: ASDisplayNode { private let context: AccountContext private let mode: CallListControllerMode private var presentationData: PresentationData private var containerLayout: (ContainerViewLayout, CGFloat)? private let _ready = ValuePromise() private var didSetReady = false var ready: Signal { return _ready.get() } var peerSelected: ((PeerId) -> Void)? var activateSearch: (() -> Void)? var deletePeerChat: ((PeerId) -> Void)? private let viewProcessingQueue = Queue() private var callListView: CallListNodeView? private var dequeuedInitialTransitionOnLayout = false private var enqueuedTransition: (CallListNodeListViewTransition, () -> Void)? private var currentState: CallListNodeState private let statePromise: ValuePromise private var currentLocationAndType = CallListNodeLocationAndType(location: .initial(count: 50), type: .all) private let callListLocationAndType = ValuePromise() private let callListDisposable = MetaDisposable() private let listNode: ListView private let emptyTextNode: ASTextNode private let call: (PeerId) -> Void private let openInfo: (PeerId, [Message]) -> Void private let emptyStateUpdated: (Bool) -> Void private let emptyStatePromise = Promise() private let emptyStateDisposable = MetaDisposable() init(context: AccountContext, mode: CallListControllerMode, presentationData: PresentationData, call: @escaping (PeerId) -> Void, openInfo: @escaping (PeerId, [Message]) -> Void, emptyStateUpdated: @escaping (Bool) -> Void) { self.context = context self.mode = mode self.presentationData = presentationData self.call = call self.openInfo = openInfo self.emptyStateUpdated = emptyStateUpdated self.currentState = CallListNodeState(theme: presentationData.theme, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, disableAnimations: presentationData.disableAnimations, editing: false, messageIdWithRevealedOptions: nil) self.statePromise = ValuePromise(self.currentState, ignoreRepeated: true) self.listNode = ListView() self.listNode.verticalScrollIndicatorColor = self.presentationData.theme.list.scrollIndicatorColor self.emptyTextNode = ASTextNode() self.emptyTextNode.alpha = 0.0 self.emptyTextNode.isUserInteractionEnabled = false self.emptyTextNode.displaysAsynchronously = false super.init() self.setViewBlock({ return UITracingLayerView() }) self.addSubnode(self.listNode) self.addSubnode(self.emptyTextNode) switch self.mode { case .tab: self.backgroundColor = presentationData.theme.chatList.backgroundColor self.listNode.backgroundColor = presentationData.theme.chatList.backgroundColor case .navigation: self.backgroundColor = presentationData.theme.list.blocksBackgroundColor self.listNode.backgroundColor = presentationData.theme.list.blocksBackgroundColor } let nodeInteraction = CallListNodeInteraction(setMessageIdWithRevealedOptions: { [weak self] messageId, fromMessageId in if let strongSelf = self { strongSelf.updateState { state in if (messageId == nil && fromMessageId == state.messageIdWithRevealedOptions) || (messageId != nil && fromMessageId == nil) { return state.withUpdatedMessageIdWithRevealedOptions(messageId) } else { return state } } } }, call: { [weak self] peerId in self?.call(peerId) }, openInfo: { [weak self] peerId, messages in self?.openInfo(peerId, messages) }, delete: { [weak self] messageIds in if let strongSelf = self { let _ = deleteMessagesInteractively(postbox: strongSelf.context.account.postbox, messageIds: messageIds, type: .forLocalPeer).start() } }, updateShowCallsTab: { [weak self] value in if let strongSelf = self { let _ = updateCallListSettingsInteractively(accountManager: strongSelf.context.sharedContext.accountManager, { $0.withUpdatedShowTab(value) }).start() if value { let _ = ApplicationSpecificNotice.incrementCallsTabTips(accountManager: strongSelf.context.sharedContext.accountManager, count: 4).start() } } }) let viewProcessingQueue = self.viewProcessingQueue let callListViewUpdate = self.callListLocationAndType.get() |> distinctUntilChanged |> mapToSignal { locationAndType in return callListViewForLocationAndType(locationAndType: locationAndType, account: context.account) } let previousView = Atomic(value: nil) let showSettings: Bool switch mode { case .tab: showSettings = false case .navigation: showSettings = true } let showCallsTab = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.callListSettings]) |> map { sharedData -> Bool in var value = true if let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.callListSettings] as? CallListSettings { value = settings.showTab } return value } let callListNodeViewTransition = combineLatest(callListViewUpdate, self.statePromise.get(), showCallsTab) |> mapToQueue { (update, state, showCallsTab) -> Signal in let processedView = CallListNodeView(originalView: update.view, filteredEntries: callListNodeEntriesForView(update.view, state: state, showSettings: showSettings, showCallsTab: showCallsTab)) let previous = previousView.swap(processedView) let reason: CallListNodeViewTransitionReason var prepareOnMainQueue = false var previousWasEmptyOrSingleHole = false if let previous = previous { if previous.filteredEntries.count == 1 { if case .holeEntry = previous.filteredEntries[0] { previousWasEmptyOrSingleHole = true } } } else { previousWasEmptyOrSingleHole = true } if previousWasEmptyOrSingleHole { reason = .initial if previous == nil { prepareOnMainQueue = true } } else { if previous?.originalView === update.view { reason = .interactiveChanges } else { switch update.type { case .Initial: reason = .initial prepareOnMainQueue = true case .Generic: reason = .interactiveChanges case .UpdateVisible: reason = .reload case .Reload: reason = .reload case .ReloadAnimated: reason = .reloadAnimated } } } return preparedCallListNodeViewTransition(from: previous, to: processedView, reason: reason, disableAnimations: false, account: context.account, scrollPosition: update.scrollPosition) |> map({ mappedCallListNodeViewListTransition(account: context.account, showSettings: showSettings, nodeInteraction: nodeInteraction, transition: $0) }) |> runOn(prepareOnMainQueue ? Queue.mainQueue() : viewProcessingQueue) } let appliedTransition = callListNodeViewTransition |> deliverOnMainQueue |> mapToQueue { [weak self] transition -> Signal in if let strongSelf = self { return strongSelf.enqueueTransition(transition) } return .complete() } self.listNode.displayedItemRangeChanged = { [weak self] range, transactionOpaqueState in if let strongSelf = self, let range = range.loadedRange, let view = (transactionOpaqueState as? CallListOpaqueTransactionState)?.callListView.originalView { var location: CallListNodeLocation? if range.firstIndex < 5 && view.later != nil { location = .navigation(index: view.entries[view.entries.count - 1].highestIndex) } else if range.firstIndex >= 5 && range.lastIndex >= view.entries.count - 5 && view.earlier != nil { location = .navigation(index: view.entries[0].lowestIndex) } if let location = location, location != strongSelf.currentLocationAndType.location { strongSelf.currentLocationAndType = CallListNodeLocationAndType(location: location, type: strongSelf.currentLocationAndType.type) strongSelf.callListLocationAndType.set(strongSelf.currentLocationAndType) } } } self.callListDisposable.set(appliedTransition.start()) self.callListLocationAndType.set(self.currentLocationAndType) let emptySignal = self.emptyStatePromise.get() |> distinctUntilChanged let typeSignal = self.callListLocationAndType.get() |> map { locationAndType -> CallListViewType in return locationAndType.type } |> distinctUntilChanged self.emptyStateDisposable.set((combineLatest(emptySignal, typeSignal, self.statePromise.get()) |> deliverOnMainQueue).start(next: { [weak self] isEmpty, type, state in if let strongSelf = self { strongSelf.updateEmptyPlaceholder(theme: state.theme, strings: state.strings, type: type, hidden: !isEmpty) } })) } deinit { self.callListDisposable.dispose() self.emptyStateDisposable.dispose() } func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, disableAnimations: Bool) { if theme !== self.currentState.theme || strings !== self.currentState.strings || disableAnimations != self.currentState.disableAnimations { switch self.mode { case .tab: self.backgroundColor = theme.chatList.backgroundColor self.listNode.backgroundColor = theme.chatList.backgroundColor case .navigation: self.backgroundColor = theme.list.blocksBackgroundColor self.listNode.backgroundColor = theme.list.blocksBackgroundColor } self.updateEmptyPlaceholder(theme: theme, strings: strings, type: self.currentLocationAndType.type, hidden: self.emptyTextNode.isHidden) self.updateState { return $0.withUpdatedPresentationData(theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, disableAnimations: disableAnimations) } } } private let textFont = Font.regular(16.0) func updateEmptyPlaceholder(theme: PresentationTheme, strings: PresentationStrings, type: CallListViewType, hidden: Bool) { let alpha: CGFloat = hidden ? 0.0 : 1.0 let previousAlpha = self.emptyTextNode.alpha self.emptyTextNode.alpha = alpha self.emptyTextNode.layer.animateAlpha(from: previousAlpha, to: alpha, duration: 0.2) if !hidden { let type = self.currentLocationAndType.type let string: String if type == .missed { string = strings.Calls_NoMissedCallsPlacehoder } else { string = strings.Calls_NoCallsPlaceholder } let color: UIColor switch self.mode { case .tab: self.backgroundColor = theme.chatList.backgroundColor self.listNode.backgroundColor = theme.chatList.backgroundColor color = theme.list.freeTextColor case .navigation: self.backgroundColor = theme.list.blocksBackgroundColor self.listNode.backgroundColor = theme.list.blocksBackgroundColor color = theme.list.freeTextColor } self.emptyTextNode.attributedText = NSAttributedString(string: string, font: textFont, textColor: color, paragraphAlignment: .center) if let layout = self.containerLayout { self.updateLayout(layout.0, navigationBarHeight: layout.1, transition: .immediate) } } } func updateState(_ f: (CallListNodeState) -> CallListNodeState) { let state = f(self.currentState) if state != self.currentState { self.currentState = state self.statePromise.set(state) } } func updateType(_ type: CallListViewType) { if type != self.currentLocationAndType.type { if let view = self.callListView?.originalView { var index: MessageIndex if !view.entries.isEmpty { index = view.entries[view.entries.count - 1].highestIndex } else { index = MessageIndex.absoluteUpperBound() } self.currentLocationAndType = CallListNodeLocationAndType(location: .changeType(index: index), type: type) self.emptyStatePromise.set(.single(false)) self.callListLocationAndType.set(self.currentLocationAndType) } } } private func enqueueTransition(_ transition: CallListNodeListViewTransition) -> Signal { return Signal { [weak self] subscriber in if let strongSelf = self { if let _ = strongSelf.enqueuedTransition { preconditionFailure() } strongSelf.enqueuedTransition = (transition, { subscriber.putCompletion() }) if strongSelf.isNodeLoaded { strongSelf.dequeueTransition() } else { if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf._ready.set(true) } } } else { subscriber.putCompletion() } return EmptyDisposable } |> runOn(Queue.mainQueue()) } private func dequeueTransition() { if let (transition, completion) = self.enqueuedTransition { self.enqueuedTransition = nil let completion: (ListViewDisplayedItemRange) -> Void = { [weak self] visibleRange in if let strongSelf = self { strongSelf.callListView = transition.callListView let empty = countMeaningfulCallListEntries(transition.callListView.filteredEntries) == 0 strongSelf.emptyStateUpdated(empty) strongSelf.emptyStatePromise.set(.single(empty)) if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf._ready.set(true) } completion() } } self.listNode.transaction(deleteIndices: transition.deleteItems, insertIndicesAndItems: transition.insertItems, updateIndicesAndItems: transition.updateItems, options: transition.options, scrollToItem: transition.scrollToItem, stationaryItemRange: transition.stationaryItemRange, updateOpaqueState: CallListOpaqueTransactionState(callListView: transition.callListView), completion: completion) } } func scrollToLatest() { if let view = self.callListView?.originalView, view.later == nil { self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) } else { let location: CallListNodeLocation = .scroll(index: MessageIndex.absoluteUpperBound(), sourceIndex: MessageIndex.absoluteLowerBound(), scrollPosition: .top(0.0), animated: true) self.currentLocationAndType = CallListNodeLocationAndType(location: location, type: self.currentLocationAndType.type) self.callListLocationAndType.set(self.currentLocationAndType) } } func updateLayout(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { var insets = layout.insets(options: [.input]) insets.top += max(navigationBarHeight, layout.insets(options: [.statusBar]).top) insets.left += layout.safeInsets.left insets.right += layout.safeInsets.right if self.mode == .navigation { insets.top += 64.0 } let size = layout.size let contentRect = CGRect(origin: CGPoint(x: 0.0, y: insets.top), size: CGSize(width: size.width, height: size.height - insets.top - insets.bottom)) let textSize = self.emptyTextNode.measure(CGSize(width: size.width - 20.0, height: size.height)) transition.updateFrame(node: self.emptyTextNode, frame: CGRect(origin: CGPoint(x: contentRect.minX + floor((contentRect.width - textSize.width) / 2.0), y: contentRect.minY + floor((contentRect.height - textSize.height) / 2.0)), size: textSize)) } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { self.containerLayout = (layout, navigationBarHeight) var insets = layout.insets(options: [.input]) insets.top += max(navigationBarHeight, layout.insets(options: [.statusBar]).top) insets.left += layout.safeInsets.left insets.right += layout.safeInsets.right 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) updateLayout(layout, navigationBarHeight: navigationBarHeight, transition: transition) var duration: Double = 0.0 var curve: UInt = 0 switch transition { case .immediate: break case let .animated(animationDuration, animationCurve): duration = animationDuration switch animationCurve { case .easeInOut, .custom: break case .spring: curve = 7 } } let listViewCurve: ListViewAnimationCurve if curve == 7 { listViewCurve = .Spring(duration: duration) } else { listViewCurve = .Default(duration: duration) } let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: listViewCurve) self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) if !self.dequeuedInitialTransitionOnLayout { self.dequeuedInitialTransitionOnLayout = true self.dequeueTransition() } } }