mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-12-24 07:05:35 +00:00
Various improvements
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import SwiftSignalKit
|
||||
import Postbox
|
||||
import TelegramCore
|
||||
import LegacyComponents
|
||||
import TelegramPresentationData
|
||||
import TelegramUIPreferences
|
||||
import ItemListUI
|
||||
import PresentationDataUtils
|
||||
import AccountContext
|
||||
import ItemListPeerActionItem
|
||||
import AttachmentUI
|
||||
import TelegramStringFormatting
|
||||
import ListMessageItem
|
||||
import ComponentFlow
|
||||
import GlassBarButtonComponent
|
||||
import BundleIconComponent
|
||||
import EdgeEffect
|
||||
|
||||
private final class AttachmentFileControllerArguments {
|
||||
let context: AccountContext
|
||||
let openGallery: () -> Void
|
||||
let openFiles: () -> Void
|
||||
let send: (Message) -> Void
|
||||
|
||||
init(context: AccountContext, openGallery: @escaping () -> Void, openFiles: @escaping () -> Void, send: @escaping (Message) -> Void) {
|
||||
self.context = context
|
||||
self.openGallery = openGallery
|
||||
self.openFiles = openFiles
|
||||
self.send = send
|
||||
}
|
||||
}
|
||||
|
||||
private enum AttachmentFileSection: Int32 {
|
||||
case select
|
||||
case recent
|
||||
}
|
||||
|
||||
private func areMessagesEqual(_ lhsMessage: Message?, _ rhsMessage: Message?) -> Bool {
|
||||
guard let lhsMessage = lhsMessage, let rhsMessage = rhsMessage else {
|
||||
return lhsMessage == nil && rhsMessage == nil
|
||||
}
|
||||
if lhsMessage.stableVersion != rhsMessage.stableVersion {
|
||||
return false
|
||||
}
|
||||
if lhsMessage.id != rhsMessage.id || lhsMessage.flags != rhsMessage.flags {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private enum AttachmentFileEntry: ItemListNodeEntry {
|
||||
case selectFromGallery(PresentationTheme, String)
|
||||
case selectFromFiles(PresentationTheme, String)
|
||||
|
||||
case recentHeader(PresentationTheme, String)
|
||||
case file(Int32, PresentationTheme, Message?)
|
||||
|
||||
var section: ItemListSectionId {
|
||||
switch self {
|
||||
case .selectFromGallery, .selectFromFiles:
|
||||
return AttachmentFileSection.select.rawValue
|
||||
case .recentHeader, .file:
|
||||
return AttachmentFileSection.recent.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
var stableId: Int32 {
|
||||
switch self {
|
||||
case .selectFromGallery:
|
||||
return 0
|
||||
case .selectFromFiles:
|
||||
return 1
|
||||
case .recentHeader:
|
||||
return 2
|
||||
case let .file(index, _, _):
|
||||
return 3 + index
|
||||
}
|
||||
}
|
||||
|
||||
static func ==(lhs: AttachmentFileEntry, rhs: AttachmentFileEntry) -> Bool {
|
||||
switch lhs {
|
||||
case let .selectFromGallery(lhsTheme, lhsText):
|
||||
if case let .selectFromGallery(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case let .selectFromFiles(lhsTheme, lhsText):
|
||||
if case let .selectFromFiles(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case let .recentHeader(lhsTheme, lhsText):
|
||||
if case let .recentHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case let .file(lhsIndex, lhsTheme, lhsMessage):
|
||||
if case let .file(rhsIndex, rhsTheme, rhsMessage) = rhs, lhsIndex == rhsIndex, lhsTheme === rhsTheme, areMessagesEqual(lhsMessage, rhsMessage) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func <(lhs: AttachmentFileEntry, rhs: AttachmentFileEntry) -> Bool {
|
||||
return lhs.stableId < rhs.stableId
|
||||
}
|
||||
|
||||
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
|
||||
let arguments = arguments as! AttachmentFileControllerArguments
|
||||
switch self {
|
||||
case let .selectFromGallery(_, text):
|
||||
return ItemListPeerActionItem(presentationData: presentationData, systemStyle: .glass, icon: PresentationResourcesItemList.imageIcon(presentationData.theme), title: text, alwaysPlain: false, sectionId: self.section, height: .generic, editing: false, action: {
|
||||
arguments.openGallery()
|
||||
})
|
||||
case let .selectFromFiles(_, text):
|
||||
return ItemListPeerActionItem(presentationData: presentationData, systemStyle: .glass, icon: PresentationResourcesItemList.cloudIcon(presentationData.theme), title: text, alwaysPlain: false, sectionId: self.section, height: .generic, editing: false, action: {
|
||||
arguments.openFiles()
|
||||
})
|
||||
case let .recentHeader(_, text):
|
||||
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
|
||||
case let .file(_, _, message):
|
||||
let interaction = ListMessageItemInteraction(openMessage: { message, _ in
|
||||
arguments.send(message)
|
||||
return false
|
||||
}, openMessageContextMenu: { _, _, _, _, _ in }, toggleMessagesSelection: { _, _ in }, openUrl: { _, _, _, _ in }, openInstantPage: { _, _ in }, longTap: { _, _ in }, getHiddenMedia: { return [:] })
|
||||
|
||||
let dateTimeFormat = arguments.context.sharedContext.currentPresentationData.with({$0}).dateTimeFormat
|
||||
let chatPresentationData = ChatPresentationData(theme: ChatPresentationThemeData(theme: presentationData.theme, wallpaper: .color(0)), fontSize: presentationData.fontSize, strings: presentationData.strings, dateTimeFormat: dateTimeFormat, nameDisplayOrder: .firstLast, disableAnimations: false, largeEmoji: false, chatBubbleCorners: PresentationChatBubbleCorners(mainRadius: 0, auxiliaryRadius: 0, mergeBubbleCorners: false))
|
||||
|
||||
return ListMessageItem(presentationData: chatPresentationData, systemStyle: .glass, context: arguments.context, chatLocation: .peer(id: PeerId(0)), interaction: interaction, message: message, selection: .none, displayHeader: false, displayFileInfo: false, displayBackground: true, style: .blocks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func attachmentFileControllerEntries(presentationData: PresentationData, recentDocuments: [Message]?, empty: Bool) -> [AttachmentFileEntry] {
|
||||
guard !empty else {
|
||||
return []
|
||||
}
|
||||
var entries: [AttachmentFileEntry] = []
|
||||
entries.append(.selectFromGallery(presentationData.theme, presentationData.strings.Attachment_SelectFromGallery))
|
||||
entries.append(.selectFromFiles(presentationData.theme, presentationData.strings.Attachment_SelectFromFiles))
|
||||
|
||||
if let recentDocuments = recentDocuments {
|
||||
if recentDocuments.count > 0 {
|
||||
entries.append(.recentHeader(presentationData.theme, presentationData.strings.Attachment_RecentlySentFiles.uppercased()))
|
||||
var i: Int32 = 0
|
||||
for file in recentDocuments {
|
||||
entries.append(.file(i, presentationData.theme, file))
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
entries.append(.recentHeader(presentationData.theme, presentationData.strings.Attachment_RecentlySentFiles.uppercased()))
|
||||
for i in 0 ..< 11 {
|
||||
entries.append(.file(Int32(i), presentationData.theme, nil))
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
private final class AttachmentFileContext: AttachmentMediaPickerContext {
|
||||
}
|
||||
|
||||
public class AttachmentFileControllerImpl: ItemListController, AttachmentFileController, 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
|
||||
|
||||
var delayDisappear = false
|
||||
|
||||
var resetForReuseImpl: () -> Void = {}
|
||||
public func resetForReuse() {
|
||||
self.resetForReuseImpl()
|
||||
self.scrollToTop?()
|
||||
}
|
||||
|
||||
public func prepareForReuse() {
|
||||
self.delayDisappear = true
|
||||
self.visibleBottomContentOffsetChanged?(self.visibleBottomContentOffset)
|
||||
self.delayDisappear = false
|
||||
}
|
||||
|
||||
public var mediaPickerContext: AttachmentMediaPickerContext? {
|
||||
return AttachmentFileContext()
|
||||
}
|
||||
|
||||
private var topEdgeEffectView: EdgeEffectView?
|
||||
private var bottomEdgeEffectView: EdgeEffectView?
|
||||
|
||||
public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
|
||||
super.containerLayoutUpdated(layout, transition: transition)
|
||||
|
||||
let topEdgeEffectView: EdgeEffectView
|
||||
if let current = self.topEdgeEffectView {
|
||||
topEdgeEffectView = current
|
||||
} else {
|
||||
topEdgeEffectView = EdgeEffectView()
|
||||
if let navigationBar = self.navigationBar {
|
||||
self.view.insertSubview(topEdgeEffectView, belowSubview: navigationBar.view)
|
||||
}
|
||||
self.topEdgeEffectView = topEdgeEffectView
|
||||
}
|
||||
|
||||
let edgeEffectHeight: CGFloat = 88.0
|
||||
let topEdgeEffectFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.size.width, height: edgeEffectHeight))
|
||||
transition.updateFrame(view: topEdgeEffectView, frame: topEdgeEffectFrame)
|
||||
topEdgeEffectView.update(content: .clear, blur: true, alpha: 1.0, rect: topEdgeEffectFrame, edge: .top, edgeSize: topEdgeEffectFrame.height, transition: ComponentTransition(transition))
|
||||
|
||||
let bottomEdgeEffectView: EdgeEffectView
|
||||
if let current = self.bottomEdgeEffectView {
|
||||
bottomEdgeEffectView = current
|
||||
} else {
|
||||
bottomEdgeEffectView = EdgeEffectView()
|
||||
self.view.addSubview(bottomEdgeEffectView)
|
||||
self.bottomEdgeEffectView = bottomEdgeEffectView
|
||||
}
|
||||
|
||||
let bottomEdgeEffectFrame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - edgeEffectHeight - layout.additionalInsets.bottom), size: CGSize(width: layout.size.width, height: edgeEffectHeight))
|
||||
transition.updateFrame(view: bottomEdgeEffectView, frame: bottomEdgeEffectFrame)
|
||||
bottomEdgeEffectView.update(content: .clear, blur: true, alpha: 1.0, rect: bottomEdgeEffectFrame, edge: .bottom, edgeSize: bottomEdgeEffectFrame.height, transition: ComponentTransition(transition))
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttachmentFileControllerState: Equatable {
|
||||
var searching: Bool
|
||||
}
|
||||
|
||||
public func makeAttachmentFileControllerImpl(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)? = nil, bannedSendMedia: (Int32, Bool)?, presentGallery: @escaping () -> Void, presentFiles: @escaping () -> Void, send: @escaping (AnyMediaReference) -> Void) -> AttachmentFileController {
|
||||
let actionsDisposable = DisposableSet()
|
||||
|
||||
let statePromise = ValuePromise(AttachmentFileControllerState(searching: false), ignoreRepeated: true)
|
||||
let stateValue = Atomic(value: AttachmentFileControllerState(searching: false))
|
||||
let updateState: ((AttachmentFileControllerState) -> AttachmentFileControllerState) -> Void = { f in
|
||||
statePromise.set(stateValue.modify { f($0) })
|
||||
}
|
||||
|
||||
var updateTabBarVisibilityImpl: ((Bool) -> Void)?
|
||||
var expandImpl: (() -> Void)?
|
||||
var dismissImpl: (() -> Void)?
|
||||
var dismissInputImpl: (() -> Void)?
|
||||
let arguments = AttachmentFileControllerArguments(
|
||||
context: context,
|
||||
openGallery: {
|
||||
presentGallery()
|
||||
},
|
||||
openFiles: {
|
||||
presentFiles()
|
||||
},
|
||||
send: { message in
|
||||
let _ = (context.engine.messages.getMessagesLoadIfNecessary([message.id], strategy: .cloud(skipLocal: true))
|
||||
|> `catch` { _ in
|
||||
return .single(.result([]))
|
||||
}
|
||||
|> mapToSignal { result -> Signal<[Message], NoError> in
|
||||
guard case let .result(result) = result else {
|
||||
return .complete()
|
||||
}
|
||||
return .single(result)
|
||||
}
|
||||
|> deliverOnMainQueue).startStandalone(next: { messages in
|
||||
if let message = messages.first, let file = message.media.first(where: { $0 is TelegramMediaFile }) as? TelegramMediaFile {
|
||||
send(.message(message: MessageReference(message), media: file))
|
||||
}
|
||||
dismissImpl?()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
let recentDocuments: Signal<[Message]?, NoError> = .single(nil)
|
||||
|> then(
|
||||
context.engine.messages.searchMessages(location: .sentMedia(tags: [.file]), query: "", state: nil)
|
||||
|> map { result -> [Message]? in
|
||||
return result.0.messages
|
||||
}
|
||||
)
|
||||
|
||||
let presentationData = updatedPresentationData?.signal ?? context.sharedContext.presentationData
|
||||
|
||||
let existingCloseButton = Atomic<BarComponentHostNode?>(value: nil)
|
||||
let existingSearchButton = Atomic<BarComponentHostNode?>(value: nil)
|
||||
|
||||
let previousRecentDocuments = Atomic<[Message]?>(value: nil)
|
||||
let signal = combineLatest(queue: Queue.mainQueue(),
|
||||
presentationData,
|
||||
recentDocuments,
|
||||
statePromise.get()
|
||||
)
|
||||
|> map {
|
||||
presentationData,
|
||||
recentDocuments,
|
||||
state -> (ItemListControllerState, (ItemListNodeState, Any)) in
|
||||
var presentationData = presentationData
|
||||
|
||||
let updatedTheme = presentationData.theme.withModalBlocksBackground()
|
||||
presentationData = presentationData.withUpdated(theme: updatedTheme)
|
||||
|
||||
let barButtonSize = CGSize(width: 40.0, height: 40.0)
|
||||
let closeButton = GlassBarButtonComponent(
|
||||
size: barButtonSize,
|
||||
backgroundColor: presentationData.theme.rootController.navigationBar.glassBarButtonBackgroundColor,
|
||||
isDark: presentationData.theme.overallDarkAppearance,
|
||||
state: .generic,
|
||||
component: AnyComponentWithIdentity(id: "close", component: AnyComponent(
|
||||
BundleIconComponent(
|
||||
name: "Navigation/Close",
|
||||
tintColor: presentationData.theme.rootController.navigationBar.glassBarButtonForegroundColor
|
||||
)
|
||||
)),
|
||||
action: { _ in
|
||||
dismissImpl?()
|
||||
}
|
||||
)
|
||||
let closeButtonComponent = AnyComponentWithIdentity(id: "close", component: AnyComponent(closeButton))
|
||||
let closeButtonNode = existingCloseButton.modify { current in
|
||||
let buttonNode: BarComponentHostNode
|
||||
if let current {
|
||||
buttonNode = current
|
||||
buttonNode.component = closeButtonComponent
|
||||
} else {
|
||||
buttonNode = BarComponentHostNode(component: closeButtonComponent, size: barButtonSize)
|
||||
}
|
||||
return buttonNode
|
||||
}
|
||||
|
||||
let searchButton = GlassBarButtonComponent(
|
||||
size: barButtonSize,
|
||||
backgroundColor: presentationData.theme.rootController.navigationBar.glassBarButtonBackgroundColor,
|
||||
isDark: presentationData.theme.overallDarkAppearance,
|
||||
state: .generic,
|
||||
component: AnyComponentWithIdentity(id: "search", component: AnyComponent(
|
||||
BundleIconComponent(
|
||||
name: "Navigation/Search",
|
||||
tintColor: presentationData.theme.rootController.navigationBar.glassBarButtonForegroundColor
|
||||
)
|
||||
)),
|
||||
action: { _ in
|
||||
updateState { state in
|
||||
var updatedState = state
|
||||
updatedState.searching = true
|
||||
return updatedState
|
||||
}
|
||||
updateTabBarVisibilityImpl?(false)
|
||||
}
|
||||
)
|
||||
let searchButtonComponent = state.searching ? nil : AnyComponentWithIdentity(id: "search", component: AnyComponent(searchButton))
|
||||
let searchButtonNode = existingSearchButton.modify { current in
|
||||
let buttonNode: BarComponentHostNode
|
||||
if let current {
|
||||
buttonNode = current
|
||||
buttonNode.component = searchButtonComponent
|
||||
} else {
|
||||
buttonNode = BarComponentHostNode(component: searchButtonComponent, size: barButtonSize)
|
||||
}
|
||||
return buttonNode
|
||||
}
|
||||
|
||||
let previousRecentDocuments = previousRecentDocuments.swap(recentDocuments)
|
||||
let crossfade = previousRecentDocuments == nil && recentDocuments != nil
|
||||
var animateChanges = false
|
||||
if let previousRecentDocuments = previousRecentDocuments,
|
||||
let recentDocuments = recentDocuments,
|
||||
!previousRecentDocuments.isEmpty && !recentDocuments.isEmpty,
|
||||
!crossfade {
|
||||
animateChanges = true
|
||||
}
|
||||
|
||||
let leftNavigationButton = closeButtonNode.flatMap { ItemListNavigationButton(content: .node($0), style: .regular, enabled: true, action: {}) }
|
||||
|
||||
var rightNavigationButton: ItemListNavigationButton?
|
||||
if bannedSendMedia == nil && (recentDocuments == nil || (recentDocuments?.count ?? 0) > 10) {
|
||||
rightNavigationButton = searchButtonNode.flatMap { ItemListNavigationButton(content: .node($0), style: .regular, enabled: true, action: {}) }
|
||||
}
|
||||
|
||||
let controllerState = ItemListControllerState(
|
||||
presentationData: ItemListPresentationData(presentationData),
|
||||
title: .text(presentationData.strings.Attachment_File),
|
||||
leftNavigationButton: leftNavigationButton,
|
||||
rightNavigationButton: rightNavigationButton,
|
||||
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back),
|
||||
animateChanges: true
|
||||
)
|
||||
|
||||
var emptyItem: AttachmentFileEmptyStateItem?
|
||||
if let (untilDate, personal) = bannedSendMedia {
|
||||
let banDescription: String
|
||||
if untilDate != 0 && untilDate != Int32.max {
|
||||
banDescription = presentationData.strings.Conversation_RestrictedMediaTimed(stringForFullDate(timestamp: untilDate, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat)).string
|
||||
} else if personal {
|
||||
banDescription = presentationData.strings.Conversation_RestrictedMedia
|
||||
} else {
|
||||
banDescription = presentationData.strings.Conversation_DefaultRestrictedMedia
|
||||
}
|
||||
emptyItem = AttachmentFileEmptyStateItem(context: context, theme: presentationData.theme, strings: presentationData.strings, content: .bannedSendMedia(text: banDescription, canBoost: false))
|
||||
} else if let recentDocuments = recentDocuments,
|
||||
recentDocuments.isEmpty {
|
||||
emptyItem = AttachmentFileEmptyStateItem(context: context, theme: presentationData.theme, strings: presentationData.strings, content: .intro)
|
||||
}
|
||||
|
||||
var searchItem: ItemListControllerSearch?
|
||||
if state.searching {
|
||||
searchItem = AttachmentFileSearchItem(context: context, presentationData: presentationData, focus: {
|
||||
expandImpl?()
|
||||
}, cancel: {
|
||||
updateState { state in
|
||||
var updatedState = state
|
||||
updatedState.searching = false
|
||||
return updatedState
|
||||
}
|
||||
updateTabBarVisibilityImpl?(true)
|
||||
}, send: { message in
|
||||
arguments.send(message)
|
||||
}, dismissInput: {
|
||||
dismissInputImpl?()
|
||||
})
|
||||
}
|
||||
|
||||
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: attachmentFileControllerEntries(presentationData: presentationData, recentDocuments: recentDocuments, empty: bannedSendMedia != nil), style: .blocks, emptyStateItem: emptyItem, searchItem: searchItem, crossfadeState: crossfade, animateChanges: animateChanges)
|
||||
|
||||
return (controllerState, (listState, arguments))
|
||||
} |> afterDisposed {
|
||||
actionsDisposable.dispose()
|
||||
}
|
||||
|
||||
let controller = AttachmentFileControllerImpl(context: context, state: signal, hideNavigationBarBackground: true)
|
||||
controller.delayDisappear = true
|
||||
controller.visibleBottomContentOffsetChanged = { [weak controller] offset in
|
||||
switch offset {
|
||||
case let .known(value):
|
||||
let backgroundAlpha: CGFloat = min(30.0, max(0.0, value)) / 30.0
|
||||
if backgroundAlpha.isZero && controller?.delayDisappear == true {
|
||||
Queue.mainQueue().after(0.25, {
|
||||
controller?.updateTabBarAlpha(backgroundAlpha, .animated(duration: 0.1, curve: .easeInOut))
|
||||
})
|
||||
} else {
|
||||
controller?.updateTabBarAlpha(backgroundAlpha, .immediate)
|
||||
}
|
||||
case .unknown, .none:
|
||||
controller?.updateTabBarAlpha(1.0, .immediate)
|
||||
controller?.delayDisappear = false
|
||||
}
|
||||
}
|
||||
controller.resetForReuseImpl = {
|
||||
updateState { state in
|
||||
var updatedState = state
|
||||
updatedState.searching = false
|
||||
return updatedState
|
||||
}
|
||||
}
|
||||
dismissImpl = { [weak controller] in
|
||||
controller?.dismiss(animated: true)
|
||||
}
|
||||
dismissInputImpl = { [weak controller] in
|
||||
controller?.view.endEditing(true)
|
||||
}
|
||||
expandImpl = { [weak controller] in
|
||||
controller?.requestAttachmentMenuExpansion()
|
||||
}
|
||||
updateTabBarVisibilityImpl = { [weak controller] isVisible in
|
||||
controller?.updateTabBarVisibility(isVisible, .animated(duration: 0.4, curve: .spring))
|
||||
}
|
||||
return controller
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AsyncDisplayKit
|
||||
import Display
|
||||
import TelegramPresentationData
|
||||
import ItemListUI
|
||||
import PresentationDataUtils
|
||||
import AnimatedStickerNode
|
||||
import TelegramAnimatedStickerNode
|
||||
import AccountContext
|
||||
import SolidRoundedButtonNode
|
||||
|
||||
final class AttachmentFileEmptyStateItem: ItemListControllerEmptyStateItem {
|
||||
enum Content: Equatable {
|
||||
case intro
|
||||
case bannedSendMedia(text: String, canBoost: Bool)
|
||||
}
|
||||
|
||||
let context: AccountContext
|
||||
let theme: PresentationTheme
|
||||
let strings: PresentationStrings
|
||||
let content: Content
|
||||
|
||||
init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, content: Content) {
|
||||
self.context = context
|
||||
self.theme = theme
|
||||
self.strings = strings
|
||||
self.content = content
|
||||
}
|
||||
|
||||
func isEqual(to: ItemListControllerEmptyStateItem) -> Bool {
|
||||
if let item = to as? AttachmentFileEmptyStateItem {
|
||||
return self.theme === item.theme && self.strings === item.strings && self.content == item.content
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func node(current: ItemListControllerEmptyStateItemNode?) -> ItemListControllerEmptyStateItemNode {
|
||||
if let current = current as? AttachmentFileEmptyStateItemNode {
|
||||
current.item = self
|
||||
return current
|
||||
} else {
|
||||
return AttachmentFileEmptyStateItemNode(item: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class AttachmentFileEmptyStateItemNode: ItemListControllerEmptyStateItemNode {
|
||||
private var animationNode: AnimatedStickerNode
|
||||
private let textNode: ASTextNode
|
||||
private let buttonNode: SolidRoundedButtonNode
|
||||
private var validLayout: (ContainerViewLayout, CGFloat)?
|
||||
|
||||
var item: AttachmentFileEmptyStateItem {
|
||||
didSet {
|
||||
self.updateThemeAndStrings(theme: self.item.theme, strings: self.item.strings)
|
||||
if let (layout, navigationHeight) = self.validLayout {
|
||||
self.updateLayout(layout: layout, navigationBarHeight: navigationHeight, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(item: AttachmentFileEmptyStateItem) {
|
||||
self.item = item
|
||||
|
||||
let name: String
|
||||
let playbackMode: AnimatedStickerPlaybackMode
|
||||
switch item.content {
|
||||
case .intro:
|
||||
name = "Files"
|
||||
playbackMode = .loop
|
||||
case .bannedSendMedia:
|
||||
name = "Banned"
|
||||
playbackMode = .once
|
||||
}
|
||||
|
||||
self.animationNode = DefaultAnimatedStickerNodeImpl()
|
||||
self.animationNode.setup(source: AnimatedStickerNodeLocalFileSource(name: name), width: 320, height: 320, playbackMode: playbackMode, mode: .direct(cachePathPrefix: nil))
|
||||
self.animationNode.visibility = true
|
||||
|
||||
self.textNode = ASTextNode()
|
||||
self.textNode.isUserInteractionEnabled = false
|
||||
self.textNode.lineSpacing = 0.1
|
||||
self.textNode.textAlignment = .center
|
||||
|
||||
self.buttonNode = SolidRoundedButtonNode(theme: SolidRoundedButtonTheme(backgroundColor: .black, foregroundColor: .white), height: 50.0, cornerRadius: 11.0, gloss: true)
|
||||
|
||||
super.init()
|
||||
|
||||
self.isUserInteractionEnabled = false
|
||||
|
||||
self.addSubnode(self.animationNode)
|
||||
self.addSubnode(self.textNode)
|
||||
|
||||
self.updateThemeAndStrings(theme: self.item.theme, strings: self.item.strings)
|
||||
|
||||
if case .bannedSendMedia(_, true) = item.content {
|
||||
self.addSubnode(self.buttonNode)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) {
|
||||
let text: String
|
||||
switch self.item.content {
|
||||
case .intro:
|
||||
text = strings.Attachment_FilesIntro
|
||||
case let .bannedSendMedia(banDescription, _):
|
||||
text = banDescription
|
||||
}
|
||||
self.textNode.attributedText = NSAttributedString(string: text.replacingOccurrences(of: "\n", with: " "), font: Font.regular(15.0), textColor: theme.list.freeTextColor, paragraphAlignment: .center)
|
||||
self.buttonNode.title = strings.Attachment_OpenSettings
|
||||
self.buttonNode.updateTheme(SolidRoundedButtonTheme(theme: theme))
|
||||
}
|
||||
|
||||
override func updateLayout(layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (layout, navigationBarHeight)
|
||||
|
||||
var imageSize = CGSize(width: 144.0, height: 144.0)
|
||||
var insets = layout.insets(options: [])
|
||||
if layout.size.width == 320.0 {
|
||||
insets.top += -60.0
|
||||
imageSize = CGSize(width: 112.0, height: 112.0)
|
||||
} else {
|
||||
insets.top += -160.0
|
||||
}
|
||||
|
||||
let imageSpacing: CGFloat = 12.0
|
||||
let textSpacing: CGFloat = 12.0
|
||||
let buttonSpacing: CGFloat = 15.0
|
||||
let bottomSpacing: CGFloat = 33.0
|
||||
|
||||
let imageHeight = layout.size.width < layout.size.height ? imageSize.height + imageSpacing : 0.0
|
||||
|
||||
let buttonWidth: CGFloat = 248.0
|
||||
let buttonHeight = self.buttonNode.updateLayout(width: buttonWidth, transition: transition)
|
||||
|
||||
let textSize = self.textNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - 40.0, height: max(1.0, layout.size.height - insets.top - insets.bottom)))
|
||||
|
||||
let totalHeight = imageHeight + textSpacing + textSize.height + buttonSpacing + buttonHeight + bottomSpacing
|
||||
let topOffset = insets.top + floor((layout.size.height - insets.top - insets.bottom - totalHeight) / 2.0)
|
||||
|
||||
transition.updateAlpha(node: self.animationNode, alpha: imageHeight > 0.0 ? 1.0 : 0.0)
|
||||
transition.updateFrame(node: self.animationNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - imageSize.width) / 2.0), y: topOffset), size: imageSize))
|
||||
self.animationNode.updateLayout(size: imageSize)
|
||||
|
||||
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layout.size.width - textSize.width - layout.safeInsets.left - layout.safeInsets.right) / 2.0), y: topOffset + imageHeight + textSpacing), size: textSize))
|
||||
|
||||
transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layout.size.width - buttonWidth - layout.safeInsets.left - layout.safeInsets.right) / 2.0), y: self.textNode.frame.maxY + buttonSpacing), size: CGSize(width: buttonWidth, height: buttonHeight)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Display
|
||||
import AsyncDisplayKit
|
||||
import Postbox
|
||||
import TelegramCore
|
||||
import SwiftSignalKit
|
||||
import ItemListUI
|
||||
import PresentationDataUtils
|
||||
import TelegramPresentationData
|
||||
import TelegramUIPreferences
|
||||
import AccountContext
|
||||
import SearchBarNode
|
||||
import MergeLists
|
||||
import ChatListSearchItemHeader
|
||||
import ItemListUI
|
||||
import SearchUI
|
||||
import ContextUI
|
||||
import ListMessageItem
|
||||
import ComponentFlow
|
||||
import SearchInputPanelComponent
|
||||
|
||||
private let searchBarFont = Font.regular(17.0)
|
||||
|
||||
private final class AttachmentFileSearchNavigationContentNode: NavigationBarContentNode, ItemListControllerSearchNavigationContentNode {
|
||||
private var theme: PresentationTheme
|
||||
private let strings: PresentationStrings
|
||||
|
||||
private let focus: () -> Void
|
||||
private let cancel: () -> Void
|
||||
|
||||
private let searchBar: SearchBarNode
|
||||
|
||||
private var queryUpdated: ((String) -> Void)?
|
||||
var activity: Bool = false {
|
||||
didSet {
|
||||
self.searchBar.activity = activity
|
||||
}
|
||||
}
|
||||
init(theme: PresentationTheme, strings: PresentationStrings, focus: @escaping () -> Void, cancel: @escaping () -> Void, updateActivity: @escaping(@escaping(Bool)->Void) -> Void) {
|
||||
self.theme = theme
|
||||
self.strings = strings
|
||||
|
||||
self.focus = focus
|
||||
self.cancel = cancel
|
||||
|
||||
self.searchBar = SearchBarNode(theme: SearchBarNodeTheme(theme: theme, hasSeparator: false), strings: strings, fieldStyle: .modern, displayBackground: false)
|
||||
|
||||
super.init()
|
||||
|
||||
//self.addSubnode(self.searchBar)
|
||||
|
||||
self.searchBar.cancel = { [weak self] in
|
||||
self?.searchBar.deactivate(clear: false)
|
||||
self?.cancel()
|
||||
}
|
||||
|
||||
self.searchBar.textUpdated = { [weak self] query, _ in
|
||||
self?.queryUpdated?(query)
|
||||
}
|
||||
|
||||
self.searchBar.focusUpdated = { [weak self] focus in
|
||||
if focus {
|
||||
self?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
updateActivity({ [weak self] value in
|
||||
self?.activity = value
|
||||
})
|
||||
|
||||
self.updatePlaceholder()
|
||||
}
|
||||
|
||||
func setQueryUpdated(_ f: @escaping (String) -> Void) {
|
||||
self.queryUpdated = f
|
||||
}
|
||||
|
||||
func updateTheme(_ theme: PresentationTheme) {
|
||||
self.theme = theme
|
||||
self.searchBar.updateThemeAndStrings(theme: SearchBarNodeTheme(theme: self.theme), strings: self.strings)
|
||||
self.updatePlaceholder()
|
||||
}
|
||||
|
||||
func updatePlaceholder() {
|
||||
self.searchBar.placeholderString = NSAttributedString(string: self.strings.Attachment_FilesSearchPlaceholder, font: searchBarFont, textColor: self.theme.rootController.navigationSearchBar.inputPlaceholderTextColor)
|
||||
}
|
||||
|
||||
override var nominalHeight: CGFloat {
|
||||
return 56.0
|
||||
}
|
||||
|
||||
override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) {
|
||||
let searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - self.nominalHeight), size: CGSize(width: size.width, height: 56.0))
|
||||
self.searchBar.frame = searchBarFrame
|
||||
self.searchBar.updateLayout(boundingSize: searchBarFrame.size, leftInset: leftInset, rightInset: rightInset, transition: transition)
|
||||
}
|
||||
|
||||
func activate() {
|
||||
//self.searchBar.activate()
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
//self.searchBar.deactivate(clear: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class AttachmentFileSearchItem: ItemListControllerSearch {
|
||||
let context: AccountContext
|
||||
let presentationData: PresentationData
|
||||
let focus: () -> Void
|
||||
let cancel: () -> Void
|
||||
let send: (Message) -> Void
|
||||
let dismissInput: () -> Void
|
||||
|
||||
private var updateActivity: ((Bool) -> Void)?
|
||||
private var activity: ValuePromise<Bool> = ValuePromise(ignoreRepeated: false)
|
||||
private let activityDisposable = MetaDisposable()
|
||||
|
||||
init(context: AccountContext, presentationData: PresentationData, focus: @escaping () -> Void, cancel: @escaping () -> Void, send: @escaping (Message) -> Void, dismissInput: @escaping () -> Void) {
|
||||
self.context = context
|
||||
self.presentationData = presentationData
|
||||
self.focus = focus
|
||||
self.cancel = cancel
|
||||
self.send = send
|
||||
self.dismissInput = dismissInput
|
||||
self.activityDisposable.set((activity.get() |> mapToSignal { value -> Signal<Bool, NoError> in
|
||||
if value {
|
||||
return .single(value) |> delay(0.2, queue: Queue.mainQueue())
|
||||
} else {
|
||||
return .single(value)
|
||||
}
|
||||
}).startStrict(next: { [weak self] value in
|
||||
self?.updateActivity?(value)
|
||||
}))
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.activityDisposable.dispose()
|
||||
}
|
||||
|
||||
func isEqual(to: ItemListControllerSearch) -> Bool {
|
||||
if let to = to as? AttachmentFileSearchItem {
|
||||
if self.context !== to.context {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func titleContentNode(current: (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)?) -> (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)? {
|
||||
return nil
|
||||
// let presentationData = self.presentationData
|
||||
// if let current = current as? AttachmentFileSearchNavigationContentNode {
|
||||
// current.updateTheme(presentationData.theme)
|
||||
// return current
|
||||
// } else {
|
||||
// return AttachmentFileSearchNavigationContentNode(theme: presentationData.theme, strings: presentationData.strings, focus: self.focus, cancel: self.cancel, updateActivity: { [weak self] value in
|
||||
// self?.updateActivity = value
|
||||
// })
|
||||
// }
|
||||
}
|
||||
|
||||
func node(current: ItemListControllerSearchNode?, titleContentNode: (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)?) -> ItemListControllerSearchNode {
|
||||
return AttachmentFileSearchItemNode(context: self.context, theme: self.presentationData.theme, strings: self.presentationData.strings, focus: self.focus, send: self.send, cancel: self.cancel, updateActivity: { [weak self] value in
|
||||
self?.activity.set(value)
|
||||
}, dismissInput: self.dismissInput)
|
||||
}
|
||||
}
|
||||
|
||||
private final class AttachmentFileSearchItemNode: ItemListControllerSearchNode {
|
||||
private let context: AccountContext
|
||||
private let theme: PresentationTheme
|
||||
private let strings: PresentationStrings
|
||||
private let focus: () -> Void
|
||||
private let cancel: () -> Void
|
||||
|
||||
private let containerNode: AttachmentFileSearchContainerNode
|
||||
|
||||
private let searchInput = ComponentView<Empty>()
|
||||
|
||||
private var validLayout: ContainerViewLayout?
|
||||
|
||||
init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, focus: @escaping () -> Void, send: @escaping (Message) -> Void, cancel: @escaping () -> Void, updateActivity: @escaping(Bool) -> Void, dismissInput: @escaping () -> Void) {
|
||||
self.context = context
|
||||
self.theme = theme
|
||||
self.strings = strings
|
||||
self.focus = focus
|
||||
self.cancel = cancel
|
||||
|
||||
self.containerNode = AttachmentFileSearchContainerNode(context: context, forceTheme: nil, send: { message in
|
||||
send(message)
|
||||
}, updateActivity: updateActivity)
|
||||
|
||||
|
||||
super.init()
|
||||
|
||||
self.addSubnode(self.containerNode)
|
||||
|
||||
self.containerNode.cancel = { [weak self] in
|
||||
dismissInput()
|
||||
cancel()
|
||||
self?.deactivateInput()
|
||||
}
|
||||
self.containerNode.dismissInput = {
|
||||
dismissInput()
|
||||
}
|
||||
}
|
||||
|
||||
override func queryUpdated(_ query: String) {
|
||||
self.containerNode.searchTextUpdated(text: query)
|
||||
}
|
||||
|
||||
override func scrollToTop() {
|
||||
self.containerNode.scrollToTop()
|
||||
}
|
||||
|
||||
private func deactivateInput() {
|
||||
if let layout = self.validLayout, let searchInputView = self.searchInput.view as? SearchInputPanelComponent.View {
|
||||
let transition = ComponentTransition.spring(duration: 0.4)
|
||||
transition.setFrame(view: searchInputView, frame: CGRect(origin: CGPoint(x: searchInputView.frame.minX, y: layout.size.height), size: searchInputView.frame.size))
|
||||
}
|
||||
}
|
||||
|
||||
override func updateLayout(layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = layout
|
||||
|
||||
transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight)))
|
||||
self.containerNode.containerLayoutUpdated(layout.withUpdatedSize(CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight)), navigationBarHeight: 0.0, transition: transition)
|
||||
|
||||
let searchInputSize = self.searchInput.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(
|
||||
SearchInputPanelComponent(
|
||||
theme: self.theme,
|
||||
strings: self.strings,
|
||||
placeholder: self.strings.Attachment_FilesSearchPlaceholder,
|
||||
resetText: nil,
|
||||
updated: { [weak self] query in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.queryUpdated(query)
|
||||
},
|
||||
cancel: { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.cancel()
|
||||
self.deactivateInput()
|
||||
}
|
||||
)
|
||||
),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right, height: layout.size.height)
|
||||
)
|
||||
|
||||
let bottomInset: CGFloat = layout.insets(options: .input).bottom
|
||||
let searchInputFrame = CGRect(origin: CGPoint(x: layout.safeInsets.left, y: layout.size.height - bottomInset - searchInputSize.height), size: searchInputSize)
|
||||
if let searchInputView = self.searchInput.view as? SearchInputPanelComponent.View {
|
||||
if searchInputView.superview == nil {
|
||||
self.view.addSubview(searchInputView)
|
||||
searchInputView.frame = CGRect(origin: CGPoint(x: searchInputFrame.minX, y: layout.size.height), size: searchInputFrame.size)
|
||||
|
||||
self.focus()
|
||||
searchInputView.activateInput()
|
||||
}
|
||||
transition.updateFrame(view: searchInputView, frame: searchInputFrame)
|
||||
}
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
if let searchInputView = self.searchInput.view as? SearchInputPanelComponent.View {
|
||||
if let result = searchInputView.hitTest(self.view.convert(point, to: searchInputView), with: event) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
if let result = self.containerNode.hitTest(self.view.convert(point, to: self.containerNode.view), with: event) {
|
||||
return result
|
||||
}
|
||||
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private final class AttachmentFileSearchContainerInteraction {
|
||||
let context: AccountContext
|
||||
let send: (Message) -> Void
|
||||
|
||||
init(context: AccountContext, send: @escaping (Message) -> Void) {
|
||||
self.context = context
|
||||
self.send = send
|
||||
}
|
||||
}
|
||||
|
||||
private enum AttachmentFileSearchEntryId: Hashable {
|
||||
case placeholder(Int)
|
||||
case message(MessageId)
|
||||
}
|
||||
|
||||
private func areMessagesEqual(_ lhsMessage: Message?, _ rhsMessage: Message?) -> Bool {
|
||||
guard let lhsMessage = lhsMessage, let rhsMessage = rhsMessage else {
|
||||
return lhsMessage == nil && rhsMessage == nil
|
||||
}
|
||||
if lhsMessage.stableVersion != rhsMessage.stableVersion {
|
||||
return false
|
||||
}
|
||||
if lhsMessage.id != rhsMessage.id || lhsMessage.flags != rhsMessage.flags {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private final class AttachmentFileSearchEntry: Comparable, Identifiable {
|
||||
let index: Int
|
||||
let message: Message?
|
||||
|
||||
init(index: Int, message: Message?) {
|
||||
self.index = index
|
||||
self.message = message
|
||||
}
|
||||
|
||||
var stableId: AttachmentFileSearchEntryId {
|
||||
if let message = self.message {
|
||||
return .message(message.id)
|
||||
} else {
|
||||
return .placeholder(self.index)
|
||||
}
|
||||
}
|
||||
|
||||
static func ==(lhs: AttachmentFileSearchEntry, rhs: AttachmentFileSearchEntry) -> Bool {
|
||||
return lhs.index == rhs.index && areMessagesEqual(lhs.message, rhs.message)
|
||||
}
|
||||
|
||||
static func <(lhs: AttachmentFileSearchEntry, rhs: AttachmentFileSearchEntry) -> Bool {
|
||||
return lhs.index < rhs.index
|
||||
}
|
||||
|
||||
func item(context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, interaction: AttachmentFileSearchContainerInteraction) -> ListViewItem {
|
||||
let itemInteraction = ListMessageItemInteraction(openMessage: { message, _ in
|
||||
interaction.send(message)
|
||||
return false
|
||||
}, openMessageContextMenu: { _, _, _, _, _ in }, toggleMessagesSelection: { _, _ in }, openUrl: { _, _, _, _ in }, openInstantPage: { _, _ in }, longTap: { _, _ in }, getHiddenMedia: { return [:] })
|
||||
return ListMessageItem(presentationData: ChatPresentationData(presentationData: interaction.context.sharedContext.currentPresentationData.with({$0})), context: interaction.context, chatLocation: .peer(id: PeerId(0)), interaction: itemInteraction, message: message, selection: .none, displayHeader: true, displayFileInfo: false, displayBackground: true, style: .plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentFileSearchContainerTransition {
|
||||
let deletions: [ListViewDeleteItem]
|
||||
let insertions: [ListViewInsertItem]
|
||||
let updates: [ListViewUpdateItem]
|
||||
let isSearching: Bool
|
||||
let isEmpty: Bool
|
||||
let query: String
|
||||
}
|
||||
|
||||
private func attachmentFileSearchContainerPreparedRecentTransition(from fromEntries: [AttachmentFileSearchEntry], to toEntries: [AttachmentFileSearchEntry], isSearching: Bool, isEmpty: Bool, query: String, context: AccountContext, presentationData: PresentationData, nameSortOrder: PresentationPersonNameOrder, nameDisplayOrder: PresentationPersonNameOrder, interaction: AttachmentFileSearchContainerInteraction) -> AttachmentFileSearchContainerTransition {
|
||||
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 AttachmentFileSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, isSearching: isSearching, isEmpty: isEmpty, query: query)
|
||||
}
|
||||
|
||||
|
||||
public final class AttachmentFileSearchContainerNode: SearchDisplayControllerContentNode {
|
||||
private let context: AccountContext
|
||||
private let send: (Message) -> Void
|
||||
|
||||
private let dimNode: ASDisplayNode
|
||||
private let listNode: ListView
|
||||
|
||||
private let emptyResultsTitleNode: ImmediateTextNode
|
||||
private let emptyResultsTextNode: ImmediateTextNode
|
||||
|
||||
private var enqueuedTransitions: [(AttachmentFileSearchContainerTransition, Bool)] = []
|
||||
private var validLayout: (ContainerViewLayout, CGFloat)?
|
||||
|
||||
private let searchQuery = Promise<String?>()
|
||||
private let emptyQueryDisposable = MetaDisposable()
|
||||
private let searchDisposable = MetaDisposable()
|
||||
|
||||
private let forceTheme: PresentationTheme?
|
||||
private var presentationData: PresentationData
|
||||
private var presentationDataDisposable: Disposable?
|
||||
|
||||
private let presentationDataPromise: Promise<PresentationData>
|
||||
|
||||
private var _hasDim: Bool = false
|
||||
override public var hasDim: Bool {
|
||||
return _hasDim
|
||||
}
|
||||
|
||||
public init(context: AccountContext, forceTheme: PresentationTheme?, send: @escaping (Message) -> Void, updateActivity: @escaping (Bool) -> Void) {
|
||||
self.context = context
|
||||
self.send = send
|
||||
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
self.presentationData = presentationData
|
||||
|
||||
self.forceTheme = forceTheme
|
||||
if let forceTheme = self.forceTheme {
|
||||
self.presentationData = self.presentationData.withUpdated(theme: forceTheme)
|
||||
}
|
||||
self.presentationDataPromise = Promise(self.presentationData)
|
||||
|
||||
self.dimNode = ASDisplayNode()
|
||||
self.dimNode.backgroundColor = .clear // UIColor.black.withAlphaComponent(0.5)
|
||||
|
||||
self.listNode = ListView()
|
||||
self.listNode.accessibilityPageScrolledString = { row, count in
|
||||
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
|
||||
}
|
||||
|
||||
self.emptyResultsTitleNode = ImmediateTextNode()
|
||||
self.emptyResultsTitleNode.displaysAsynchronously = false
|
||||
self.emptyResultsTitleNode.attributedText = NSAttributedString(string: self.presentationData.strings.ChatList_Search_NoResults, font: Font.semibold(17.0), textColor: self.presentationData.theme.list.freeTextColor)
|
||||
self.emptyResultsTitleNode.textAlignment = .center
|
||||
self.emptyResultsTitleNode.isHidden = true
|
||||
|
||||
self.emptyResultsTextNode = ImmediateTextNode()
|
||||
self.emptyResultsTextNode.displaysAsynchronously = false
|
||||
self.emptyResultsTextNode.maximumNumberOfLines = 0
|
||||
self.emptyResultsTextNode.textAlignment = .center
|
||||
self.emptyResultsTextNode.isHidden = true
|
||||
|
||||
super.init()
|
||||
|
||||
self.listNode.backgroundColor = self.presentationData.theme.chatList.backgroundColor
|
||||
self.listNode.alpha = 0.0
|
||||
//self.listNode.isHidden = true
|
||||
|
||||
self._hasDim = true
|
||||
|
||||
self.addSubnode(self.dimNode)
|
||||
self.addSubnode(self.listNode)
|
||||
|
||||
self.addSubnode(self.emptyResultsTitleNode)
|
||||
self.addSubnode(self.emptyResultsTextNode)
|
||||
|
||||
|
||||
let interaction = AttachmentFileSearchContainerInteraction(context: context, send: { [weak self] message in
|
||||
send(message)
|
||||
self?.listNode.clearHighlightAnimated(true)
|
||||
})
|
||||
|
||||
let presentationDataPromise = self.presentationDataPromise
|
||||
|
||||
let searchQuery = self.searchQuery.get()
|
||||
|> mapToSignal { query -> Signal<String?, NoError> in
|
||||
if let query = query, !query.isEmpty {
|
||||
return (.complete() |> delay(0.6, queue: Queue.mainQueue()))
|
||||
|> then(.single(query))
|
||||
} else {
|
||||
return .single(query)
|
||||
}
|
||||
}
|
||||
|
||||
let foundItems = searchQuery
|
||||
|> mapToSignal { query -> Signal<[AttachmentFileSearchEntry]?, NoError> in
|
||||
guard let query = query, !query.isEmpty else {
|
||||
return .single(nil)
|
||||
}
|
||||
|
||||
let signal: Signal<[Message]?, NoError> = .single(nil)
|
||||
|> then(
|
||||
context.engine.messages.searchMessages(location: .sentMedia(tags: [.file]), query: query, state: nil)
|
||||
|> map { result -> [Message]? in
|
||||
return result.0.messages
|
||||
}
|
||||
)
|
||||
updateActivity(true)
|
||||
|
||||
return combineLatest(signal, presentationDataPromise.get())
|
||||
|> mapToSignal { messages, presentationData -> Signal<[AttachmentFileSearchEntry]?, NoError> in
|
||||
var entries: [AttachmentFileSearchEntry] = []
|
||||
var index = 0
|
||||
if let messages = messages {
|
||||
for message in messages {
|
||||
entries.append(AttachmentFileSearchEntry(index: index, message: message))
|
||||
index += 1
|
||||
}
|
||||
} else {
|
||||
for _ in 0 ..< 2 {
|
||||
entries.append(AttachmentFileSearchEntry(index: index, message: nil))
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return .single(entries)
|
||||
}
|
||||
}
|
||||
|
||||
let previousSearchItems = Atomic<[AttachmentFileSearchEntry]?>(value: nil)
|
||||
self.searchDisposable.set((combineLatest(searchQuery, foundItems, self.presentationDataPromise.get())
|
||||
|> deliverOnMainQueue).startStrict(next: { [weak self] query, entries, presentationData in
|
||||
if let strongSelf = self {
|
||||
let previousEntries = previousSearchItems.swap(entries)
|
||||
updateActivity(false)
|
||||
let firstTime = previousEntries == nil
|
||||
let transition = attachmentFileSearchContainerPreparedRecentTransition(from: previousEntries ?? [], to: entries ?? [], isSearching: entries != nil, isEmpty: entries?.isEmpty ?? false, query: query ?? "", context: context, presentationData: presentationData, nameSortOrder: presentationData.nameSortOrder, nameDisplayOrder: presentationData.nameDisplayOrder, interaction: interaction)
|
||||
strongSelf.enqueueTransition(transition, firstTime: firstTime)
|
||||
}
|
||||
}))
|
||||
|
||||
self.presentationDataDisposable = (context.sharedContext.presentationData
|
||||
|> deliverOnMainQueue).startStrict(next: { [weak self] presentationData in
|
||||
if let strongSelf = self {
|
||||
var presentationData = presentationData
|
||||
|
||||
let previousTheme = strongSelf.presentationData.theme
|
||||
let previousStrings = strongSelf.presentationData.strings
|
||||
|
||||
if let forceTheme = strongSelf.forceTheme {
|
||||
presentationData = presentationData.withUpdated(theme: forceTheme)
|
||||
}
|
||||
|
||||
strongSelf.presentationData = presentationData
|
||||
|
||||
if previousTheme !== presentationData.theme || previousStrings !== presentationData.strings {
|
||||
strongSelf.updateThemeAndStrings(theme: presentationData.theme, strings: presentationData.strings)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
self.listNode.beganInteractiveDragging = { [weak self] _ in
|
||||
self?.dismissInput?()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.searchDisposable.dispose()
|
||||
self.presentationDataDisposable?.dispose()
|
||||
}
|
||||
|
||||
override public func didLoad() {
|
||||
super.didLoad()
|
||||
|
||||
self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
|
||||
}
|
||||
|
||||
private func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) {
|
||||
self.listNode.backgroundColor = theme.chatList.backgroundColor
|
||||
}
|
||||
|
||||
override public func searchTextUpdated(text: String) {
|
||||
if text.isEmpty {
|
||||
self.searchQuery.set(.single(nil))
|
||||
} else {
|
||||
self.searchQuery.set(.single(text))
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueueTransition(_ transition: AttachmentFileSearchContainerTransition, firstTime: Bool) {
|
||||
self.enqueuedTransitions.append((transition, firstTime))
|
||||
|
||||
if let _ = self.validLayout {
|
||||
while !self.enqueuedTransitions.isEmpty {
|
||||
self.dequeueTransition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dequeueTransition() {
|
||||
if let (transition, _) = self.enqueuedTransitions.first {
|
||||
self.enqueuedTransitions.remove(at: 0)
|
||||
|
||||
var options = ListViewDeleteAndInsertOptions()
|
||||
options.insert(.PreferSynchronousDrawing)
|
||||
options.insert(.PreferSynchronousResourceLoading)
|
||||
|
||||
let isSearching = transition.isSearching
|
||||
self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
|
||||
let containerTransition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut)
|
||||
containerTransition.updateAlpha(node: strongSelf.listNode, alpha: isSearching ? 1.0 : 0.0)
|
||||
strongSelf.dimNode.isHidden = transition.isSearching
|
||||
|
||||
strongSelf.emptyResultsTextNode.attributedText = NSAttributedString(string: strongSelf.presentationData.strings.ChatList_Search_NoResultsQueryDescription(transition.query).string, font: Font.regular(15.0), textColor: strongSelf.presentationData.theme.list.freeTextColor)
|
||||
|
||||
let emptyResults = transition.isSearching && transition.isEmpty
|
||||
strongSelf.emptyResultsTitleNode.isHidden = !emptyResults
|
||||
strongSelf.emptyResultsTextNode.isHidden = !emptyResults
|
||||
|
||||
if let (layout, navigationBarHeight) = strongSelf.validLayout {
|
||||
strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
|
||||
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
|
||||
|
||||
let hadValidLayout = self.validLayout == nil
|
||||
self.validLayout = (layout, navigationBarHeight)
|
||||
|
||||
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
|
||||
|
||||
var insets = layout.insets(options: [.input])
|
||||
insets.top += navigationBarHeight
|
||||
|
||||
let topInset = navigationBarHeight
|
||||
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset), size: CGSize(width: layout.size.width, height: layout.size.height - topInset)))
|
||||
|
||||
self.listNode.frame = CGRect(origin: CGPoint(), size: layout.size)
|
||||
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
|
||||
|
||||
let padding: CGFloat = 16.0
|
||||
let emptyTitleSize = self.emptyResultsTitleNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
|
||||
let emptyTextSize = self.emptyResultsTextNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
|
||||
|
||||
let emptyTextSpacing: CGFloat = 8.0
|
||||
let emptyTotalHeight = emptyTitleSize.height + emptyTextSize.height + emptyTextSpacing
|
||||
let emptyTitleY = navigationBarHeight + floorToScreenPixels((layout.size.height - navigationBarHeight - max(insets.bottom, layout.intrinsicInsets.bottom) - emptyTotalHeight) / 2.0)
|
||||
|
||||
transition.updateFrame(node: self.emptyResultsTitleNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + padding + (layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0 - emptyTitleSize.width) / 2.0, y: emptyTitleY), size: emptyTitleSize))
|
||||
transition.updateFrame(node: self.emptyResultsTextNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + padding + (layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0 - emptyTextSize.width) / 2.0, y: emptyTitleY + emptyTitleSize.height + emptyTextSpacing), size: emptyTextSize))
|
||||
|
||||
if !hadValidLayout {
|
||||
while !self.enqueuedTransitions.isEmpty {
|
||||
self.dequeueTransition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override public func scrollToTop() {
|
||||
if self.listNode.alpha > 0.0 {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
|
||||
if case .ended = recognizer.state {
|
||||
self.cancel?()
|
||||
}
|
||||
}
|
||||
|
||||
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
guard let result = self.view.hitTest(point, with: event) else {
|
||||
return nil
|
||||
}
|
||||
if result === self.view {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user