import Foundation import UIKit import Display import AsyncDisplayKit import Postbox import TelegramCore import SwiftSignalKit import AccountContext import TelegramPresentationData import TelegramUIPreferences import MergeLists import Photos import LegacyComponents import AttachmentUI import ItemListUI import MediaAssetsContext private enum MediaGroupsEntry: Comparable, Identifiable { enum StableId: Hashable { case albumsHeader case albums case smartAlbumsHeader case smartAlbum(String) } case albumsHeader(PresentationTheme, String) case albums(PresentationTheme, [PHAssetCollection]) case smartAlbumsHeader(PresentationTheme, String) case smartAlbum(PresentationTheme, Int, PHAssetCollection, Int) var stableId: StableId { switch self { case .albumsHeader: return .albumsHeader case .albums: return .albums case .smartAlbumsHeader: return .smartAlbumsHeader case let .smartAlbum(_, _, album, _): return .smartAlbum(album.localIdentifier) } } static func ==(lhs: MediaGroupsEntry, rhs: MediaGroupsEntry) -> Bool { switch lhs { case let .albumsHeader(lhsTheme, lhsText): if case let .albumsHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { return true } else { return false } case let .albums(lhsTheme, lhsAssetCollections): if case let .albums(rhsTheme, rhsAssetCollections) = rhs, lhsTheme === rhsTheme, lhsAssetCollections == rhsAssetCollections { return true } else { return false } case let .smartAlbumsHeader(lhsTheme, lhsText): if case let .smartAlbumsHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { return true } else { return false } case let .smartAlbum(lhsTheme, lhsIndex, lhsAssetCollection, lhsCount): if case let .smartAlbum(rhsTheme, rhsIndex, rhsAssetCollection, rhsCount) = rhs, lhsTheme === rhsTheme, lhsIndex == rhsIndex, lhsAssetCollection == rhsAssetCollection, lhsCount == rhsCount { return true } else { return false } } } private var sortId: Int { switch self { case .albumsHeader: return 0 case .albums: return 1 case .smartAlbumsHeader: return 2 case let .smartAlbum(_, index, _, _): return 3 + index } } static func <(lhs: MediaGroupsEntry, rhs: MediaGroupsEntry) -> Bool { return lhs.sortId < rhs.sortId } func item(presentationData: PresentationData, openGroup: @escaping (PHAssetCollection) -> Void) -> ListViewItem { switch self { case let .albumsHeader(_, text), let .smartAlbumsHeader(_, text): return MediaGroupsHeaderItem(presentationData: ItemListPresentationData(presentationData), title: text) case let .albums(_, collections): return MediaGroupsAlbumGridItem(presentationData: presentationData, collections: collections, action: { collection in openGroup(collection) }) case let .smartAlbum(_, _, collection, count): let title = collection.localizedTitle ?? "" let count = presentationStringsFormattedNumber(Int32(count), presentationData.dateTimeFormat.groupingSeparator) var icon: MediaGroupsAlbumItem.Icon? switch collection.assetCollectionSubtype { case .smartAlbumAnimated: icon = .animated case .smartAlbumBursts: icon = .bursts case .smartAlbumDepthEffect: icon = .depthEffect case .smartAlbumLivePhotos: icon = .livePhotos case .smartAlbumPanoramas: icon = .panoramas case .smartAlbumScreenshots: icon = .screenshots case .smartAlbumSelfPortraits: icon = .selfPortraits case .smartAlbumSlomoVideos: icon = .slomoVideos case .smartAlbumTimelapses: icon = .timelapses case .smartAlbumVideos: icon = .videos case .smartAlbumAllHidden: icon = .hidden default: icon = nil } return MediaGroupsAlbumItem(presentationData: ItemListPresentationData(presentationData), title: title, count: count, icon: icon, action: { openGroup(collection) }) } } } private struct MediaGroupsTransition { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] } private func preparedTransition(from fromEntries: [MediaGroupsEntry], to toEntries: [MediaGroupsEntry], presentationData: PresentationData, openGroup: @escaping (PHAssetCollection) -> Void) -> MediaGroupsTransition { 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(presentationData: presentationData, openGroup: openGroup), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, openGroup: openGroup), directionHint: nil) } return MediaGroupsTransition(deletions: deletions, insertions: insertions, updates: updates) } public final class MediaGroupsScreen: ViewController, AttachmentContainable { 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 isMinimized: Bool = false public var mediaPickerContext: AttachmentMediaPickerContext? { return nil } private let context: AccountContext private var presentationData: PresentationData private var presentationDataDisposable: Disposable? private let mediaAssetsContext: MediaAssetsContext private let embedded: Bool private let openGroup: (PHAssetCollection) -> Void private class Node: ViewControllerTracingNode { struct State { let albums: PHFetchResult let smartAlbums: PHFetchResult } private weak var controller: MediaGroupsScreen? private var presentationData: PresentationData private let containerNode: ASDisplayNode private let backgroundNode: NavigationBackgroundNode private let listNode: ListView private var nextStableId: Int = 1 private var currentEntries: [MediaGroupsEntry] = [] private var enqueuedTransactions: [MediaGroupsTransition] = [] private var state: State? private var itemsDisposable: Disposable? private var didSetReady = false private let _ready = Promise() var ready: Promise { return self._ready } private var validLayout: (ContainerViewLayout, CGFloat)? init(controller: MediaGroupsScreen) { self.controller = controller self.presentationData = controller.presentationData self.containerNode = ASDisplayNode() self.backgroundNode = NavigationBackgroundNode(color: self.presentationData.theme.rootController.tabBar.backgroundColor) self.listNode = ListView() super.init() if !controller.embedded { self.addSubnode(self.backgroundNode) } else { self.containerNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor } self.addSubnode(self.containerNode) self.containerNode.addSubnode(self.listNode) let updatedState = combineLatest(queue: Queue.mainQueue(), controller.mediaAssetsContext.fetchAssetsCollections(.album), controller.mediaAssetsContext.fetchAssetsCollections(.smartAlbum)) self.itemsDisposable = (updatedState |> deliverOnMainQueue).start(next: { [weak self] albums, smartAlbums in guard let strongSelf = self else { return } strongSelf.updateState(State(albums: albums, smartAlbums: smartAlbums)) }) self.listNode.beganInteractiveDragging = { [weak self] _ in self?.view.window?.endEditing(true) } self.listNode.visibleContentOffsetChanged = { [weak self] _ in self?.updateNavigation(transition: .immediate) } } deinit { self.itemsDisposable?.dispose() } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let result = super.hitTest(point, with: event) if result == self.view { return nil } return result } private func updateState(_ state: State) { self.state = state var entries: [MediaGroupsEntry] = [] var albums: [PHAssetCollection] = [] entries.append(.albumsHeader(self.presentationData.theme, self.presentationData.strings.Attachment_MyAlbums)) state.smartAlbums.enumerateObjects { collection, _, _ in if [.smartAlbumUserLibrary, .smartAlbumFavorites].contains(collection.assetCollectionSubtype) { albums.append(collection) } } state.albums.enumerateObjects(options: [.reverse]) { collection, _, _ in albums.append(collection) } entries.append(.albums(self.presentationData.theme, albums)) let smartAlbumsHeaderIndex = entries.count var addedSmartAlbum = false state.smartAlbums.enumerateObjects { collection, index, _ in var supportedAlbums: [PHAssetCollectionSubtype] = [ .smartAlbumBursts, .smartAlbumPanoramas, .smartAlbumScreenshots, .smartAlbumSelfPortraits, .smartAlbumSlomoVideos, .smartAlbumTimelapses, .smartAlbumVideos, .smartAlbumAllHidden ] if #available(iOS 11, *) { supportedAlbums.append(.smartAlbumAnimated) supportedAlbums.append(.smartAlbumDepthEffect) supportedAlbums.append(.smartAlbumLivePhotos) } if supportedAlbums.contains(collection.assetCollectionSubtype) { let result = PHAsset.fetchAssets(in: collection, options: nil) if result.count > 0 { addedSmartAlbum = true entries.append(.smartAlbum(self.presentationData.theme, index, collection, result.count)) } } } if addedSmartAlbum { entries.insert(.smartAlbumsHeader(self.presentationData.theme, self.presentationData.strings.Attachment_MediaTypes), at: smartAlbumsHeaderIndex) } let previousEntries = self.currentEntries self.currentEntries = entries let transaction = preparedTransition(from: previousEntries, to: entries, presentationData: self.presentationData, openGroup: { [weak self] collection in self?.view.window?.endEditing(true) self?.controller?.openGroup(collection) }) self.enqueueTransaction(transaction) } func updatePresentationData(_ presentationData: PresentationData) { self.presentationData = presentationData if self.controller?.embedded == true { self.containerNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor } self.backgroundNode.updateColor(color: self.presentationData.theme.rootController.tabBar.backgroundColor, transition: .immediate) } private func enqueueTransaction(_ transaction: MediaGroupsTransition) { self.enqueuedTransactions.append(transaction) if let _ = self.validLayout { while !self.enqueuedTransactions.isEmpty { self.dequeueTransaction() } } } private func dequeueTransaction() { if self.enqueuedTransactions.isEmpty { return } let transaction = self.enqueuedTransactions.removeFirst() let options = ListViewDeleteAndInsertOptions() self.listNode.transaction(deleteIndices: transaction.deletions, insertIndicesAndItems: transaction.insertions, updateIndicesAndItems: transaction.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in if let strongSelf = self { if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf._ready.set(.single(true)) } } }) } 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 }) } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { guard let controller = self.controller else { return } let firstTime = self.validLayout == nil self.validLayout = (layout, navigationBarHeight) let topInset: CGFloat = controller.embedded ? 12.0 : 0.0 let containerFrame = CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight + topInset), size: CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight - topInset)) transition.updateFrame(node: self.containerNode, frame: containerFrame) transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: .zero, size: layout.size)) self.backgroundNode.update(size: layout.size, transition: transition) let size = layout.size let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: size, insets: UIEdgeInsets(top: 0.0, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom + 56.0, right: layout.safeInsets.right), headerInsets: UIEdgeInsets(), scrollIndicatorInsets: UIEdgeInsets(), duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(), size: size)) if firstTime { self.dequeueTransaction() } } private var previousContentOffset: GridNodeVisibleContentOffset? func updateNavigation(delayDisappear: Bool = false, transition: ContainedViewLayoutTransition) { var previousContentOffsetValue: CGFloat? if let previousContentOffset = self.previousContentOffset, case let .known(value) = previousContentOffset { previousContentOffsetValue = value } let offset = self.listNode.visibleContentOffset() switch offset { case let .known(value): let transition: ContainedViewLayoutTransition if let previousContentOffsetValue = previousContentOffsetValue, value <= 0.0, previousContentOffsetValue > 2.0 { transition = .animated(duration: 0.2, curve: .easeInOut) } else { transition = .immediate } self.controller?.navigationBar?.updateBackgroundAlpha(min(2.0, value) / 2.0, transition: transition) case .unknown, .none: self.controller?.navigationBar?.updateBackgroundAlpha(1.0, transition: .immediate) } } } private var validLayout: ContainerViewLayout? private var controllerNode: Node { return self.displayNode as! Node } private let _ready = Promise() override public var ready: Promise { return self._ready } init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, mediaAssetsContext: MediaAssetsContext, embedded: Bool = false, openGroup: @escaping (PHAssetCollection) -> Void) { self.context = context self.presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } self.mediaAssetsContext = mediaAssetsContext self.embedded = embedded self.openGroup = openGroup super.init(navigationBarPresentationData: !embedded ? NavigationBarPresentationData(presentationData: presentationData) : nil) self.statusBar.statusBarStyle = .Ignore self.presentationDataDisposable = ((updatedPresentationData?.signal ?? context.sharedContext.presentationData) |> deliverOnMainQueue).start(next: { [weak self] presentationData in if let strongSelf = self { let previousTheme = strongSelf.presentationData.theme let previousStrings = strongSelf.presentationData.strings strongSelf.presentationData = presentationData if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings { strongSelf.controllerNode.updatePresentationData(presentationData) } } }) self.scrollToTop = { [weak self] in if let strongSelf = self { strongSelf.controllerNode.scrollToTop() } } if !embedded { self.title = "Albums" self.navigationItem.leftBarButtonItem = UIBarButtonItem(backButtonAppearanceWithTitle: self.presentationData.strings.Common_Back, target: self, action: #selector(self.backPressed)) } } required init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.presentationDataDisposable?.dispose() } override public func loadDisplayNode() { self.displayNode = Node(controller: self) self._ready.set(self.controllerNode.ready.get()) super.displayNodeDidLoad() } @objc private func backPressed() { if let _ = self.navigationController { self.dismiss() } else { self.updateNavigationStack { current in var mediaPickerContext: AttachmentMediaPickerContext? if let first = current.first as? MediaPickerScreenImpl { mediaPickerContext = first.webSearchController?.mediaPickerContext ?? first.mediaPickerContext } return (current.filter { $0 !== self }, mediaPickerContext) } } } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) self.validLayout = layout self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) } }