diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index c28d37a99f..d8dd72eb9c 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -12194,6 +12194,13 @@ Sorry for the inconvenience."; "HashtagSearch.ThisChat" = "This Chat"; "HashtagSearch.MyMessages" = "My Messages"; "HashtagSearch.PublicPosts" = "Public Posts"; +"HashtagSearch.SearchPlaceholder" = "Hashtag search"; + +"HashtagSearch.NoRecentQueries" = "Enter a hashtag to find messages\ncontaining it."; +"HashtagSearch.ClearRecent" = "Clear History"; + +"HashtagSearch.NoResults" = "No Results"; +"HashtagSearch.NoResultsQueryDescription" = "There were no results for %@.\nTry another hashtag."; "Chat.Context.Phone.AddToContacts" = "Add to Contacts"; "Chat.Context.Phone.CreateNewContact" = "Create New Contact"; @@ -12233,3 +12240,5 @@ Sorry for the inconvenience."; "Stars.Purchase.ShowMore" = "Show More Options"; "Stars.Purchase.Info" = "By proceeding and purchasing Stars, you agree with [Terms and Conditions]()."; "Stars.Purchase.Terms_URL" = "https://telegram.org/tos"; + +"Settings.Stars" = "Your Stars"; diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index ae42cad445..bf4e60edfc 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -986,7 +986,10 @@ public protocol ChatController: ViewController { var visibleContextController: ViewController? { get } + var searching: ValuePromise { get } + var alwaysShowSearchResultsAsList: Bool { get set } + var includeSavedPeersInSearchResults: Bool { get set } func updatePresentationMode(_ mode: ChatControllerPresentationMode) func beginMessageSearch(_ query: String) @@ -1102,6 +1105,7 @@ public enum ChatQuickReplyShortcutType { public enum ChatCustomContentsKind: Equatable { case quickReplyMessageInput(shortcut: String, shortcutType: ChatQuickReplyShortcutType) case businessLinkSetup(link: TelegramBusinessChatLinks.Link) + case hashTagSearch } public protocol ChatCustomContentsProtocol: AnyObject { @@ -1115,6 +1119,11 @@ public protocol ChatCustomContentsProtocol: AnyObject { func quickReplyUpdateShortcut(value: String) func businessLinkUpdate(message: String, entities: [MessageTextEntity], title: String?) + + func loadMore() + + func hashtagSearchUpdate(query: String) + var hashtagSearchResultsUpdate: ((SearchMessagesResult, SearchMessagesState)) -> Void { get set } } public enum ChatHistoryListDisplayHeaders { diff --git a/submodules/DrawingUI/Sources/DrawingUtils.swift b/submodules/DrawingUI/Sources/DrawingUtils.swift index 1050beaafb..43f6047068 100644 --- a/submodules/DrawingUI/Sources/DrawingUtils.swift +++ b/submodules/DrawingUI/Sources/DrawingUtils.swift @@ -544,59 +544,3 @@ extension CATransform3D { return (t, r, s) } } - -public extension UIImage { - class func animatedImageFromData(data: Data) -> DrawingAnimatedImage? { - guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { - return nil - } - - let count = CGImageSourceGetCount(source) - var images = [UIImage]() - var duration = 0.0 - - for i in 0.. Double { - var delay = 0.0 - - let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) - let gifPropertiesPointer = UnsafeMutablePointer.allocate(capacity: 0) - if CFDictionaryGetValueIfPresent(cfProperties, Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque(), gifPropertiesPointer) == false { - return delay - } - - let gifProperties:CFDictionary = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self) - - var delayObject: AnyObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()), to: AnyObject.self) - if delayObject.doubleValue == 0 { - delayObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()), to: AnyObject.self) - } - - delay = delayObject as? Double ?? 0 - - return delay - } -} - -public final class DrawingAnimatedImage { - public let images: [UIImage] - public let duration: Double - - init(images: [UIImage], duration: Double) { - self.images = images - self.duration = duration - } -} diff --git a/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift b/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift index 5340d936df..e0c832a762 100644 --- a/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift +++ b/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift @@ -49,91 +49,64 @@ public final class HashtagSearchController: TelegramBaseController { self.title = query self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) - let location: SearchMessagesLocation = .general(scope: .everywhere, tags: nil, minDate: nil, maxDate: nil) - let search = context.engine.messages.searchMessages(location: location, query: query, state: nil) - let foundMessages: Signal<[ChatListSearchEntry], NoError> = combineLatest(search, self.context.sharedContext.presentationData) - |> map { result, presentationData in - let result = result.0 - let chatListPresentationData = ChatListPresentationData(theme: presentationData.theme, fontSize: presentationData.listsFontSize, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameSortOrder: presentationData.nameSortOrder, nameDisplayOrder: presentationData.nameDisplayOrder, disableAnimations: true) - return result.messages.map({ .message(EngineMessage($0), EngineRenderedPeer(message: EngineMessage($0)), result.readStates[$0.id.peerId].flatMap { EnginePeerReadCounters(state: $0, isMuted: false) }, nil, chatListPresentationData, result.totalCount, nil, false, .index($0.index), nil, .generic, false, nil, false) }) - } - let interaction = ChatListNodeInteraction(context: context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, activateSearch: { - }, peerSelected: { _, _, _, _ in - }, disabledPeerSelected: { _, _, _ in - }, togglePeerSelected: { _, _ in - }, togglePeersSelection: { _, _ in - }, additionalCategorySelected: { _ in - }, messageSelected: { [weak self] peer, _, message, _ in - if let strongSelf = self { - strongSelf.openMessageFromSearchDisposable.set((strongSelf.context.engine.peers.ensurePeerIsLocallyAvailable(peer: peer) |> deliverOnMainQueue).start(next: { actualPeer in - if let strongSelf = self, let navigationController = strongSelf.navigationController as? NavigationController { - strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(actualPeer), subject: message.id.peerId == actualPeer.id ? .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil) : nil, keepStack: .always)) - } - })) - strongSelf.controllerNode.listNode.clearHighlightAnimated(true) - } - }, groupSelected: { _ in - }, addContact: {_ in - }, setPeerIdWithRevealedOptions: { _, _ in - }, setItemPinned: { _, _ in - }, setPeerMuted: { _, _ in - }, setPeerThreadMuted: { _, _, _ in - }, deletePeer: { _, _ in - }, deletePeerThread: { _, _ in - }, setPeerThreadStopped: { _, _, _ in - }, setPeerThreadPinned: { _, _, _ in - }, setPeerThreadHidden: { _, _, _ in - }, updatePeerGrouping: { _, _ in - }, togglePeerMarkedUnread: { _, _ in - }, toggleArchivedFolderHiddenByDefault: { - }, toggleThreadsSelection: { _, _ in - }, hidePsa: { _ in - }, activateChatPreview: { _, _, _, gesture, _ in - gesture?.cancel() - }, present: { _ in - }, openForumThread: { _, _ in - }, openStorageManagement: { - }, openPasswordSetup: { - }, openPremiumIntro: { - }, openPremiumGift: { _ in - }, openPremiumManagement: { - }, openActiveSessions: { - }, openBirthdaySetup: { - }, performActiveSessionAction: { _, _ in - }, openChatFolderUpdates: { - }, hideChatFolderUpdates: { - }, openStories: { _, _ in - }, dismissNotice: { _ in - }, editPeer: { _ in - }) +// let location: SearchMessagesLocation = .general(scope: .everywhere, tags: nil, minDate: nil, maxDate: nil) +// let search = context.engine.messages.searchMessages(location: location, query: query, state: nil) +// let foundMessages: Signal<[ChatListSearchEntry], NoError> = combineLatest(search, self.context.sharedContext.presentationData) +// |> map { result, presentationData in +// let result = result.0 +// let chatListPresentationData = ChatListPresentationData(theme: presentationData.theme, fontSize: presentationData.listsFontSize, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameSortOrder: presentationData.nameSortOrder, nameDisplayOrder: presentationData.nameDisplayOrder, disableAnimations: true) +// return result.messages.map({ .message(EngineMessage($0), EngineRenderedPeer(message: EngineMessage($0)), result.readStates[$0.id.peerId].flatMap { EnginePeerReadCounters(state: $0, isMuted: false) }, nil, chatListPresentationData, result.totalCount, nil, false, .index($0.index), nil, .generic, false, nil, false) }) +// } - let previousSearchItems = Atomic<[ChatListSearchEntry]?>(value: nil) - self.transitionDisposable = (foundMessages - |> deliverOnMainQueue).start(next: { [weak self] entries in - if let strongSelf = self { - let previousEntries = previousSearchItems.swap(entries) +// let interaction = ChatListNodeInteraction(context: context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, activateSearch: { +// }, peerSelected: { _, _, _, _ in +// }, disabledPeerSelected: { _, _, _ in +// }, togglePeerSelected: { _, _ in +// }, togglePeersSelection: { _, _ in +// }, additionalCategorySelected: { _ in +// }, messageSelected: { [weak self] peer, _, message, _ in +// if let strongSelf = self { +// strongSelf.openMessageFromSearchDisposable.set((strongSelf.context.engine.peers.ensurePeerIsLocallyAvailable(peer: peer) |> deliverOnMainQueue).start(next: { actualPeer in +// if let strongSelf = self, let navigationController = strongSelf.navigationController as? NavigationController { +// strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(actualPeer), subject: message.id.peerId == actualPeer.id ? .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil) : nil, keepStack: .always)) +// } +// })) +// } +// }, groupSelected: { _ in +// }, addContact: {_ in +// }, setPeerIdWithRevealedOptions: { _, _ in +// }, setItemPinned: { _, _ in +// }, setPeerMuted: { _, _ in +// }, setPeerThreadMuted: { _, _, _ in +// }, deletePeer: { _, _ in +// }, deletePeerThread: { _, _ in +// }, setPeerThreadStopped: { _, _, _ in +// }, setPeerThreadPinned: { _, _, _ in +// }, setPeerThreadHidden: { _, _, _ in +// }, updatePeerGrouping: { _, _ in +// }, togglePeerMarkedUnread: { _, _ in +// }, toggleArchivedFolderHiddenByDefault: { +// }, toggleThreadsSelection: { _, _ in +// }, hidePsa: { _ in +// }, activateChatPreview: { _, _, _, gesture, _ in +// gesture?.cancel() +// }, present: { _ in +// }, openForumThread: { _, _ in +// }, openStorageManagement: { +// }, openPasswordSetup: { +// }, openPremiumIntro: { +// }, openPremiumGift: { _ in +// }, openPremiumManagement: { +// }, openActiveSessions: { +// }, openBirthdaySetup: { +// }, performActiveSessionAction: { _, _ in +// }, openChatFolderUpdates: { +// }, hideChatFolderUpdates: { +// }, openStories: { _, _ in +// }, dismissNotice: { _ in +// }, editPeer: { _ in +// }) - let listInteraction = ListMessageItemInteraction(openMessage: { message, mode -> Bool in - return true - }, openMessageContextMenu: { message, bool, node, rect, gesture in - }, toggleMessagesSelection: { messageId, selected in - }, openUrl: { url, _, _, message in - }, openInstantPage: { message, data in - }, longTap: { action, message in - }, getHiddenMedia: { - return [:] - }) - - let firstTime = previousEntries == nil - let transition = chatListSearchContainerPreparedTransition(from: previousEntries ?? [], to: entries, displayingResults: true, isEmpty: entries.isEmpty, isLoading: false, animated: false, context: strongSelf.context, presentationData: strongSelf.presentationData, enableHeaders: false, filter: [], requestPeerType: nil, location: .chatList(groupId: .root), key: .chats, tagMask: nil, interaction: interaction, listInteraction: listInteraction, peerContextAction: nil, toggleExpandLocalResults: { - }, toggleExpandGlobalResults: { - }, searchPeer: { _ in - }, searchQuery: "", searchOptions: nil, messageContextAction: nil, openClearRecentlyDownloaded: {}, toggleAllPaused: {}, openStories: { _, _ in - }) - strongSelf.controllerNode.enqueueTransition(transition, firstTime: firstTime) - } - }) - self.presentationDataDisposable = (self.context.sharedContext.presentationData |> deliverOnMainQueue).start(next: { [weak self] presentationData in if let strongSelf = self { @@ -158,23 +131,18 @@ public final class HashtagSearchController: TelegramBaseController { } deinit { - self.transitionDisposable?.dispose() self.presentationDataDisposable?.dispose() self.openMessageFromSearchDisposable.dispose() } override public func loadDisplayNode() { self.displayNode = HashtagSearchControllerNode(context: self.context, controller: self, peer: self.peer, query: self.query, navigationBar: self.navigationBar, navigationController: self.navigationController as? NavigationController) - if let chatController = self.controllerNode.chatController { + if let chatController = self.controllerNode.currentController { chatController.parentController = self } self.displayNodeDidLoad() } - - private var suspendNavigationBarLayout: Bool = false - private var suspendedNavigationBarLayout: ContainerViewLayout? - private var additionalNavigationBarBackgroundHeight: CGFloat = 0.0 private func updateThemeAndStrings() { self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style @@ -183,26 +151,10 @@ public final class HashtagSearchController: TelegramBaseController { self.controllerNode.updateThemeAndStrings(theme: self.presentationData.theme, strings: self.presentationData.strings) } - - override public func updateNavigationBarLayout(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { - if self.suspendNavigationBarLayout { - self.suspendedNavigationBarLayout = layout - return - } - self.applyNavigationBarLayout(layout, navigationLayout: self.navigationLayout(layout: layout), additionalBackgroundHeight: self.additionalNavigationBarBackgroundHeight, transition: transition) - } - override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { - self.suspendNavigationBarLayout = true - + public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) - self.additionalNavigationBarBackgroundHeight = self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) - - self.suspendNavigationBarLayout = false - if let suspendedNavigationBarLayout = self.suspendedNavigationBarLayout { - self.suspendedNavigationBarLayout = suspendedNavigationBarLayout - self.applyNavigationBarLayout(suspendedNavigationBarLayout, navigationLayout: self.navigationLayout(layout: layout), additionalBackgroundHeight: self.additionalNavigationBarBackgroundHeight, transition: transition) - } + let _ = self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.cleanNavigationHeight, transition: transition) } } diff --git a/submodules/HashtagSearchUI/Sources/HashtagSearchControllerNode.swift b/submodules/HashtagSearchUI/Sources/HashtagSearchControllerNode.swift index 7bf1f75596..e1fc79ae27 100644 --- a/submodules/HashtagSearchUI/Sources/HashtagSearchControllerNode.swift +++ b/submodules/HashtagSearchUI/Sources/HashtagSearchControllerNode.swift @@ -1,6 +1,7 @@ import Display import UIKit import AsyncDisplayKit +import SwiftSignalKit import TelegramCore import TelegramPresentationData import AccountContext @@ -11,18 +12,28 @@ import ChatListSearchItemHeader final class HashtagSearchControllerNode: ASDisplayNode { private let context: AccountContext private weak var controller: HashtagSearchController? - private let query: String + private var query: String + + private let searchQueryPromise = ValuePromise() + private var searchQueryDisposable: Disposable? private let navigationBar: NavigationBar? - private let segmentedControlNode: SegmentedControlNode - let listNode: ListView - let shimmerNode: ChatListSearchShimmerNode + private let searchContentNode: HashtagSearchNavigationContentNode + private let shimmerNode: ChatListSearchShimmerNode + private let recentListNode: HashtagSearchRecentListNode - let chatController: ChatController? + private let isSearching = Promise() + private var isSearchingDisposable: Disposable? + + let currentController: ChatController? + let myController: ChatController? + let myChatContents: HashtagSearchGlobalChatContents? + + let globalController: ChatController? + let globalChatContents: HashtagSearchGlobalChatContents? private var containerLayout: (ContainerViewLayout, CGFloat)? - private var enqueuedTransitions: [(ChatListSearchContainerTransition, Bool)] = [] private var hasValidLayout = false init(context: AccountContext, controller: HashtagSearchController, peer: EnginePeer?, query: String, navigationBar: NavigationBar?, navigationController: NavigationController?) { @@ -33,32 +44,40 @@ final class HashtagSearchControllerNode: ASDisplayNode { let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let cleanHashtag = query.replacingOccurrences(of: "#", with: "") + self.searchContentNode = HashtagSearchNavigationContentNode(theme: presentationData.theme, strings: presentationData.strings, initialQuery: cleanHashtag, cancel: { [weak controller] in + controller?.dismiss() + }) + self.shimmerNode = ChatListSearchShimmerNode(key: .chats) self.shimmerNode.isUserInteractionEnabled = false self.shimmerNode.allowsGroupOpacity = true - self.listNode = ListView() - self.listNode.accessibilityPageScrolledString = { row, count in - return presentationData.strings.VoiceOver_ScrollStatus(row, count).string - } - - var items: [String] = [] - if peer?.id == context.account.peerId { - items.append(presentationData.strings.Conversation_SavedMessages) - } else if let id = peer?.id, id.isReplies { - items.append(presentationData.strings.DialogList_Replies) + self.recentListNode = HashtagSearchRecentListNode(context: context) + + let navigationController = controller.navigationController as? NavigationController + if let peer { + self.currentController = context.sharedContext.makeChatController(context: context, chatLocation: .peer(id: peer.id), subject: nil, botStart: nil, mode: .inline(navigationController)) + self.currentController?.alwaysShowSearchResultsAsList = true + self.currentController?.customNavigationController = navigationController } else { - items.append(peer?.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) ?? "") + self.currentController = nil } - items.append(presentationData.strings.HashtagSearch_AllChats) - self.segmentedControlNode = SegmentedControlNode(theme: SegmentedControlTheme(theme: presentationData.theme), items: items.map { SegmentedControlItem(title: $0) }, selectedIndex: controller.all ? 1 : 0) - if let peer = peer { - self.chatController = context.sharedContext.makeChatController(context: context, chatLocation: .peer(id: peer.id), subject: nil, botStart: nil, mode: .inline(navigationController)) - } else { - self.chatController = nil - } - + self.isSearching.set(self.currentController?.searching.get() ?? .single(false)) + + let myChatContents = HashtagSearchGlobalChatContents(context: context, kind: .hashTagSearch, query: cleanHashtag, onlyMy: true) + self.myChatContents = myChatContents + self.myController = context.sharedContext.makeChatController(context: context, chatLocation: .customChatContents, subject: .customChatContents(contents: myChatContents), botStart: nil, mode: .standard(.default)) + self.myController?.alwaysShowSearchResultsAsList = true + self.myController?.customNavigationController = navigationController + + let globalChatContents = HashtagSearchGlobalChatContents(context: context, kind: .hashTagSearch, query: cleanHashtag, onlyMy: false) + self.globalChatContents = globalChatContents + self.globalController = context.sharedContext.makeChatController(context: context, chatLocation: .customChatContents, subject: .customChatContents(contents: globalChatContents), botStart: nil, mode: .standard(.default)) + self.globalController?.alwaysShowSearchResultsAsList = true + self.globalController?.customNavigationController = navigationController + super.init() self.setViewBlock({ @@ -66,135 +85,206 @@ final class HashtagSearchControllerNode: ASDisplayNode { }) self.backgroundColor = presentationData.theme.chatList.backgroundColor - - self.addSubnode(self.listNode) -// self.addSubnode(self.shimmerNode) - + if controller.all { - self.chatController?.displayNode.isHidden = true - self.listNode.isHidden = false + self.currentController?.displayNode.isHidden = true } else { - self.chatController?.displayNode.isHidden = false - self.listNode.isHidden = true + self.currentController?.displayNode.isHidden = false + self.myController?.displayNode.isHidden = true + self.globalController?.displayNode.isHidden = true } - self.segmentedControlNode.selectedIndexChanged = { [weak self] index in - if let strongSelf = self { - if index == 0 { - strongSelf.chatController?.displayNode.isHidden = false - strongSelf.listNode.isHidden = true - } else { - strongSelf.chatController?.displayNode.isHidden = true - strongSelf.listNode.isHidden = false - } + self.searchContentNode.indexUpdated = { [weak self] index in + guard let self else { + return + } + self.searchContentNode.selectedIndex = index + if index == 0 { + self.currentController?.displayNode.isHidden = false + self.myController?.displayNode.isHidden = true + self.globalController?.displayNode.isHidden = true + self.isSearching.set(self.currentController?.searching.get() ?? .single(false)) + } else if index == 1 { + self.currentController?.displayNode.isHidden = true + self.myController?.displayNode.isHidden = false + self.globalController?.displayNode.isHidden = true + self.isSearching.set(self.myChatContents?.searching ?? .single(false)) + } else if index == 2 { + self.currentController?.displayNode.isHidden = true + self.myController?.displayNode.isHidden = true + self.globalController?.displayNode.isHidden = false + self.isSearching.set(self.globalChatContents?.searching ?? .single(false)) } } + + self.recentListNode.setSearchQuery = { [weak self] query in + guard let self else { + return + } + self.searchContentNode.query = query + self.updateSearchQuery(query) + } - self.chatController?.isSelectingMessagesUpdated = { [weak self] isSelecting in + self.currentController?.isSelectingMessagesUpdated = { [weak self] isSelecting in if let strongSelf = self { let button: UIBarButtonItem? = isSelecting ? UIBarButtonItem(title: presentationData.strings.Common_Cancel, style: .done, target: self, action: #selector(strongSelf.cancelPressed)) : nil strongSelf.controller?.navigationItem.setRightBarButton(button, animated: true) } } - } - - @objc private func cancelPressed() { - self.chatController?.cancelSelectingMessages() - } - - func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { - self.backgroundColor = theme.chatList.backgroundColor - self.segmentedControlNode.updateTheme(SegmentedControlTheme(theme: theme)) + navigationBar?.setContentNode(self.searchContentNode, animated: false) - self.listNode.forEachItemHeaderNode({ itemHeaderNode in - if let itemHeaderNode = itemHeaderNode as? ChatListSearchItemHeaderNode { - itemHeaderNode.updateTheme(theme: theme) + self.addSubnode(self.shimmerNode) + + self.searchContentNode.setQueryUpdated { [weak self] query in + self?.searchQueryPromise.set(query) + } + + let _ = addRecentHashtagSearchQuery(engine: context.engine, string: query).startStandalone() + self.searchContentNode.onReturn = { query in + let _ = addRecentHashtagSearchQuery(engine: context.engine, string: query).startStandalone() + } + + let throttledSearchQuery = self.searchQueryPromise.get() + |> mapToSignal { query -> Signal in + if !query.isEmpty { + return (.complete() |> delay(1.0, queue: Queue.mainQueue())) + |> then(.single(query)) + } else { + return .single(query) + } + } + + self.searchQueryDisposable = (throttledSearchQuery + |> deliverOnMainQueue).start(next: { [weak self] query in + if let self { + self.updateSearchQuery(query) + } + }) + + self.isSearchingDisposable = (self.isSearching.get() + |> deliverOnMainQueue).start(next: { [weak self] isSearching in + if let self { + self.searchContentNode.isSearching = isSearching + let transition: ContainedViewLayoutTransition = isSearching ? .immediate : .animated(duration: 0.2, curve: .easeInOut) + transition.updateAlpha(node: self.shimmerNode, alpha: isSearching ? 1.0 : 0.0) } }) } - func enqueueTransition(_ transition: ChatListSearchContainerTransition, firstTime: Bool) { - self.enqueuedTransitions.append((transition, firstTime)) + deinit { + self.searchQueryDisposable?.dispose() + self.isSearchingDisposable?.dispose() + } + + func updateSearchQuery(_ query: String) { + self.query = query - if self.hasValidLayout { - while !self.enqueuedTransitions.isEmpty { - self.dequeueTransition() - } + var cleanQuery = query + if cleanQuery.hasPrefix("#") { + cleanQuery.removeFirst() + } + if !cleanQuery.isEmpty { + self.currentController?.beginMessageSearch("#" + cleanQuery) + + self.myChatContents?.hashtagSearchUpdate(query: cleanQuery) + self.myController?.beginMessageSearch("#" + cleanQuery) + + self.globalChatContents?.hashtagSearchUpdate(query: cleanQuery) + self.globalController?.beginMessageSearch("#" + cleanQuery) + } + + if let (layout, navigationHeight) = self.containerLayout { + let _ = self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .immediate) } } - private func dequeueTransition() { - if let (transition, _) = self.enqueuedTransitions.first { - self.enqueuedTransitions.remove(at: 0) - - let options = ListViewDeleteAndInsertOptions() - self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { _ in }) - } + @objc private func cancelPressed() { + self.currentController?.cancelSelectingMessages() + } + + func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { + self.backgroundColor = theme.chatList.backgroundColor + self.searchContentNode.updateTheme(theme) } func scrollToTop() { - if self.segmentedControlNode.selectedIndex == 0 { - self.chatController?.scrollToTop?() + if self.searchContentNode.selectedIndex == 0 { + self.currentController?.scrollToTop?() + } else if self.searchContentNode.selectedIndex == 2 { + self.globalController?.scrollToTop?() } else { - 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 }) + self.myController?.scrollToTop?() } } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + let isFirstTime = self.containerLayout == nil self.containerLayout = (layout, navigationBarHeight) - - if self.chatController != nil && self.segmentedControlNode.supernode == nil { - self.navigationBar?.additionalContentNode.addSubnode(self.segmentedControlNode) - } - + var insets = layout.insets(options: [.input]) insets.top += navigationBarHeight let toolbarHeight: CGFloat = 40.0 - let panelY: CGFloat = insets.top - UIScreenPixel - 4.0 - - let controlSize = self.segmentedControlNode.updateLayout(.stretchToFill(width: layout.size.width - 14.0 * 2.0), transition: transition) - transition.updateFrame(node: self.segmentedControlNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - controlSize.width) / 2.0), y: panelY + 2.0 + floor((toolbarHeight - controlSize.height) / 2.0)), size: controlSize)) - - if let chatController = self.chatController { - insets.top += toolbarHeight - 4.0 - let chatSize = CGSize(width: layout.size.width, height: layout.size.height) - transition.updateFrame(node: chatController.displayNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: chatSize)) - chatController.containerLayoutUpdated(ContainerViewLayout(size: chatSize, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: insets.top, left: 0.0, bottom: layout.intrinsicInsets.bottom, right: 0.0), safeInsets: layout.safeInsets, additionalInsets: layout.additionalInsets, statusBarHeight: nil, inputHeight: nil, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: .immediate) - if chatController.displayNode.supernode == nil { - chatController.viewWillAppear(false) - self.insertSubnode(chatController.displayNode, at: 0) - chatController.viewDidAppear(false) + insets.top += toolbarHeight - 4.0 + if let controller = self.currentController { + transition.updateFrame(node: controller.displayNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: layout.size)) + controller.containerLayoutUpdated(ContainerViewLayout(size: layout.size, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: insets.top - 79.0, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right), safeInsets: layout.safeInsets, additionalInsets: layout.additionalInsets, statusBarHeight: nil, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: transition) + + if controller.displayNode.supernode == nil { + controller.viewWillAppear(false) + self.insertSubnode(controller.displayNode, at: 0) + controller.viewDidAppear(false) - chatController.beginMessageSearch(self.query) + controller.beginMessageSearch(self.query) } } - 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) + if let controller = self.myController { + transition.updateFrame(node: controller.displayNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: layout.size)) + controller.containerLayoutUpdated(ContainerViewLayout(size: layout.size, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: insets.top - 89.0, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right), safeInsets: layout.safeInsets, additionalInsets: layout.additionalInsets, statusBarHeight: nil, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: transition) + + if controller.displayNode.supernode == nil { + controller.viewWillAppear(false) + self.insertSubnode(controller.displayNode, at: 0) + controller.viewDidAppear(false) + + controller.beginMessageSearch(self.query) + } + } + + if let controller = self.globalController { + transition.updateFrame(node: controller.displayNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: layout.size)) + controller.containerLayoutUpdated(ContainerViewLayout(size: layout.size, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: insets.top - 89.0, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right), safeInsets: layout.safeInsets, additionalInsets: layout.additionalInsets, statusBarHeight: nil, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: transition) + + if controller.displayNode.supernode == nil { + controller.viewWillAppear(false) + self.insertSubnode(controller.displayNode, at: 0) + controller.viewDidAppear(false) + + controller.beginMessageSearch(self.query) + } + } let overflowInset: CGFloat = 0.0 let topInset = navigationBarHeight self.shimmerNode.frame = CGRect(origin: CGPoint(x: overflowInset, y: topInset), size: CGSize(width: layout.size.width - overflowInset * 2.0, height: layout.size.height)) self.shimmerNode.update(context: self.context, size: CGSize(width: layout.size.width - overflowInset * 2.0, height: layout.size.height), presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, animationCache: self.context.animationCache, animationRenderer: self.context.animationRenderer, key: .chats, hasSelection: false, transition: transition) - insets.top += 4.0 + if isFirstTime { + self.insertSubnode(self.recentListNode, aboveSubnode: self.shimmerNode) + } - let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) - let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: curve) - - self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + self.recentListNode.frame = CGRect(origin: .zero, size: layout.size) + self.recentListNode.updateLayout(layout: ContainerViewLayout(size: layout.size, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, intrinsicInsets: UIEdgeInsets(top: insets.top - 35.0, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right), safeInsets: layout.safeInsets, additionalInsets: layout.additionalInsets, statusBarHeight: nil, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: false, inVoiceOver: false), transition: transition) + self.recentListNode.isHidden = !self.query.isEmpty if !self.hasValidLayout { self.hasValidLayout = true - while !self.enqueuedTransitions.isEmpty { - self.dequeueTransition() - } } - if self.chatController != nil { + if self.currentController != nil { return toolbarHeight } else { return 0.0 diff --git a/submodules/HashtagSearchUI/Sources/HashtagSearchGlobalChatContents.swift b/submodules/HashtagSearchUI/Sources/HashtagSearchGlobalChatContents.swift new file mode 100644 index 0000000000..f7acade603 --- /dev/null +++ b/submodules/HashtagSearchUI/Sources/HashtagSearchGlobalChatContents.swift @@ -0,0 +1,217 @@ +import Foundation +import UIKit +import SwiftSignalKit +import Postbox +import TelegramCore +import AccountContext + +final class HashtagSearchGlobalChatContents: ChatCustomContentsProtocol { + private final class Impl { + let queue: Queue + let context: AccountContext + + fileprivate var query: String { + didSet { + if self.query != oldValue { + self.updateHistoryViewRequest(reload: true) + } + } + } + private let onlyMy: Bool + private var currentSearchState: SearchMessagesState? + + private(set) var mergedHistoryView: MessageHistoryView? + private var sourceHistoryView: MessageHistoryView? + + private var historyViewDisposable: Disposable? + let historyViewStream = ValuePipe<(MessageHistoryView, ViewUpdateType)>() + private var nextUpdateIsHoleFill: Bool = false + + var hashtagSearchResultsUpdate: ((SearchMessagesResult, SearchMessagesState)) -> Void = { _ in } + + let isSearchingPromise = ValuePromise(true) + + init(queue: Queue, context: AccountContext, query: String, onlyMy: Bool) { + self.queue = queue + self.context = context + self.query = query + self.onlyMy = onlyMy + + self.updateHistoryViewRequest(reload: false) + } + + deinit { + self.historyViewDisposable?.dispose() + } + + private func updateHistoryViewRequest(reload: Bool) { + guard self.historyViewDisposable == nil || reload else { + return + } + self.historyViewDisposable?.dispose() + + let search: Signal<(SearchMessagesResult, SearchMessagesState), NoError> + if self.onlyMy { + search = self.context.engine.messages.searchMessages(location: .general(scope: .everywhere, tags: nil, minDate: nil, maxDate: nil), query: "#\(self.query)", state: nil) + } else { + search = self.context.engine.messages.searchHashtagPosts(hashtag: self.query, state: nil) + } + + self.isSearchingPromise.set(true) + self.historyViewDisposable = (search + |> deliverOn(self.queue)).start(next: { [weak self] result in + guard let self else { + return + } + + let updateType: ViewUpdateType = .Initial + + let historyView = MessageHistoryView(tag: nil, namespaces: .just(Set([Namespaces.Message.Cloud])), entries: result.0.messages.reversed().map { MessageHistoryEntry(message: $0, isRead: false, location: nil, monthLocation: nil, attributes: MutableMessageHistoryEntryAttributes(authorIsContact: false)) }, holeEarlier: false, holeLater: false, isLoading: false) + self.sourceHistoryView = historyView + self.updateHistoryView(updateType: updateType) + + Queue.mainQueue().async { + self.currentSearchState = result.1 + + self.hashtagSearchResultsUpdate(result) + } + + self.historyViewDisposable?.dispose() + self.historyViewDisposable = nil + + self.isSearchingPromise.set(false) + }) + } + + private func updateHistoryView(updateType: ViewUpdateType) { + var entries = self.sourceHistoryView?.entries ?? [] + entries.sort(by: { $0.message.index < $1.message.index }) + + let mergedHistoryView = MessageHistoryView(tag: nil, namespaces: .just(Set([Namespaces.Message.Cloud])), entries: entries, holeEarlier: false, holeLater: false, isLoading: false) + self.mergedHistoryView = mergedHistoryView + + self.historyViewStream.putNext((mergedHistoryView, updateType)) + } + + func loadMore() { + guard self.historyViewDisposable == nil, let currentSearchState = self.currentSearchState else { + return + } + + let search: Signal<(SearchMessagesResult, SearchMessagesState), NoError> + if self.onlyMy { + search = self.context.engine.messages.searchMessages(location: .general(scope: .everywhere, tags: nil, minDate: nil, maxDate: nil), query: "#\(self.query)", state: currentSearchState) + } else { + search = self.context.engine.messages.searchHashtagPosts(hashtag: self.query, state: self.currentSearchState) + } + + self.historyViewDisposable?.dispose() + self.historyViewDisposable = (search + |> deliverOn(self.queue)).startStrict(next: { [weak self] result in + guard let self else { + return + } + + let updateType: ViewUpdateType = .FillHole + + let historyView = MessageHistoryView(tag: nil, namespaces: .just(Set([Namespaces.Message.Cloud])), entries: result.0.messages.reversed().map { MessageHistoryEntry(message: $0, isRead: false, location: nil, monthLocation: nil, attributes: MutableMessageHistoryEntryAttributes(authorIsContact: false)) }, holeEarlier: false, holeLater: false, isLoading: false) + self.sourceHistoryView = historyView + + self.updateHistoryView(updateType: updateType) + + Queue.mainQueue().async { + self.currentSearchState = result.1 + + self.hashtagSearchResultsUpdate(result) + } + + self.historyViewDisposable?.dispose() + self.historyViewDisposable = nil + }) + } + + func enqueueMessages(messages: [EnqueueMessage]) { + } + + func deleteMessages(ids: [EngineMessage.Id]) { + + } + + func editMessage(id: EngineMessage.Id, text: String, media: RequestEditMessageMedia, entities: TextEntitiesMessageAttribute?, webpagePreviewAttribute: WebpagePreviewMessageAttribute?, disableUrlPreview: Bool) { + } + } + + var kind: ChatCustomContentsKind + + var historyView: Signal<(MessageHistoryView, ViewUpdateType), NoError> { + return self.impl.signalWith({ impl, subscriber in + if let mergedHistoryView = impl.mergedHistoryView { + subscriber.putNext((mergedHistoryView, .Initial)) + } + return impl.historyViewStream.signal().start(next: subscriber.putNext) + }) + } + + var searching: Signal { + return self.impl.signalWith({ impl, subscriber in + return impl.isSearchingPromise.get().start(next: subscriber.putNext) + }) + } + + var messageLimit: Int? { + return nil + } + + private let queue: Queue + private let impl: QueueLocalObject + + init(context: AccountContext, kind: ChatCustomContentsKind, query: String, onlyMy: Bool) { + self.kind = kind + + let queue = Queue() + self.queue = queue + self.impl = QueueLocalObject(queue: queue, generate: { + return Impl(queue: queue, context: context, query: query, onlyMy: onlyMy) + }) + } + + func enqueueMessages(messages: [EnqueueMessage]) { + + } + + func deleteMessages(ids: [EngineMessage.Id]) { + + } + + func editMessage(id: EngineMessage.Id, text: String, media: RequestEditMessageMedia, entities: TextEntitiesMessageAttribute?, webpagePreviewAttribute: WebpagePreviewMessageAttribute?, disableUrlPreview: Bool) { + + } + + func quickReplyUpdateShortcut(value: String) { + + } + + func businessLinkUpdate(message: String, entities: [TelegramCore.MessageTextEntity], title: String?) { + + } + + func loadMore() { + self.impl.with { impl in + impl.loadMore() + } + } + + var hashtagSearchResultsUpdate: ((SearchMessagesResult, SearchMessagesState)) -> Void = { _ in } { + didSet { + self.impl.with { impl in + impl.hashtagSearchResultsUpdate = self.hashtagSearchResultsUpdate + } + } + } + + func hashtagSearchUpdate(query: String) { + self.impl.with { impl in + impl.query = query + } + } +} diff --git a/submodules/HashtagSearchUI/Sources/HashtagSearchNavigationContentNode.swift b/submodules/HashtagSearchUI/Sources/HashtagSearchNavigationContentNode.swift new file mode 100644 index 0000000000..151e77629d --- /dev/null +++ b/submodules/HashtagSearchUI/Sources/HashtagSearchNavigationContentNode.swift @@ -0,0 +1,149 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import TelegramPresentationData +import SearchBarNode +import ComponentFlow +import ComponentDisplayAdapters +import TabSelectorComponent + +private let searchBarFont = Font.regular(17.0) + +final class HashtagSearchNavigationContentNode: NavigationBarContentNode { + private var theme: PresentationTheme + private let strings: PresentationStrings + + private let cancel: () -> Void + + var onReturn: (String) -> Void = { _ in } + + private let searchBar: SearchBarNode + private let tabSelector = ComponentView() + + private var queryUpdated: ((String) -> Void)? + var indexUpdated: ((Int) -> Void)? + + var selectedIndex: Int = 0 { + didSet { + if let (size, leftInset, rightInset) = self.validLayout { + self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, transition: .animated(duration: 0.35, curve: .spring)) + } + } + } + + var isSearching: Bool = false { + didSet { + self.searchBar.activity = self.isSearching + } + } + + var query: String { + get { + return self.searchBar.text + } + set { + self.searchBar.text = newValue + } + } + + init(theme: PresentationTheme, strings: PresentationStrings, initialQuery: String, cancel: @escaping () -> Void) { + self.theme = theme + self.strings = strings + + self.cancel = cancel + + self.searchBar = SearchBarNode(theme: SearchBarNodeTheme(theme: theme, hasSeparator: false), strings: strings, fieldStyle: .modern, icon: .hashtag, displayBackground: false) + self.searchBar.text = initialQuery + self.searchBar.placeholderString = NSAttributedString(string: strings.HashtagSearch_SearchPlaceholder, font: searchBarFont, textColor: theme.rootController.navigationSearchBar.inputPlaceholderTextColor) + + 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.textReturned = { [weak self] query in + self?.onReturn(query) + } + } + + func updateTheme(_ theme: PresentationTheme) { + self.theme = theme + self.searchBar.updateThemeAndStrings(theme: SearchBarNodeTheme(theme: theme, hasSeparator: false), strings: self.strings) + } + + func setQueryUpdated(_ f: @escaping (String) -> Void) { + self.queryUpdated = f + } + + override var nominalHeight: CGFloat { + return 54.0 + 44.0 + } + + private var validLayout: (CGSize, CGFloat, CGFloat)? + + override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) { + self.validLayout = (size, leftInset, rightInset) + + let sideInset: CGFloat = 6.0 + + let searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - self.nominalHeight + 5.0), size: CGSize(width: size.width, height: 54.0)) + self.searchBar.frame = searchBarFrame + self.searchBar.updateLayout(boundingSize: searchBarFrame.size, leftInset: leftInset + sideInset, rightInset: rightInset + sideInset, transition: transition) + + let tabSelectorSize = self.tabSelector.update( + transition: Transition(transition), + component: AnyComponent(TabSelectorComponent( + colors: TabSelectorComponent.Colors( + foreground: self.theme.list.itemSecondaryTextColor, + selection: self.theme.list.itemAccentColor + ), + customLayout: TabSelectorComponent.CustomLayout( + font: Font.medium(14.0), + spacing: 24.0, + lineSelection: true + ), + items: [ + TabSelectorComponent.Item(id: AnyHashable(0), title: self.strings.HashtagSearch_ThisChat), + TabSelectorComponent.Item(id: AnyHashable(1), title: self.strings.HashtagSearch_MyMessages), + TabSelectorComponent.Item(id: AnyHashable(2), title: self.strings.HashtagSearch_PublicPosts) + ], + selectedId: AnyHashable(self.selectedIndex), + setSelectedId: { [weak self] id in + guard let self, let index = id.base as? Int else { + return + } + self.indexUpdated?(index) + }, + transitionFraction: 0.0 + )), + environment: {}, + containerSize: CGSize(width: size.width, height: 44.0) + ) + let tabSelectorFrame = CGRect(origin: CGPoint(x: floor((size.width - tabSelectorSize.width) / 2.0), y: size.height - tabSelectorSize.height - 9.0), size: tabSelectorSize) + if let tabSelectorView = self.tabSelector.view { + if tabSelectorView.superview == nil { + self.view.addSubview(tabSelectorView) + } + transition.updateFrame(view: tabSelectorView, frame: tabSelectorFrame) + } + } + + func activate() { + self.searchBar.activate() + } + + func deactivate() { + self.searchBar.deactivate(clear: false) + } +} diff --git a/submodules/HashtagSearchUI/Sources/HashtagSearchRecentListNode.swift b/submodules/HashtagSearchUI/Sources/HashtagSearchRecentListNode.swift new file mode 100644 index 0000000000..4ce0ebb0cd --- /dev/null +++ b/submodules/HashtagSearchUI/Sources/HashtagSearchRecentListNode.swift @@ -0,0 +1,510 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import SwiftSignalKit +import Display +import TelegramCore +import TelegramPresentationData +import ItemListUI +import MergeLists +import AccountContext + +final class HashtagSearchInteraction { + let setSearchQuery: (String) -> Void + let deleteRecentQuery: (String) -> Void + let clearRecentQueries: () -> Void + + init(setSearchQuery: @escaping (String) -> Void, deleteRecentQuery: @escaping (String) -> Void, clearRecentQueries: @escaping () -> Void) { + self.setSearchQuery = setSearchQuery + self.deleteRecentQuery = deleteRecentQuery + self.clearRecentQueries = clearRecentQueries + } +} + +private enum HashtagSearchRecentQueryStableId: Hashable { + case query(String) + case clear +} + +private enum HashtagSearchRecentQueryEntry: Comparable, Identifiable { + case query(index: Int, text: String) + case clear + + var stableId: HashtagSearchRecentQueryStableId { + switch self { + case let .query(_, text): + return .query(text) + case .clear: + return .clear + } + } + + static func ==(lhs: HashtagSearchRecentQueryEntry, rhs: HashtagSearchRecentQueryEntry) -> Bool { + switch lhs { + case let .query(lhsIndex, lhsText): + if case let .query(rhsIndex, rhsText) = rhs { + return lhsIndex == rhsIndex && lhsText == rhsText + } else { + return false + } + case .clear: + if case .clear = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: HashtagSearchRecentQueryEntry, rhs: HashtagSearchRecentQueryEntry) -> Bool { + switch lhs { + case let .query(lhsIndex, _): + switch rhs { + case let .query(rhsIndex, _): + return lhsIndex < rhsIndex + case .clear: + return true + } + case .clear: + switch rhs { + case .query: + return false + case .clear: + return true + } + } + } + + func item(account: Account, theme: PresentationTheme, strings: PresentationStrings, interaction: HashtagSearchInteraction) -> ListViewItem { + var isClear = false + let text: String + switch self { + case let .query(_, value): + text = value + case .clear: + isClear = true + text = strings.HashtagSearch_ClearRecent + } + return HashtagSearchRecentQueryItem(account: account, theme: theme, strings: strings, query: text, clear: isClear, tapped: { query in + if isClear { + interaction.clearRecentQueries() + } else { + interaction.setSearchQuery(text) + } + }, deleted: { query in + interaction.deleteRecentQuery(query) + }) + } +} + +private struct HashtagSearchRecentTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] + let isEmpty: Bool +} + +private func preparedHashtagSearchRecentTransition(from fromEntries: [HashtagSearchRecentQueryEntry], to toEntries: [HashtagSearchRecentQueryEntry], account: Account, theme: PresentationTheme, strings: PresentationStrings, interaction: HashtagSearchInteraction) -> HashtagSearchRecentTransition { + 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(account: account, theme: theme, strings: strings, interaction: interaction), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, theme: theme, strings: strings, interaction: interaction), directionHint: nil) } + + return HashtagSearchRecentTransition(deletions: deletions, insertions: insertions, updates: updates, isEmpty: toEntries.isEmpty) +} + +private enum RevealOptionKey: Int32 { + case delete +} + +public class HashtagSearchRecentQueryItem: ListViewItem { + let theme: PresentationTheme + let strings: PresentationStrings + let account: Account + let query: String + let clear: Bool + let tapped: (String) -> Void + let deleted: (String) -> Void + + let header: ListViewItemHeader? = nil + + public init(account: Account, theme: PresentationTheme, strings: PresentationStrings, query: String, clear: Bool, tapped: @escaping (String) -> Void, deleted: @escaping (String) -> Void) { + self.theme = theme + self.strings = strings + self.account = account + self.query = query + self.clear = clear + self.tapped = tapped + self.deleted = deleted + } + + public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + async { + let node = HashtagSearchRecentQueryItemNode() + let makeLayout = node.asyncLayout() + let (nodeLayout, nodeApply) = makeLayout(self, params, nextItem == nil, !(previousItem is HashtagSearchRecentQueryItem)) + node.contentSize = nodeLayout.contentSize + node.insets = nodeLayout.insets + + completion(node, nodeApply) + } + } + + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + if let nodeValue = node() as? HashtagSearchRecentQueryItemNode { + let layout = nodeValue.asyncLayout() + async { + let (nodeLayout, apply) = layout(self, params, nextItem == nil, !(previousItem is HashtagSearchRecentQueryItem)) + Queue.mainQueue().async { + completion(nodeLayout, { info in + apply().1(info) + }) + } + } + } + } + } + + public var selectable: Bool { + return true + } + + public func selected(listView: ListView) { + listView.clearHighlightAnimated(true) + self.tapped(self.query) + } +} + +final class HashtagSearchRecentQueryItemNode: ItemListRevealOptionsItemNode { + private let backgroundNode: ASDisplayNode + private let separatorNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + private var textNode: TextNode? + private let iconNode: ASImageNode + + private var item: HashtagSearchRecentQueryItem? + private var layoutParams: ListViewItemLayoutParams? + + required init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + + self.separatorNode = ASDisplayNode() + self.separatorNode.isLayerBacked = true + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.isLayerBacked = true + + self.iconNode = ASImageNode() + self.iconNode.displaysAsynchronously = false + + super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false) + + self.addSubnode(self.backgroundNode) + self.addSubnode(self.separatorNode) + self.addSubnode(self.iconNode) + } + + override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + if let item = self.item { + let makeLayout = self.asyncLayout() + let (nodeLayout, nodeApply) = makeLayout(item, params, nextItem == nil, previousItem == nil) + self.contentSize = nodeLayout.contentSize + self.insets = nodeLayout.insets + let _ = nodeApply() + } + } + + override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { + super.setHighlighted(highlighted, at: point, animated: animated) + + if highlighted { + self.highlightedBackgroundNode.alpha = 1.0 + if self.highlightedBackgroundNode.supernode == nil { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode) + } + } else { + if self.highlightedBackgroundNode.supernode != nil { + if animated { + self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in + if let strongSelf = self { + if completed { + strongSelf.highlightedBackgroundNode.removeFromSupernode() + } + } + }) + self.highlightedBackgroundNode.alpha = 0.0 + } else { + self.highlightedBackgroundNode.removeFromSupernode() + } + } + } + } + + func asyncLayout() -> (_ item: HashtagSearchRecentQueryItem, _ params: ListViewItemLayoutParams, _ last: Bool, _ firstWithHeader: Bool) -> (ListViewItemNodeLayout, () -> (Signal?, (ListViewItemApply) -> Void)) { + let currentItem = self.item + + let textLayout = TextNode.asyncLayout(self.textNode) + + return { [weak self] item, params, last, firstWithHeader in + + let leftInset: CGFloat = 62.0 + params.leftInset + let rightInset: CGFloat = params.rightInset + + let attributedString = NSAttributedString(string: item.query, font: Font.regular(17.0), textColor: item.clear ? item.theme.list.itemAccentColor : item.theme.list.itemPrimaryTextColor) + let textApply = textLayout(TextNodeLayoutArguments(attributedString: attributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - 15.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + + let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: 44.0), insets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0)) + + return (nodeLayout, { [weak self] in + var updatedTheme: PresentationTheme? + if currentItem?.theme !== item.theme { + updatedTheme = item.theme + } + + return (nil, { _ in + if let strongSelf = self { + strongSelf.item = item + strongSelf.layoutParams = params + + if let _ = updatedTheme { + strongSelf.separatorNode.backgroundColor = item.theme.list.itemPlainSeparatorColor + strongSelf.backgroundNode.backgroundColor = item.theme.list.plainBackgroundColor + strongSelf.highlightedBackgroundNode.backgroundColor = item.theme.list.itemHighlightedBackgroundColor + if item.clear { + strongSelf.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Hashtag/ClearRecent"), color: item.theme.list.itemAccentColor) + } else { + strongSelf.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Hashtag/RecentHashtag"), color: item.theme.list.itemSecondaryTextColor) + } + } + + let (textLayout, textApply) = textApply + let textNode = textApply() + if strongSelf.textNode == nil { + strongSelf.textNode = textNode + strongSelf.addSubnode(textNode) + } + + let textFrame = CGRect(origin: CGPoint(x: leftInset, y: floorToScreenPixels((nodeLayout.contentSize.height - textLayout.size.height) / 2.0)), size: textLayout.size) + textNode.frame = textFrame + + if let icon = strongSelf.iconNode.image { + strongSelf.iconNode.frame = CGRect(origin: CGPoint(x: textFrame.minX - icon.size.width - 16.0, y: floorToScreenPixels((nodeLayout.contentSize.height - icon.size.height) / 2.0)), size: icon.size) + } + + let separatorHeight = UIScreenPixel + let topHighlightInset: CGFloat = separatorHeight + + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: nodeLayout.contentSize.width, height: nodeLayout.contentSize.height)) + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -nodeLayout.insets.top - topHighlightInset), size: CGSize(width: nodeLayout.size.width, height: nodeLayout.size.height + topHighlightInset)) + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: nodeLayout.contentSize.height - separatorHeight), size: CGSize(width: nodeLayout.size.width, height: separatorHeight)) + strongSelf.separatorNode.isHidden = last + + strongSelf.updateLayout(size: nodeLayout.contentSize, leftInset: params.leftInset, rightInset: params.rightInset) + + strongSelf.setRevealOptions((left: [], right: [ItemListRevealOption(key: RevealOptionKey.delete.rawValue, title: item.strings.Common_Delete, icon: .none, color: item.theme.list.itemDisclosureActions.destructive.fillColor, textColor: item.theme.list.itemDisclosureActions.destructive.foregroundColor)])) + } + }) + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * 0.5) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.5, removeOnCompletion: false) + } + + override public func headers() -> [ListViewItemHeader]? { + if let item = self.item { + return item.header.flatMap { [$0] } + } else { + return nil + } + } + + override func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { + super.updateRevealOffset(offset: offset, transition: transition) + + if let params = self.layoutParams, let textNode = self.textNode { + let leftInset: CGFloat = 15.0 + params.leftInset + + var textFrame = textNode.frame + textFrame.origin.x = leftInset + offset + transition.updateFrame(node: textNode, frame: textFrame) + } + } + + override func revealOptionSelected(_ option: ItemListRevealOption, animated: Bool) { + if let item = self.item { + switch option.key { + case RevealOptionKey.delete.rawValue: + item.deleted(item.query) + default: + break + } + } + self.setRevealOptionsOpened(false, animated: true) + self.revealOptionsInteractivelyClosed() + } +} + +final class HashtagSearchRecentListNode: ASDisplayNode { + private let context: AccountContext + private var presentationData: PresentationData + + private let listNode: ListView + + private let emptyIconNode: ASImageNode + private let emptyTextNode: ImmediateTextNode + + private var enqueuedRecentTransitions: [(HashtagSearchRecentTransition, Bool)] = [] + private var recentDisposable: Disposable? + + private var validLayout: ContainerViewLayout? + + private var interaction: HashtagSearchInteraction? + + var setSearchQuery: (String) -> Void = { _ in } + + init(context: AccountContext) { + self.context = context + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.presentationData = presentationData + + self.listNode = ListView() + self.listNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } + + self.emptyIconNode = ASImageNode() + self.emptyIconNode.displaysAsynchronously = false + + self.emptyTextNode = ImmediateTextNode() + self.emptyTextNode.displaysAsynchronously = false + self.emptyTextNode.maximumNumberOfLines = 0 + self.emptyTextNode.textAlignment = .center + + super.init() + + self.addSubnode(self.listNode) + self.addSubnode(self.emptyIconNode) + self.addSubnode(self.emptyTextNode) + + self.interaction = HashtagSearchInteraction( + setSearchQuery: { [weak self] query in + self?.setSearchQuery(query) + }, + deleteRecentQuery: { query in + let _ = removeRecentHashtagSearchQuery(engine: context.engine, string: query).startStandalone() + }, + clearRecentQueries: { + let _ = clearRecentHashtagSearchQueries(engine: context.engine).startStandalone() + } + ) + + self.listNode.beganInteractiveDragging = { [weak self] _ in + self?.view.window?.endEditing(true) + } + + let previousRecentItems = Atomic<[HashtagSearchRecentQueryEntry]?>(value: nil) + self.recentDisposable = (hashtagSearchRecentQueries(engine: self.context.engine) + |> deliverOnMainQueue).start(next: { [weak self] queries in + guard let self else { + return + } + var entries: [HashtagSearchRecentQueryEntry] = [] + for i in 0 ..< queries.count { + entries.append(.query(index: i, text: queries[i])) + } + + if !entries.isEmpty { + entries.append(.clear) + } + + let previousEntries = previousRecentItems.swap(entries) + + let transition = preparedHashtagSearchRecentTransition(from: previousEntries ?? [], to: entries, account: context.account, theme: self.presentationData.theme, strings: self.presentationData.strings, interaction: self.interaction!) + self.enqueueRecentTransition(transition, firstTime: previousEntries == nil) + }) + + self.updatePresentationData(self.presentationData) + } + + deinit { + self.recentDisposable?.dispose() + } + + private func updatePresentationData(_ presentationData: PresentationData) { + self.presentationData = presentationData + + self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor + self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor + + self.emptyIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Hashtag/EmptyHashtag"), color: self.presentationData.theme.list.freeMonoIconColor) + self.emptyTextNode.attributedText = NSAttributedString(string: self.presentationData.strings.HashtagSearch_NoRecentQueries, font: Font.regular(15.0), textColor: self.presentationData.theme.list.freeTextColor) + } + + private func enqueueRecentTransition(_ transition: HashtagSearchRecentTransition, firstTime: Bool) { + enqueuedRecentTransitions.append((transition, firstTime)) + + if let _ = self.validLayout { + while !self.enqueuedRecentTransitions.isEmpty { + self.dequeueRecentTransition() + } + } + } + + private func dequeueRecentTransition() { + if let (transition, firstTime) = self.enqueuedRecentTransitions.first { + self.enqueuedRecentTransitions.remove(at: 0) + + var options = ListViewDeleteAndInsertOptions() + if firstTime { + options.insert(.PreferSynchronousDrawing) + } else { + options.insert(.AnimateInsertion) + } + + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in + guard let self else { + return + } + + self.emptyIconNode.isHidden = !transition.isEmpty + self.emptyTextNode.isHidden = !transition.isEmpty + }) + } + } + + func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + self.validLayout = layout + + let insets: UIEdgeInsets = layout.insets(options: [.input]) + + let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) + 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 }) + + if let emptyIconSize = self.emptyIconNode.image?.size { + let topInset: CGFloat = insets.top + let bottomInset: CGFloat = insets.bottom + + let sideInset: CGFloat = 0.0 + let padding: CGFloat = 16.0 + let emptyTextSize = self.emptyTextNode.updateLayout(CGSize(width: layout.size.width - sideInset * 2.0 - padding * 2.0, height: CGFloat.greatestFiniteMagnitude)) + + let emptyTextSpacing: CGFloat = 6.0 + let emptyTotalHeight = emptyIconSize.height + emptyTextSpacing + emptyTextSize.height + let emptyOriginY = topInset + floorToScreenPixels((layout.size.height - topInset - bottomInset - emptyTotalHeight) / 2.0) + + transition.updateFrame(node: self.emptyIconNode, frame: CGRect(origin: CGPoint(x: sideInset + padding + (layout.size.width - sideInset * 2.0 - padding * 2.0 - emptyIconSize.width) / 2.0, y: emptyOriginY), size: emptyIconSize)) + transition.updateFrame(node: self.emptyTextNode, frame: CGRect(origin: CGPoint(x: sideInset + padding + (layout.size.width - sideInset * 2.0 - padding * 2.0 - emptyTextSize.width) / 2.0, y: emptyOriginY + emptyIconSize.height + emptyTextSpacing), size: emptyTextSize)) + } + } +} diff --git a/submodules/HashtagSearchUI/Sources/HashtagSearchRecentQueries.swift b/submodules/HashtagSearchUI/Sources/HashtagSearchRecentQueries.swift new file mode 100644 index 0000000000..bca1b83497 --- /dev/null +++ b/submodules/HashtagSearchUI/Sources/HashtagSearchRecentQueries.swift @@ -0,0 +1,68 @@ +import Foundation +import Postbox +import TelegramCore +import SwiftSignalKit +import TelegramUIPreferences + +private struct HashtagSearchRecentQueryItemId { + public let rawValue: MemoryBuffer + + var value: String { + return String(data: self.rawValue.makeData(), encoding: .utf8) ?? "" + } + + init(_ rawValue: MemoryBuffer) { + self.rawValue = rawValue + } + + init?(_ value: String) { + if let data = value.data(using: .utf8) { + self.rawValue = MemoryBuffer(data: data) + } else { + return nil + } + } +} + +public final class RecentHashtagSearchQueryItem: Codable { + public init() { + } + + public init(from decoder: Decoder) throws { + } + + public func encode(to encoder: Encoder) throws { + } +} + +func addRecentHashtagSearchQuery(engine: TelegramEngine, string: String) -> Signal { + if let itemId = HashtagSearchRecentQueryItemId(string) { + return engine.orderedLists.addOrMoveToFirstPosition(collectionId: ApplicationSpecificOrderedItemListCollectionId.hashtagSearchRecentQueries, id: itemId.rawValue, item: RecentHashtagSearchQueryItem(), removeTailIfCountExceeds: 100) + } else { + return .complete() + } +} + +func removeRecentHashtagSearchQuery(engine: TelegramEngine, string: String) -> Signal { + if let itemId = HashtagSearchRecentQueryItemId(string) { + return engine.orderedLists.removeItem(collectionId: ApplicationSpecificOrderedItemListCollectionId.hashtagSearchRecentQueries, id: itemId.rawValue) + } else { + return .complete() + } +} + +func clearRecentHashtagSearchQueries(engine: TelegramEngine) -> Signal { + return engine.orderedLists.clear(collectionId: ApplicationSpecificOrderedItemListCollectionId.hashtagSearchRecentQueries) +} + +func hashtagSearchRecentQueries(engine: TelegramEngine) -> Signal<[String], NoError> { + return engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: ApplicationSpecificOrderedItemListCollectionId.hashtagSearchRecentQueries)) + |> map { items -> [String] in + var result: [String] = [] + for item in items { + let value = HashtagSearchRecentQueryItemId(item.id).value + result.append(value) + } + return result + } +} diff --git a/submodules/InvisibleInkDustNode/Sources/MediaDustNode.swift b/submodules/InvisibleInkDustNode/Sources/MediaDustNode.swift index 31ad26614b..d6af2f9e1a 100644 --- a/submodules/InvisibleInkDustNode/Sources/MediaDustNode.swift +++ b/submodules/InvisibleInkDustNode/Sources/MediaDustNode.swift @@ -211,7 +211,7 @@ public class MediaDustNode: ASDisplayNode { self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tap(_:)))) } - @objc private func tap(_ gestureRecognizer: UITapGestureRecognizer) { + public func tap(at location: CGPoint) { guard !self.isRevealed else { return } @@ -223,13 +223,12 @@ public class MediaDustNode: ASDisplayNode { if self.enableAnimations { self.isExploding = true - let position = gestureRecognizer.location(in: self.view) self.emitterLayer?.setValue(true, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") - self.emitterLayer?.setValue(position, forKeyPath: "emitterBehaviors.fingerAttractor.position") + self.emitterLayer?.setValue(location, forKeyPath: "emitterBehaviors.fingerAttractor.position") let maskSize = self.emitterNode.frame.size Queue.concurrentDefaultQueue().async { - let emitterMaskImage = generateMaskImage(size: maskSize, position: position, inverse: true) + let emitterMaskImage = generateMaskImage(size: maskSize, position: location, inverse: true) Queue.mainQueue().async { self.emitterSpotNode.image = emitterMaskImage @@ -237,8 +236,8 @@ public class MediaDustNode: ASDisplayNode { } Queue.mainQueue().after(0.1 * UIView.animationDurationFactor()) { - let xFactor = (position.x / self.emitterNode.frame.width - 0.5) * 2.0 - let yFactor = (position.y / self.emitterNode.frame.height - 0.5) * 2.0 + let xFactor = (location.x / self.emitterNode.frame.width - 0.5) * 2.0 + let yFactor = (location.y / self.emitterNode.frame.height - 0.5) * 2.0 let maxFactor = max(abs(xFactor), abs(yFactor)) let scaleAddition = maxFactor * 4.0 @@ -247,8 +246,8 @@ public class MediaDustNode: ASDisplayNode { self.supernode?.view.mask = self.emitterMaskNode.view self.emitterSpotNode.frame = CGRect(x: 0.0, y: 0.0, width: self.emitterMaskNode.frame.width * 3.0, height: self.emitterMaskNode.frame.height * 3.0) - self.emitterSpotNode.layer.anchorPoint = CGPoint(x: position.x / self.emitterMaskNode.frame.width, y: position.y / self.emitterMaskNode.frame.height) - self.emitterSpotNode.position = position + self.emitterSpotNode.layer.anchorPoint = CGPoint(x: location.x / self.emitterMaskNode.frame.width, y: location.y / self.emitterMaskNode.frame.height) + self.emitterSpotNode.position = location self.emitterSpotNode.layer.animateScale(from: 0.3333, to: 10.5 + scaleAddition, duration: 0.45 + durationAddition, removeOnCompletion: false, completion: { [weak self] _ in self?.revealed() self?.alpha = 0.0 @@ -272,6 +271,11 @@ public class MediaDustNode: ASDisplayNode { }) } } + + @objc private func tap(_ gestureRecognizer: UITapGestureRecognizer) { + let location = gestureRecognizer.location(in: self.view) + self.tap(at: location) + } private var didSetupAnimations = false private func setupRandomAnimations() { diff --git a/submodules/TelegramUI/Components/Chat/ChatEmptyNode/Sources/ChatEmptyNode.swift b/submodules/TelegramUI/Components/Chat/ChatEmptyNode/Sources/ChatEmptyNode.swift index 218ca14ac6..ac62e628a6 100644 --- a/submodules/TelegramUI/Components/Chat/ChatEmptyNode/Sources/ChatEmptyNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatEmptyNode/Sources/ChatEmptyNode.swift @@ -779,6 +779,8 @@ private final class ChatEmptyNodeCloudChatContent: ASDisplayNode, ChatEmptyNodeC insets.top = -9.0 imageSpacing = 4.0 titleSpacing = 5.0 + case .hashTagSearch: + break } } @@ -838,6 +840,9 @@ private final class ChatEmptyNodeCloudChatContent: ASDisplayNode, ChatEmptyNodeC } self.businessLink = link + case .hashTagSearch: + titleString = "" + strings = [] } } else { titleString = interfaceState.strings.Conversation_CloudStorageInfo_Title diff --git a/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/BUILD b/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/BUILD index f82054dbad..c9dbe087ce 100644 --- a/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/BUILD @@ -27,6 +27,8 @@ swift_library( "//submodules/ContactsPeerItem", "//submodules/ItemListUI", "//submodules/ChatListSearchItemHeader", + "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/Components/MultilineTextComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/Sources/ChatInlineSearchResultsListComponent.swift b/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/Sources/ChatInlineSearchResultsListComponent.swift index dff94d1759..d8a51a421e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/Sources/ChatInlineSearchResultsListComponent.swift +++ b/submodules/TelegramUI/Components/Chat/ChatInlineSearchResultsListComponent/Sources/ChatInlineSearchResultsListComponent.swift @@ -16,6 +16,8 @@ import ChatPresentationInterfaceState import ContactsPeerItem import ItemListUI import ChatListSearchItemHeader +import LottieComponent +import MultilineTextComponent public final class ChatInlineSearchResultsListComponent: Component { public struct Presentation: Equatable { @@ -73,9 +75,11 @@ public final class ChatInlineSearchResultsListComponent: Component { public let context: AccountContext public let presentation: Presentation - public let peerId: EnginePeer.Id + public let peerId: EnginePeer.Id? public let contents: Contents public let insets: UIEdgeInsets + public let inputHeight: CGFloat + public let showEmptyResults: Bool public let messageSelected: (EngineMessage) -> Void public let peerSelected: (EnginePeer) -> Void public let loadTagMessages: (MemoryBuffer, MessageIndex?) -> Signal? @@ -86,9 +90,11 @@ public final class ChatInlineSearchResultsListComponent: Component { public init( context: AccountContext, presentation: Presentation, - peerId: EnginePeer.Id, + peerId: EnginePeer.Id?, contents: Contents, insets: UIEdgeInsets, + inputHeight: CGFloat, + showEmptyResults: Bool, messageSelected: @escaping (EngineMessage) -> Void, peerSelected: @escaping (EnginePeer) -> Void, loadTagMessages: @escaping (MemoryBuffer, MessageIndex?) -> Signal?, @@ -101,6 +107,8 @@ public final class ChatInlineSearchResultsListComponent: Component { self.peerId = peerId self.contents = contents self.insets = insets + self.inputHeight = inputHeight + self.showEmptyResults = showEmptyResults self.messageSelected = messageSelected self.peerSelected = peerSelected self.loadTagMessages = loadTagMessages @@ -125,6 +133,12 @@ public final class ChatInlineSearchResultsListComponent: Component { if lhs.insets != rhs.insets { return false } + if lhs.inputHeight != rhs.inputHeight { + return false + } + if lhs.showEmptyResults != rhs.showEmptyResults { + return false + } return true } @@ -216,6 +230,9 @@ public final class ChatInlineSearchResultsListComponent: Component { private var isUpdating: Bool = false private let listNode: ListView + private let emptyResultsTitle = ComponentView() + private let emptyResultsText = ComponentView() + private let emptyResultsAnimation = ComponentView() private var tagContents: (index: MessageIndex?, disposable: Disposable?)? private var searchContents: (index: MessageIndex?, disposable: Disposable?)? @@ -257,6 +274,10 @@ public final class ChatInlineSearchResultsListComponent: Component { self.searchContents?.disposable?.dispose() } + public 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 }) + } + public func animateIn() { self.listNode.layer.animateSublayerScale(from: 0.95, to: 1.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) @@ -846,6 +867,103 @@ public final class ChatInlineSearchResultsListComponent: Component { } } + let fadeTransition = Transition.easeInOut(duration: 0.25) + if component.showEmptyResults, let appliedContentsState = self.appliedContentsState, appliedContentsState.entries.isEmpty, case let .search(query, _) = component.contents, !query.isEmpty { + let sideInset: CGFloat = 44.0 + let emptyAnimationHeight = 148.0 + let topInset: CGFloat = component.insets.top + let bottomInset: CGFloat = max(component.insets.bottom, component.inputHeight) + let visibleHeight = availableSize.height + let emptyAnimationSpacing: CGFloat = 8.0 + let emptyTextSpacing: CGFloat = 8.0 + + let emptyResultsTitleSize = self.emptyResultsTitle.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: component.presentation.strings.HashtagSearch_NoResults, font: Font.semibold(17.0), textColor: component.presentation.theme.list.itemSecondaryTextColor)), + horizontalAlignment: .center + ) + ), + environment: {}, + containerSize: availableSize + ) + let emptyResultsTextSize = self.emptyResultsText.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: component.presentation.strings.HashtagSearch_NoResultsQueryDescription(query).string, font: Font.regular(15.0), textColor: component.presentation.theme.list.itemSecondaryTextColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 0 + ) + ), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height) + ) + let emptyResultsAnimationSize = self.emptyResultsAnimation.update( + transition: .immediate, + component: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent(name: "ChatListNoResults") + )), + environment: {}, + containerSize: CGSize(width: emptyAnimationHeight, height: emptyAnimationHeight) + ) + + let emptyTotalHeight = emptyAnimationHeight + emptyAnimationSpacing + emptyResultsTitleSize.height + emptyResultsTextSize.height + emptyTextSpacing + let emptyAnimationY = topInset + floorToScreenPixels((visibleHeight - topInset - bottomInset - emptyTotalHeight) / 2.0) + + let emptyResultsAnimationFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - emptyResultsAnimationSize.width) / 2.0), y: emptyAnimationY), size: emptyResultsAnimationSize) + + let emptyResultsTitleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - emptyResultsTitleSize.width) / 2.0), y: emptyResultsAnimationFrame.maxY + emptyAnimationSpacing), size: emptyResultsTitleSize) + + let emptyResultsTextFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - emptyResultsTextSize.width) / 2.0), y: emptyResultsTitleFrame.maxY + emptyTextSpacing), size: emptyResultsTextSize) + + if let view = self.emptyResultsAnimation.view as? LottieComponent.View { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.addSubview(view) + view.playOnce() + } + view.bounds = CGRect(origin: .zero, size: emptyResultsAnimationFrame.size) + transition.setPosition(view: view, position: emptyResultsAnimationFrame.center) + } + if let view = self.emptyResultsTitle.view { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.addSubview(view) + } + view.bounds = CGRect(origin: .zero, size: emptyResultsTitleFrame.size) + transition.setPosition(view: view, position: emptyResultsTitleFrame.center) + } + if let view = self.emptyResultsText.view { + if view.superview == nil { + view.alpha = 0.0 + fadeTransition.setAlpha(view: view, alpha: 1.0) + self.addSubview(view) + } + view.bounds = CGRect(origin: .zero, size: emptyResultsTextFrame.size) + transition.setPosition(view: view, position: emptyResultsTextFrame.center) + } + } else { + if let view = self.emptyResultsAnimation.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + if let view = self.emptyResultsTitle.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + if let view = self.emptyResultsText.view { + fadeTransition.setAlpha(view: view, alpha: 0.0, completion: { _ in + view.removeFromSuperview() + }) + } + } + return availableSize } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift index fd85a70f3d..0facc485d0 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -1156,6 +1156,7 @@ public class ChatMessageAnimatedStickerItemNode: ChatMessageItemView { context: item.context, controllerInteraction: item.controllerInteraction, type: .standalone, + peer: nil, threadId: item.message.threadId ?? 1, parentMessage: item.message, constrainedSize: CGSize(width: availableContentWidth, height: CGFloat.greatestFiniteMagnitude), diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index b47316f0f0..4b3582b94a 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -1473,6 +1473,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI ignoreNameHiding = true } + if let subject = item.associatedData.subject, case let .customChatContents(contents) = subject, case .hashTagSearch = contents.kind { + ignoreNameHiding = true + } + displayAuthorInfo = !mergedTop.merged && allowAuthor && peerId.isGroupOrChannel && effectiveAuthor != nil if let forwardInfo = firstMessage.forwardInfo, forwardInfo.psaType != nil { displayAuthorInfo = false @@ -1500,6 +1504,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else if incoming { hasAvatar = true } + + if let subject = item.associatedData.subject, case let .customChatContents(contents) = subject, case .hashTagSearch = contents.kind { + hasAvatar = true + } } var isInstantVideo = false @@ -2090,6 +2098,13 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if !displayHeader, case .peer = item.chatLocation, let channel = item.message.peers[item.message.id.peerId] as? TelegramChannel, channel.flags.contains(.isForum), item.message.associatedThreadInfo != nil { displayHeader = true } + if case let .customChatContents(contents) = item.associatedData.subject, case .hashTagSearch = contents.kind, let peer = item.message.peers[item.message.id.peerId] { + if let channel = peer as? TelegramChannel, case .broadcast = channel.info { + + } else { + displayHeader = true + } + } } let firstNodeTopPosition: ChatMessageBubbleRelativePosition @@ -2412,9 +2427,16 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI headerSize.height += 2.0 } } + + var hasThreadInfo = false + if case let .peer(peerId) = item.chatLocation, (peerId == replyMessage?.id.peerId || item.message.threadId == 1 || item.associatedData.isRecentActions), let channel = item.message.peers[item.message.id.peerId] as? TelegramChannel, channel.flags.contains(.isForum), item.message.associatedThreadInfo != nil { + hasThreadInfo = true + } else if case let .customChatContents(contents) = item.associatedData.subject, case .hashTagSearch = contents.kind { + hasThreadInfo = true + } var hasReply = replyMessage != nil || replyForward != nil || replyStory != nil - if !isInstantVideo, case let .peer(peerId) = item.chatLocation, (peerId == replyMessage?.id.peerId || item.message.threadId == 1 || item.associatedData.isRecentActions), let channel = item.message.peers[item.message.id.peerId] as? TelegramChannel, channel.flags.contains(.isForum), item.message.associatedThreadInfo != nil { + if !isInstantVideo, hasThreadInfo { if let threadId = item.message.threadId, let replyMessage = replyMessage, Int64(replyMessage.id.id) == threadId { hasReply = false } @@ -2431,6 +2453,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI context: item.context, controllerInteraction: item.controllerInteraction, type: .bubble(incoming: incoming), + peer: item.message.peers[item.message.id.peerId].flatMap(EnginePeer.init), threadId: item.message.threadId ?? 1, parentMessage: item.message, constrainedSize: CGSize(width: maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right, height: CGFloat.greatestFiniteMagnitude), diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift index 516a8755eb..17108c48db 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift @@ -200,9 +200,22 @@ extension UIBezierPath { } private class ExtendedMediaOverlayNode: ASDisplayNode { + enum Icon { + case lock + case eye + + var image: UIImage { + switch self { + case .lock: + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Stickers/SmallLock"), color: .white)! + case .eye: + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/AgeRestricted"), color: .white)! + } + } + } private let blurredImageNode: TransformImageNode private let dustNode: MediaDustNode - private let buttonNode: HighlightTrackingButtonNode + fileprivate let buttonNode: HighlightTrackingButtonNode private let highlightedBackgroundNode: ASDisplayNode private let iconNode: ASImageNode private let textNode: ImmediateTextNode @@ -214,7 +227,7 @@ private class ExtendedMediaOverlayNode: ASDisplayNode { var isRevealed = false var tapped: () -> Void = {} - init(hasImageOverlay: Bool, enableAnimations: Bool) { + init(hasImageOverlay: Bool, icon: Icon, enableAnimations: Bool) { self.blurredImageNode = TransformImageNode() self.blurredImageNode.contentAnimations = [] @@ -231,10 +244,11 @@ private class ExtendedMediaOverlayNode: ASDisplayNode { self.iconNode = ASImageNode() self.iconNode.displaysAsynchronously = false - self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Stickers/SmallLock"), color: .white) + self.iconNode.image = icon.image self.textNode = ImmediateTextNode() - + self.textNode.isUserInteractionEnabled = false + super.init() if hasImageOverlay { @@ -244,8 +258,8 @@ private class ExtendedMediaOverlayNode: ASDisplayNode { self.addSubnode(self.buttonNode) self.buttonNode.addSubnode(self.highlightedBackgroundNode) - self.addSubnode(self.iconNode) - self.addSubnode(self.textNode) + self.buttonNode.addSubnode(self.iconNode) + self.buttonNode.addSubnode(self.textNode) self.buttonNode.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { @@ -263,7 +277,7 @@ private class ExtendedMediaOverlayNode: ASDisplayNode { } @objc private func buttonPressed() { - + self.tapped() } override func didLoad() { @@ -284,10 +298,14 @@ private class ExtendedMediaOverlayNode: ASDisplayNode { self.maskLayer = maskLayer } - func reveal() { + func reveal(animated: Bool = false) { self.isRevealed = true - self.blurredImageNode.removeFromSupernode() - self.dustNode.removeFromSupernode() + if animated { + self.dustNode.tap(at: CGPoint(x: self.dustNode.bounds.width / 2.0, y: self.dustNode.bounds.height / 2.0)) + } else { + self.blurredImageNode.removeFromSupernode() + self.dustNode.removeFromSupernode() + } } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { @@ -317,12 +335,20 @@ private class ExtendedMediaOverlayNode: ASDisplayNode { self.isRevealed = self.dustNode.isRevealed self.dustNode.revealed = { [weak self] in - self?.isRevealed = true - self?.blurredImageNode.removeFromSupernode() + guard let self else { + return + } + self.isRevealed = true + self.blurredImageNode.removeFromSupernode() + self.buttonNode.removeFromSupernode() } self.dustNode.tapped = { [weak self] in - self?.isRevealed = true - self?.tapped() + guard let self else { + return + } + if !self.isRevealed { + self.tapped() + } } } else { self.blurredImageNode.isHidden = true @@ -347,8 +373,8 @@ private class ExtendedMediaOverlayNode: ASDisplayNode { self.buttonNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - contentSize.width) / 2.0), y: floorToScreenPixels((size.height - contentSize.height) / 2.0)), size: contentSize) self.highlightedBackgroundNode.frame = CGRect(origin: .zero, size: contentSize) - self.iconNode.frame = CGRect(origin: CGPoint(x: self.buttonNode.frame.minX + padding, y: self.buttonNode.frame.minY + floorToScreenPixels((contentSize.height - iconSize.height) / 2.0) + 1.0 - UIScreenPixel), size: iconSize) - self.textNode.frame = CGRect(origin: CGPoint(x: self.iconNode.frame.maxX + spacing, y: self.buttonNode.frame.minY + floorToScreenPixels((contentSize.height - textSize.height) / 2.0)), size: textSize) + self.iconNode.frame = CGRect(origin: CGPoint(x: padding, y: floorToScreenPixels((contentSize.height - iconSize.height) / 2.0) + 1.0 - UIScreenPixel), size: iconSize) + self.textNode.frame = CGRect(origin: CGPoint(x: self.iconNode.frame.maxX + spacing, y: floorToScreenPixels((contentSize.height - textSize.height) / 2.0)), size: textSize) } } @@ -457,7 +483,8 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr public var activatePinch: ((PinchSourceContainerNode) -> Void)? public var updateMessageReaction: ((Message, ChatControllerInteractionReaction, Bool, ContextExtractedContentContainingView?) -> Void)? public var playMessageEffect: ((Message) -> Void)? - + public var activateAgeRestrictedMedia: (() -> Void)? + override public init() { self.pinchContainerNode = PinchSourceContainerNode() @@ -1867,7 +1894,7 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr backgroundColor = messageTheme.mediaDateAndStatusFillColor } - if let invoice = invoice { + if let invoice = invoice, invoice.currency != "XTR" { if let extendedMedia = invoice.extendedMedia { if case let .preview(_, _, maybeVideoDuration) = extendedMedia, let videoDuration = maybeVideoDuration { badgeContent = .text(inset: 0.0, backgroundColor: messageTheme.mediaDateAndStatusFillColor, foregroundColor: messageTheme.mediaDateAndStatusTextColor, text: NSAttributedString(string: stringForDuration(videoDuration, position: nil)), iconName: nil) @@ -2216,6 +2243,7 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr badgeNode.removeFromSupernode() } + var icon: ExtendedMediaOverlayNode.Icon = .lock var displaySpoiler = false if let invoice = invoice, let extendedMedia = invoice.extendedMedia, case .preview = extendedMedia { displaySpoiler = true @@ -2223,14 +2251,26 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr displaySpoiler = true } else if isSecretMedia { displaySpoiler = true + } else if message.isAgeRestricted() { + displaySpoiler = true + icon = .eye } - + if displaySpoiler { if self.extendedMediaOverlayNode == nil { - let extendedMediaOverlayNode = ExtendedMediaOverlayNode(hasImageOverlay: !isSecretMedia, enableAnimations: (self.context?.sharedContext.energyUsageSettings.fullTranslucency ?? true) && !isPreview) + let enableAnimations = (self.context?.sharedContext.energyUsageSettings.fullTranslucency ?? true) && !isPreview + let extendedMediaOverlayNode = ExtendedMediaOverlayNode(hasImageOverlay: !isSecretMedia, icon: icon, enableAnimations: enableAnimations) extendedMediaOverlayNode.tapped = { [weak self] in - self?.internallyVisible = true - self?.updateVisibility() + guard let self else { + return + } + if message.isAgeRestricted() { + self.activateAgeRestrictedMedia?() + } else { + self.internallyVisible = true + self.extendedMediaOverlayNode?.isRevealed = true + self.updateVisibility() + } } self.extendedMediaOverlayNode = extendedMediaOverlayNode self.pinchContainerNode.contentNode.insertSubnode(extendedMediaOverlayNode, aboveSubnode: self.imageNode) @@ -2249,21 +2289,26 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr self.extendedMediaOverlayNode?.isUserInteractionEnabled = tappable - var paymentText: String = "" - outer: for attribute in message.attributes { - if let attribute = attribute as? ReplyMarkupMessageAttribute { - for row in attribute.rows { - for button in row.buttons { - if case .payment = button.action { - paymentText = button.title - break outer + var viewText: String = "" + if message.isAgeRestricted() { + //TODO: localize + viewText = "18+ Content" + } else { + outer: for attribute in message.attributes { + if let attribute = attribute as? ReplyMarkupMessageAttribute { + for row in attribute.rows { + for button in row.buttons { + if case .payment = button.action { + viewText = button.title + break outer + } } } + break } - break } } - self.extendedMediaOverlayNode?.update(size: self.imageNode.frame.size, text: paymentText, imageSignal: self.currentBlurredImageSignal, imageFrame: self.imageNode.view.convert(self.imageNode.bounds, to: self.extendedMediaOverlayNode?.view), corners: self.currentImageArguments?.corners) + self.extendedMediaOverlayNode?.update(size: self.imageNode.frame.size, text: viewText, imageSignal: self.currentBlurredImageSignal, imageFrame: self.imageNode.view.convert(self.imageNode.bounds, to: self.extendedMediaOverlayNode?.view), corners: self.currentImageArguments?.corners) } else if let extendedMediaOverlayNode = self.extendedMediaOverlayNode { self.extendedMediaOverlayNode = nil extendedMediaOverlayNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak extendedMediaOverlayNode] _ in @@ -2286,6 +2331,10 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr } } + public func reveal() { + self.extendedMediaOverlayNode?.reveal(animated: true) + } + public static func asyncLayout(_ node: ChatMessageInteractiveMediaNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ dateTimeFormat: PresentationDateTimeFormat, _ message: Message, _ associatedData: ChatMessageItemAssociatedData, _ attributes: ChatMessageEntryAttributes, _ media: Media, _ dateAndStatus: ChatMessageDateAndStatus?, _ automaticDownload: InteractiveMediaNodeAutodownloadMode, _ peerType: MediaAutoDownloadPeerType, _ peerId: EnginePeer.Id?, _ sizeCalculation: InteractiveMediaNodeSizeCalculation, _ layoutConstants: ChatMessageItemLayoutConstants, _ contentMode: InteractiveMediaNodeContentMode, _ presentationContext: ChatPresentationContext) -> (CGSize, CGFloat, (CGSize, Bool, Bool, ImageCorners) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> ChatMessageInteractiveMediaNode))) { let currentAsyncLayout = node?.asyncLayout() @@ -2465,4 +2514,14 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr return nil } } + + public func ignoreTapActionAtPoint(_ point: CGPoint) -> Bool { + if let extendedMediaOverlayNode = self.extendedMediaOverlayNode { + let convertedPoint = self.view.convert(point, to: extendedMediaOverlayNode.view) + if extendedMediaOverlayNode.buttonNode.frame.contains(convertedPoint) { + return true + } + } + return false + } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift index 168f20a5f2..011839327a 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageMediaBubbleContentNode/Sources/ChatMessageMediaBubbleContentNode.swift @@ -42,20 +42,28 @@ public class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { self.addSubnode(self.interactiveImageNode) self.interactiveImageNode.activateLocalContent = { [weak self] mode in - if let strongSelf = self { - if let item = strongSelf.item { - let openChatMessageMode: ChatControllerInteractionOpenMessageMode - switch mode { - case .default: - openChatMessageMode = .default - case .stream: - openChatMessageMode = .stream - case .automaticPlayback: - openChatMessageMode = .automaticPlayback - } - let _ = item.controllerInteraction.openMessage(item.message, OpenMessageParams(mode: openChatMessageMode)) - } + guard let self, let item = self.item else { + return } + let openChatMessageMode: ChatControllerInteractionOpenMessageMode + switch mode { + case .default: + openChatMessageMode = .default + case .stream: + openChatMessageMode = .stream + case .automaticPlayback: + openChatMessageMode = .automaticPlayback + } + let _ = item.controllerInteraction.openMessage(item.message, OpenMessageParams(mode: openChatMessageMode)) + } + + self.interactiveImageNode.activateAgeRestrictedMedia = { [weak self] in + guard let self, let item = self.item else { + return + } + let _ = item.controllerInteraction.openAgeRestrictedMessageMedia(item.message, { [weak self] in + self?.interactiveImageNode.reveal() + }) } self.interactiveImageNode.updateMessageReaction = { [weak self] message, value, force, sourceView in @@ -470,6 +478,9 @@ public class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { } override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { + if self.interactiveImageNode.ignoreTapActionAtPoint(point) { + return ChatMessageBubbleContentTapAction(content: .ignore) + } return ChatMessageBubbleContentTapAction(content: .none) } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift index fef6916251..62e1b527e6 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageStickerItemNode/Sources/ChatMessageStickerItemNode.swift @@ -731,6 +731,7 @@ public class ChatMessageStickerItemNode: ChatMessageItemView { context: item.context, controllerInteraction: item.controllerInteraction, type: .standalone, + peer: nil, threadId: item.message.threadId ?? 1, parentMessage: item.message, constrainedSize: CGSize(width: availableWidth, height: CGFloat.greatestFiniteMagnitude), diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index c6ce0ca329..d28ddddc49 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -263,8 +263,12 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { default: break } - if case .customChatContents = item.associatedData.subject { - displayStatus = false + if case let .customChatContents(contents) = item.associatedData.subject { + if case .hashTagSearch = contents.kind { + displayStatus = true + } else { + displayStatus = false + } } if displayStatus { if incoming { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageThreadInfoNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageThreadInfoNode/BUILD index a870c33413..6b225f349c 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageThreadInfoNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageThreadInfoNode/BUILD @@ -29,6 +29,7 @@ swift_library( "//submodules/TelegramUI/Components/EmojiStatusComponent", "//submodules/WallpaperBackgroundNode", "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/AvatarNode", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageThreadInfoNode/Sources/ChatMessageThreadInfoNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageThreadInfoNode/Sources/ChatMessageThreadInfoNode.swift index f8f4523fd8..3e5efe2ef5 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageThreadInfoNode/Sources/ChatMessageThreadInfoNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageThreadInfoNode/Sources/ChatMessageThreadInfoNode.swift @@ -19,6 +19,7 @@ import ComponentFlow import EmojiStatusComponent import WallpaperBackgroundNode import ChatControllerInteraction +import AvatarNode private func generateRectsImage(color: UIColor, rects: [CGRect], inset: CGFloat, outerRadius: CGFloat, innerRadius: CGFloat) -> (CGPoint, UIImage?) { enum CornerType { @@ -184,7 +185,8 @@ public class ChatMessageThreadInfoNode: ASDisplayNode { public let context: AccountContext public let controllerInteraction: ChatControllerInteraction public let type: ChatMessageThreadInfoType - public let threadId: Int64 + public let peer: EnginePeer? + public let threadId: Int64? public let parentMessage: Message public let constrainedSize: CGSize public let animationCache: AnimationCache? @@ -196,7 +198,8 @@ public class ChatMessageThreadInfoNode: ASDisplayNode { context: AccountContext, controllerInteraction: ChatControllerInteraction, type: ChatMessageThreadInfoType, - threadId: Int64, + peer: EnginePeer?, + threadId: Int64?, parentMessage: Message, constrainedSize: CGSize, animationCache: AnimationCache?, @@ -207,6 +210,7 @@ public class ChatMessageThreadInfoNode: ASDisplayNode { self.context = context self.controllerInteraction = controllerInteraction self.type = type + self.peer = peer self.threadId = threadId self.parentMessage = parentMessage self.constrainedSize = constrainedSize @@ -239,6 +243,7 @@ public class ChatMessageThreadInfoNode: ASDisplayNode { private let contentBackgroundNode: ASImageNode private var textNode: TextNodeWithEntities? private let arrowNode: ASImageNode + private var avatarNode: AvatarNode? private var titleTopicIconView: ComponentHostView? private var titleTopicIconComponent: EmojiStatusComponent? @@ -322,6 +327,8 @@ public class ChatMessageThreadInfoNode: ASDisplayNode { topicTitle = threadInfo.title topicIconId = threadInfo.icon topicIconColor = threadInfo.iconColor + } else if let peer = arguments.peer { + topicTitle = peer.displayTitle(strings: arguments.presentationData.strings, displayOrder: arguments.presentationData.nameDisplayOrder) } let backgroundColor: UIColor @@ -362,7 +369,10 @@ public class ChatMessageThreadInfoNode: ASDisplayNode { let fillInset: CGFloat = 5.0 let iconSize = CGSize(width: 22.0, height: 22.0) let insets = UIEdgeInsets(top: 2.0, left: 4.0, bottom: 2.0, right: 4.0) - let spacing: CGFloat = 4.0 + var spacing: CGFloat = 4.0 + if arguments.peer != nil { + spacing += 3.0 + } let (textLayout, textApply) = textNodeLayout(TextNodeLayoutArguments(attributedString: text, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .end, constrainedSize: CGSize(width: arguments.constrainedSize.width - insets.left - insets.right - iconSize.width - spacing, height: arguments.constrainedSize.height), alignment: .natural, cutout: nil, insets: .zero)) @@ -393,7 +403,11 @@ public class ChatMessageThreadInfoNode: ASDisplayNode { } node.pressed = { - arguments.controllerInteraction.navigateToThreadMessage(arguments.parentMessage.id.peerId, arguments.threadId, arguments.parentMessage.id) + if let _ = arguments.peer { + arguments.controllerInteraction.navigateToMessage(arguments.parentMessage.id, arguments.parentMessage.id, NavigateToMessageParams(timestamp: nil, quote: nil, forceNew: true)) + } else if let threadId = arguments.threadId { + arguments.controllerInteraction.navigateToThreadMessage(arguments.parentMessage.id.peerId, threadId, arguments.parentMessage.id) + } } if node.lineRects != lineRects { @@ -471,57 +485,69 @@ public class ChatMessageThreadInfoNode: ASDisplayNode { node.contentNode.addSubnode(textNode.textNode) } - let titleTopicIconView: ComponentHostView - if let current = node.titleTopicIconView { - titleTopicIconView = current - } else { - titleTopicIconView = ComponentHostView() - node.titleTopicIconView = titleTopicIconView - node.contentNode.view.addSubview(titleTopicIconView) - } - - let titleTopicIconContent: EmojiStatusComponent.Content - var containerSize: CGSize = CGSize(width: 22.0, height: 22.0) - var iconX: CGFloat = 0.0 - if arguments.threadId == 1 { - titleTopicIconContent = .image(image: generalThreadIcon) - containerSize = CGSize(width: 18.0, height: 18.0) - iconX = 3.0 - } else if let fileId = topicIconId, fileId != 0 { - titleTopicIconContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 36.0, height: 36.0), placeholderColor: arguments.presentationData.theme.theme.list.mediaPlaceholderColor, themeColor: arguments.presentationData.theme.theme.list.itemAccentColor, loopMode: .count(1)) - } else { - titleTopicIconContent = .topic(title: String(topicTitle.prefix(1)), color: topicIconColor, size: CGSize(width: 22.0, height: 22.0)) - } - - if let animationCache = arguments.animationCache, let animationRenderer = arguments.animationRenderer { - let titleTopicIconComponent = EmojiStatusComponent( - context: arguments.context, - animationCache: animationCache, - animationRenderer: animationRenderer, - content: titleTopicIconContent, - isVisibleForAnimations: node.visibility, - action: nil - ) - node.titleTopicIconComponent = titleTopicIconComponent - - let iconSize = titleTopicIconView.update( - transition: .immediate, - component: AnyComponent(titleTopicIconComponent), - environment: {}, - containerSize: containerSize - ) - - let iconY: CGFloat - if let firstLineMidY = firstLineMidY { - iconY = floorToScreenPixels(firstLineMidY - iconSize.height / 2.0) + if let peer = arguments.peer { + let avatarNode: AvatarNode + if let current = node.avatarNode { + avatarNode = current } else { - iconY = 0.0 + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 15.0)) + node.contentNode.addSubnode(avatarNode) + } + avatarNode.frame = CGRect(origin: CGPoint(x: -1.0, y: -3.0), size: CGSize(width: 26.0, height: 26.0)) + avatarNode.setPeer(context: arguments.context, theme: arguments.presentationData.theme.theme, peer: peer) + } else { + let titleTopicIconView: ComponentHostView + if let current = node.titleTopicIconView { + titleTopicIconView = current + } else { + titleTopicIconView = ComponentHostView() + node.titleTopicIconView = titleTopicIconView + node.contentNode.view.addSubview(titleTopicIconView) } - titleTopicIconView.frame = CGRect(origin: CGPoint(x: insets.left + iconX, y: insets.top + iconY), size: iconSize) + let titleTopicIconContent: EmojiStatusComponent.Content + var containerSize: CGSize = CGSize(width: 22.0, height: 22.0) + var iconX: CGFloat = 0.0 + if arguments.threadId == 1 { + titleTopicIconContent = .image(image: generalThreadIcon) + containerSize = CGSize(width: 18.0, height: 18.0) + iconX = 3.0 + } else if let fileId = topicIconId, fileId != 0 { + titleTopicIconContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 36.0, height: 36.0), placeholderColor: arguments.presentationData.theme.theme.list.mediaPlaceholderColor, themeColor: arguments.presentationData.theme.theme.list.itemAccentColor, loopMode: .count(1)) + } else { + titleTopicIconContent = .topic(title: String(topicTitle.prefix(1)), color: topicIconColor, size: CGSize(width: 22.0, height: 22.0)) + } + + if let animationCache = arguments.animationCache, let animationRenderer = arguments.animationRenderer { + let titleTopicIconComponent = EmojiStatusComponent( + context: arguments.context, + animationCache: animationCache, + animationRenderer: animationRenderer, + content: titleTopicIconContent, + isVisibleForAnimations: node.visibility, + action: nil + ) + node.titleTopicIconComponent = titleTopicIconComponent + + let iconSize = titleTopicIconView.update( + transition: .immediate, + component: AnyComponent(titleTopicIconComponent), + environment: {}, + containerSize: containerSize + ) + + let iconY: CGFloat + if let firstLineMidY = firstLineMidY { + iconY = floorToScreenPixels(firstLineMidY - iconSize.height / 2.0) + } else { + iconY = 0.0 + } + + titleTopicIconView.frame = CGRect(origin: CGPoint(x: insets.left + iconX, y: insets.top + iconY), size: iconSize) + } } - let textFrame = CGRect(origin: CGPoint(x: iconSize.width + 2.0 + insets.left, y: insets.top), size: textLayout.size) + let textFrame = CGRect(origin: CGPoint(x: iconSize.width + (spacing - 2.0) + insets.left, y: insets.top), size: textLayout.size) textNode.textNode.frame = textFrame if let arrowIcon = arrowIcon, let firstLine = lineRects.first, let lastLine = lineRects.last { diff --git a/submodules/TelegramUI/Components/Chat/FactCheckAlertController/Sources/FactCheckAlertController.swift b/submodules/TelegramUI/Components/Chat/FactCheckAlertController/Sources/FactCheckAlertController.swift index 023ad26a55..869ea6242a 100644 --- a/submodules/TelegramUI/Components/Chat/FactCheckAlertController/Sources/FactCheckAlertController.swift +++ b/submodules/TelegramUI/Components/Chat/FactCheckAlertController/Sources/FactCheckAlertController.swift @@ -219,7 +219,7 @@ private final class FactCheckAlertContentNode: AlertContentNode { customInputView: nil, resetText: nil, isOneLineWhenUnfocused: false, - characterLimit: nil, + characterLimit: 1024, emptyLineHandling: .oneConsecutive, formatMenuAvailability: .available([.bold, .italic, .link]), returnKeyType: .done, diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 12024e3f88..ebf1719ee4 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -97,11 +97,13 @@ public struct NavigateToMessageParams { public var timestamp: Double? public var quote: Quote? public var progress: Promise? + public var forceNew: Bool - public init(timestamp: Double?, quote: Quote?, progress: Promise? = nil) { + public init(timestamp: Double?, quote: Quote?, progress: Promise? = nil, forceNew: Bool = false) { self.timestamp = timestamp self.quote = quote self.progress = progress + self.forceNew = forceNew } } diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift index fa47f65448..56612ab51f 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift @@ -1828,6 +1828,8 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { switch customChatContents.kind { case .quickReplyMessageInput: break + case .hashTagSearch: + break case .businessLinkSetup: stickerContent = nil gifContent = nil diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift index 2779b41157..b280a5231f 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift @@ -249,6 +249,8 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { fileType = .sticker } self.content = .file(.standalone(media: file), fileType) + } else if let dataPath = try container.decodeIfPresent(String.self, forKey: .animatedImagePath), let data = try? Data(contentsOf: URL(fileURLWithPath: fullEntityMediaPath(dataPath))), let imagePath = try container.decodeIfPresent(String.self, forKey: .imagePath), let thumbnailImage = UIImage(contentsOfFile: fullEntityMediaPath(imagePath)) { + self.content = .animatedImage(data, thumbnailImage) } else if let imagePath = try container.decodeIfPresent(String.self, forKey: .imagePath), let image = UIImage(contentsOfFile: fullEntityMediaPath(imagePath)) { let isRectangle = try container.decodeIfPresent(Bool.self, forKey: .isRectangle) ?? false let isDualPhoto = try container.decodeIfPresent(Bool.self, forKey: .isDualPhoto) ?? false @@ -261,8 +263,6 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { imageType = .sticker } self.content = .image(image, imageType) - } else if let dataPath = try container.decodeIfPresent(String.self, forKey: .animatedImagePath), let data = try? Data(contentsOf: URL(fileURLWithPath: fullEntityMediaPath(dataPath))), let imagePath = try container.decodeIfPresent(String.self, forKey: .imagePath), let thumbnailImage = UIImage(contentsOfFile: fullEntityMediaPath(imagePath)) { - self.content = .animatedImage(data, thumbnailImage) } else if let file = try container.decodeIfPresent(TelegramMediaFile.self, forKey: .videoFile) { self.content = .video(file) } else { @@ -407,3 +407,61 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { return true } } + +public extension UIImage { + class func animatedImageFromData(data: Data) -> DrawingAnimatedImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } + + let count = CGImageSourceGetCount(source) + var images = [UIImage]() + var duration = 0.0 + + for i in 0.. Double { + var delay = 0.0 + guard #available(iOS 13.0, *) else { + return delay + } + + let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) + let gifPropertiesPointer = UnsafeMutablePointer.allocate(capacity: 0) + if CFDictionaryGetValueIfPresent(cfProperties, Unmanaged.passUnretained(kCGImagePropertyHEICSDictionary).toOpaque(), gifPropertiesPointer) == false { + return delay + } + + let gifProperties:CFDictionary = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self) + + var delayObject: AnyObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyHEICSUnclampedDelayTime).toOpaque()), to: AnyObject.self) + if delayObject.doubleValue == 0 { + delayObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyHEICSDelayTime).toOpaque()), to: AnyObject.self) + } + + delay = delayObject as? Double ?? 0 + + return delay + } +} + +public final class DrawingAnimatedImage { + public let images: [UIImage] + public let duration: Double + + init(images: [UIImage], duration: Double) { + self.images = images + self.duration = duration + } +} diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift index c41ecc71a7..99dc63546e 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift @@ -67,14 +67,19 @@ func composerEntitiesForDrawingEntity(postbox: Postbox, textScale: CGFloat, enti return [] } else { let content: MediaEditorComposerStickerEntity.Content + var scale = entity.scale switch entity.content { case let .file(file, _): content = .file(file.media) case let .image(image, _): content = .image(image) case let .animatedImage(data, _): - let _ = data - return [] + if let animatedImage = UIImage.animatedImageFromData(data: data) { + content = .animatedImage(animatedImage.images, animatedImage.duration) + scale *= 1.0 + } else { + return [] + } case let .video(file): content = .video(file) case .dualVideoReference: @@ -93,7 +98,7 @@ func composerEntitiesForDrawingEntity(postbox: Postbox, textScale: CGFloat, enti return [] } } - return [MediaEditorComposerStickerEntity(postbox: postbox, content: content, position: entity.position, scale: entity.scale, rotation: entity.rotation, baseSize: entity.baseSize, mirrored: entity.mirrored, colorSpace: colorSpace, tintColor: tintColor, isStatic: entity.isExplicitlyStatic)] + return [MediaEditorComposerStickerEntity(postbox: postbox, content: content, position: entity.position, scale: scale, rotation: entity.rotation, baseSize: entity.baseSize, mirrored: entity.mirrored, colorSpace: colorSpace, tintColor: tintColor, isStatic: entity.isExplicitlyStatic)] } } else if let renderImage = entity.renderImage, let image = CIImage(image: renderImage, options: [.colorSpace: colorSpace]) { if let entity = entity as? DrawingBubbleEntity { @@ -296,8 +301,9 @@ final class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { self.imagePromise.set(.single(image)) case let .animatedImage(images, duration): self.isAnimated = true - let _ = images - let _ = duration + self.videoFrameRate = Float(images.count) / Float(duration) + self.totalDuration = duration + self.durationPromise.set(.single(duration)) case .video: self.isAnimated = true } @@ -338,7 +344,37 @@ final class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { func image(for time: CMTime, frameRate: Float, context: CIContext, completion: @escaping (CIImage?) -> Void) { let currentTime = CMTimeGetSeconds(time) - if case .video = self.content { + if case let .animatedImage(images, _) = self.content { + var frameAdvancement: Int = 0 + if let frameRate = self.videoFrameRate, frameRate > 0 { + let frameTime = 1.0 / Double(frameRate) + let frameIndex = Int(floor(currentTime / frameTime)) + + let currentFrameIndex = self.currentFrameIndex + if currentFrameIndex != frameIndex { + let previousFrameIndex = currentFrameIndex + self.currentFrameIndex = frameIndex + + var delta = 1 + if let previousFrameIndex = previousFrameIndex { + delta = max(1, frameIndex - previousFrameIndex) + } + frameAdvancement = delta + } + } + if frameAdvancement == 0, let image = self.image { + completion(image) + return + } else if let currentFrameIndex = self.currentFrameIndex { + let index = currentFrameIndex % images.count + var image = images[index] + image = generateScaledImage(image: images[index], size: image.size.aspectFitted(CGSize(width: 384, height: 384)), opaque: false, scale: 1.0)! + let ciImage = CIImage(image: image) + self.image = ciImage + completion(ciImage) + return + } + } else if case .video = self.content { if self.videoOutput == nil { self.setupVideoOutput() } @@ -475,7 +511,6 @@ final class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { let options = NSMutableDictionary() options.setObject(ioSurfaceProperties, forKey: kCVPixelBufferIOSurfacePropertiesKey as NSString) - var pixelBuffer: CVPixelBuffer? CVPixelBufferCreate( kCFAllocatorDefault, diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift index 0c44d4b0d3..c3333137e2 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoChatListPaneNode/Sources/PeerInfoChatListPaneNode.swift @@ -402,7 +402,7 @@ public final class PeerInfoChatListPaneNode: ASDisplayNode, PeerInfoPaneNode, AS if self.chatController == nil { let chatController = self.context.sharedContext.makeChatController(context: self.context, chatLocation: .peer(id: self.context.account.peerId), subject: nil, botStart: nil, mode: .standard(.embedded(invertDirection: false))) chatController.alwaysShowSearchResultsAsList = true - + chatController.includeSavedPeersInSearchResults = true self.chatController = chatController chatController.navigation_setNavigationController(self.navigationController()) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift index aa8e3b2029..d9e9c17fe1 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift @@ -800,6 +800,12 @@ func peerInfoScreenSettingsData(context: AccountContext, peerId: EnginePeer.Id, let starsState: Signal if let starsContext { starsState = starsContext.state + |> map { state in + if let state, state.balance > 0 || !state.transactions.isEmpty { + return state + } + return nil + } } else { starsState = .single(nil) } @@ -875,7 +881,8 @@ func peerInfoScreenSettingsData(context: AccountContext, peerId: EnginePeer.Id, bots: bots, hasPassport: hasPassport, hasWatchApp: hasWatchApp, - enableQRLogin: enableQRLogin) + enableQRLogin: enableQRLogin + ) return PeerInfoScreenData( peer: peer, diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index dab75b4fc0..b8becd4489 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -979,16 +979,17 @@ private func settingsItems(data: PeerInfoScreenData?, context: AccountContext, p items[.payment]!.append(PeerInfoScreenDisclosureItem(id: 100, label: .text(""), text: presentationData.strings.Settings_Premium, icon: PresentationResourcesSettings.premium, action: { interaction.openSettings(.premium) })) - //TODO:localize - let balanceText: String - if let balance = data.starsState?.balance, balance > 0 { - balanceText = "\(balance)" - } else { - balanceText = "" + if let starsState = data.starsState { + let balanceText: String + if starsState.balance > 0 { + balanceText = "\(starsState.balance)" + } else { + balanceText = "" + } + items[.payment]!.append(PeerInfoScreenDisclosureItem(id: 102, label: .text(balanceText), text: presentationData.strings.Settings_Stars, icon: PresentationResourcesSettings.stars, action: { + interaction.openSettings(.stars) + })) } - items[.payment]!.append(PeerInfoScreenDisclosureItem(id: 102, label: .text(balanceText), text: "Your Stars", icon: PresentationResourcesSettings.stars, action: { - interaction.openSettings(.stars) - })) items[.payment]!.append(PeerInfoScreenDisclosureItem(id: 103, label: .text(""), additionalBadgeLabel: presentationData.strings.Settings_New, text: presentationData.strings.Settings_Business, icon: PresentationResourcesSettings.business, action: { interaction.openSettings(.businessSetup) })) diff --git a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupChatContents.swift b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupChatContents.swift index cde85a0476..66e706dddf 100644 --- a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupChatContents.swift +++ b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/AutomaticBusinessMessageSetupChatContents.swift @@ -215,6 +215,8 @@ final class AutomaticBusinessMessageSetupChatContents: ChatCustomContentsProtoco initialShortcut = shortcut case .businessLinkSetup: initialShortcut = "" + case .hashTagSearch: + initialShortcut = "" } let queue = Queue() @@ -251,9 +253,19 @@ final class AutomaticBusinessMessageSetupChatContents: ChatCustomContentsProtoco } case .businessLinkSetup: break + case .hashTagSearch: + break } } func businessLinkUpdate(message: String, entities: [MessageTextEntity], title: String?) { } + + func loadMore() { + } + + func hashtagSearchUpdate(query: String) { + } + + var hashtagSearchResultsUpdate: ((SearchMessagesResult, SearchMessagesState)) -> Void = { _ in } } diff --git a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/BusinessLinkChatContents.swift b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/BusinessLinkChatContents.swift index 3b69806c61..13f07d581f 100644 --- a/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/BusinessLinkChatContents.swift +++ b/submodules/TelegramUI/Components/Settings/AutomaticBusinessMessageSetupScreen/Sources/BusinessLinkChatContents.swift @@ -85,4 +85,12 @@ final class BusinessLinkChatContents: ChatCustomContentsProtocol { )) } } + + func loadMore() { + } + + func hashtagSearchUpdate(query: String) { + } + + var hashtagSearchResultsUpdate: ((SearchMessagesResult, SearchMessagesState)) -> Void = { _ in } } diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/ClearRecent.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/ClearRecent.imageset/Contents.json new file mode 100644 index 0000000000..0c68b117b1 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/ClearRecent.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "clearrecents_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/ClearRecent.imageset/clearrecents_30.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/ClearRecent.imageset/clearrecents_30.pdf new file mode 100644 index 0000000000..cf8a1640fc --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/ClearRecent.imageset/clearrecents_30.pdf @@ -0,0 +1,62 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Filter /FlateDecode + /Length 3 0 R + >> +stream +xeˎ0 yI'vxjH3#x~4.+}|?o_>>>}OxKS`g(gؑTŬY:h7tj0 ;_HёnWvM  DfVۣ *YkE:b\q}V?6sL +< +Yf99PR@nL5i{2D ??C&$ ,֊ 7hCNQZkT`c4KQ +T!PBk> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000000702 00000 n +0000000724 00000 n +0000000897 00000 n +0000000971 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1030 +%%EOF \ No newline at end of file diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/Contents.json new file mode 100644 index 0000000000..6e965652df --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/EmptyHashtag.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/EmptyHashtag.imageset/Contents.json new file mode 100644 index 0000000000..19cb866d35 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/EmptyHashtag.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tagempty_80.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/EmptyHashtag.imageset/tagempty_80.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/EmptyHashtag.imageset/tagempty_80.pdf new file mode 100644 index 0000000000..ff7a6c11ef Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/EmptyHashtag.imageset/tagempty_80.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/RecentHashtag.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/RecentHashtag.imageset/Contents.json new file mode 100644 index 0000000000..dc214f3c0e --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/RecentHashtag.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tagsearch_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/RecentHashtag.imageset/tagsearch_30.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/RecentHashtag.imageset/tagsearch_30.pdf new file mode 100644 index 0000000000..4854fca9ab Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Chat/Hashtag/RecentHashtag.imageset/tagsearch_30.pdf differ diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index 6b8f550ef3..ed51dee3b1 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -1269,11 +1269,14 @@ extension ChatControllerImpl { donateSendMessageIntent(account: strongSelf.context.account, sharedContext: strongSelf.context.sharedContext, intentContext: .chat, peerIds: [peerId]) } else if case let .customChatContents(customChatContents) = strongSelf.subject { switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput: customChatContents.enqueueMessages(messages: messages) strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() case let .businessLinkSetup(link): if messages.count > 1 { + //TODO:localize strongSelf.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: strongSelf.presentationData), title: nil, text: "The message text limit is 4096 characters", actions: [ TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {}) ]), in: .window(.root)) @@ -1306,6 +1309,40 @@ extension ChatControllerImpl { strongSelf.updateChatPresentationInterfaceState(interactive: true, { $0.updatedShowCommands(false) }) } + if case let .customChatContents(customChatContents) = self.subject { + customChatContents.hashtagSearchResultsUpdate = { [weak self] searchResult in + guard let self else { + return + } + let (results, state) = searchResult + let isEmpty = results.totalCount == 0 + if isEmpty { + self.alwaysShowSearchResultsAsList = true + } + self.updateChatPresentationInterfaceState(animated: true, interactive: true, { current in + var updatedState = current + if let data = current.search { + let messageIndices = results.messages.map({ $0.index }).sorted() + var currentIndex = messageIndices.last + if let previousResultId = data.resultsState?.currentId { + for index in messageIndices { + if index.id >= previousResultId { + currentIndex = index + break + } + } + } + updatedState = updatedState.updatedSearch(data.withUpdatedResultsState(ChatSearchResultsState(messageIndices: messageIndices, currentId: currentIndex?.id, state: state, totalCount: results.totalCount, completed: results.completed))) + } + if isEmpty { + updatedState = updatedState.updatedDisplayHistoryFilterAsList(true) + } + return updatedState + }) + self.searchResult.set(.single((results, state, .general(scope: .channels, tags: nil, minDate: nil, maxDate: nil)))) + } + } + self.chatDisplayNode.requestUpdateChatInterfaceState = { [weak self] transition, saveInterfaceState, f in self?.updateChatPresentationInterfaceState(transition: transition, interactive: true, saveInterfaceState: saveInterfaceState, { $0.updatedInterfaceState(f) }) } @@ -1364,6 +1401,8 @@ extension ChatControllerImpl { self?.enqueueGifData(data) case let .sticker(image, isMemoji): self?.enqueueStickerImage(image, isMemoji: isMemoji) + case let .animatedSticker(data): + self?.enqueueAnimatedStickerData(data) } } self.chatDisplayNode.updateTypingActivity = { [weak self] value in @@ -1442,7 +1481,9 @@ extension ChatControllerImpl { return } - if let resultsState = self.presentationInterfaceState.search?.resultsState, !resultsState.messageIndices.isEmpty { + if case let .customChatContents(contents) = self.presentationInterfaceState.subject, case .hashTagSearch = contents.kind { + self.chatDisplayNode.historyNode.scrollToEndOfHistory() + } else if let resultsState = self.presentationInterfaceState.search?.resultsState, !resultsState.messageIndices.isEmpty { if let currentId = resultsState.currentId, let index = resultsState.messageIndices.firstIndex(where: { $0.id == currentId }) { if index != resultsState.messageIndices.count - 1 { self.interfaceInteraction?.navigateMessageSearch(.later) diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerNavigateToMessage.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerNavigateToMessage.swift index 092847181d..d53572aab0 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerNavigateToMessage.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerNavigateToMessage.swift @@ -29,7 +29,7 @@ extension ChatControllerImpl { guard let self else { return } - self.navigateToMessage(from: fromId, to: .id(id, params), forceInCurrentChat: fromId.peerId == id.peerId) + self.navigateToMessage(from: fromId, to: .id(id, params), forceInCurrentChat: fromId.peerId == id.peerId && !params.forceNew, forceNew: params.forceNew) } let _ = (self.context.engine.data.get( @@ -72,6 +72,7 @@ extension ChatControllerImpl { scrollPosition: ListViewScrollPosition = .center(.bottom), rememberInStack: Bool = true, forceInCurrentChat: Bool = false, + forceNew: Bool = false, dropStack: Bool = false, animated: Bool = true, completion: (() -> Void)? = nil, @@ -104,11 +105,11 @@ extension ChatControllerImpl { if case let .peer(peerId) = self.chatLocation, messageLocation.peerId == peerId, !isPinnedMessages, !isScheduledMessages { forceInCurrentChat = true } - if case .customChatContents = self.chatLocation { + if case .customChatContents = self.chatLocation, !forceNew { forceInCurrentChat = true } - if isPinnedMessages, let messageId = messageLocation.messageId { + if isPinnedMessages || forceNew, let messageId = messageLocation.messageId { let _ = (combineLatest( self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId)), self.context.engine.messages.getMessagesLoadIfNecessary([messageId], strategy: .local) diff --git a/submodules/TelegramUI/Sources/Chat/UpdateChatPresentationInterfaceState.swift b/submodules/TelegramUI/Sources/Chat/UpdateChatPresentationInterfaceState.swift index 09780c583b..71709a4294 100644 --- a/submodules/TelegramUI/Sources/Chat/UpdateChatPresentationInterfaceState.swift +++ b/submodules/TelegramUI/Sources/Chat/UpdateChatPresentationInterfaceState.swift @@ -223,6 +223,8 @@ func updateChatPresentationInterfaceStateImpl( var canHaveUrlPreview = true if case let .customChatContents(customChatContents) = updatedChatPresentationInterfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput: break case .businessLinkSetup: diff --git a/submodules/TelegramUI/Sources/ChatBusinessLinkTitlePanelNode.swift b/submodules/TelegramUI/Sources/ChatBusinessLinkTitlePanelNode.swift index fbe13ebdb1..b17db6edb0 100644 --- a/submodules/TelegramUI/Sources/ChatBusinessLinkTitlePanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatBusinessLinkTitlePanelNode.swift @@ -194,6 +194,8 @@ final class ChatBusinessLinkTitlePanelNode: ChatTitleAccessoryPanelNode { break case let .businessLinkSetup(link): self.link = link + case .hashTagSearch: + break } default: break diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 55f308f31c..6dcb92f230 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -113,6 +113,7 @@ import ChatNavigationButton import WebsiteType import ChatQrCodeScreen import PeerInfoScreen +import MediaEditor import MediaEditorScreen import WallpaperGalleryScreen import WallpaperGridScreen @@ -321,7 +322,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let editingMessage = ValuePromise(nil, ignoreRepeated: true) let startingBot = ValuePromise(false, ignoreRepeated: true) let unblockingPeer = ValuePromise(false, ignoreRepeated: true) - let searching = ValuePromise(false, ignoreRepeated: true) + public let searching = ValuePromise(false, ignoreRepeated: true) let searchResult = Promise<(SearchMessagesResult, SearchMessagesState, SearchMessagesLocation)?>() let loadingMessage = Promise(nil) let performingInlineSearch = ValuePromise(false, ignoreRepeated: true) @@ -596,6 +597,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var networkSpeedEventsDisposable: Disposable? + var stickerVideoExport: MediaEditorVideoExport? + var messageComposeController: MFMessageComposeViewController? public var alwaysShowSearchResultsAsList: Bool = false { @@ -605,6 +608,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } + public var includeSavedPeersInSearchResults: Bool = false { + didSet { + self.chatDisplayNode.includeSavedPeersInSearchResults = self.includeSavedPeersInSearchResults + } + } + public init(context: AccountContext, chatLocation: ChatLocation, chatLocationContextHolder: Atomic = Atomic(value: nil), subject: ChatControllerSubject? = nil, botStart: ChatControllerInitialBotStart? = nil, attachBotStart: ChatControllerInitialAttachBotStart? = nil, botAppStart: ChatControllerInitialBotAppStart? = nil, mode: ChatControllerPresentationMode = .standard(.default), peekData: ChatPeekTimeout? = nil, peerNearbyData: ChatPeerNearbyData? = nil, chatListFilter: Int32? = nil, chatNavigationStack: [ChatNavigationStackItem] = [], customChatNavigationStack: [EnginePeer.Id]? = nil) { let _ = ChatControllerCount.modify { value in return value + 1 @@ -684,6 +693,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return interfaceState.withUpdatedEffectiveInputState(ChatTextInputState(inputText: chatInputStateStringWithAppliedEntities(link.message, entities: link.entities))) }) } + case .hashTagSearch: + break } } @@ -745,6 +756,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if case let .customChatContents(customChatContents) = strongSelf.presentationInterfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + return true case let .quickReplyMessageInput(_, shortcutType): if let historyView = strongSelf.chatDisplayNode.historyNode.originalHistoryView, historyView.entries.isEmpty { @@ -6449,6 +6462,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if case let .customChatContents(customChatContents) = self.subject { switch customChatContents.kind { + case .hashTagSearch: + break case let .quickReplyMessageInput(shortcut, shortcutType): switch shortcutType { case .generic: @@ -9286,7 +9301,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } })) } - + func enqueueChatContextResult(_ results: ChatContextResultCollection, _ result: ChatContextResult, hideVia: Bool = false, closeMediaInput: Bool = false, silentPosting: Bool = false, resetTextInputState: Bool = true) { if !canSendMessagesToChat(self.presentationInterfaceState) { return @@ -9385,14 +9400,18 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G func updateDownButtonVisibility() { let recordingMediaMessage = self.audioRecorderValue != nil || self.videoRecorderValue != nil || self.presentationInterfaceState.interfaceState.mediaDraftState != nil - if let search = self.presentationInterfaceState.search, let results = search.resultsState, results.messageIndices.count != 0 { + var ignoreSearchState = false + if case let .customChatContents(contents) = self.subject, case .hashTagSearch = contents.kind { + ignoreSearchState = true + } + + if !ignoreSearchState, let search = self.presentationInterfaceState.search, let results = search.resultsState, results.messageIndices.count != 0 { var resultIndex: Int? if let currentId = results.currentId, let index = results.messageIndices.firstIndex(where: { $0.id == currentId }) { resultIndex = index } else { resultIndex = nil } - if let resultIndex { self.chatDisplayNode.navigateButtons.directionButtonState = ChatHistoryNavigationButtons.DirectionState( up: ChatHistoryNavigationButtons.ButtonState(isEnabled: resultIndex != 0), @@ -9536,7 +9555,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var cancelImpl: (() -> Void)? let presentationData = self.presentationData let progressSignal = Signal { [weak self] subscriber in - let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { + let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { cancelImpl?() })) self?.present(controller, in: .window(.root)) @@ -9547,7 +9566,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } |> runOn(Queue.mainQueue()) - |> delay(0.15, queue: Queue.mainQueue()) + |> delay(0.25, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() resolveSignal = resolveSignal diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index ed4d9de045..18f4345f09 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -137,6 +137,8 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { private(set) var loadingPlaceholderNode: ChatLoadingPlaceholderNode? var alwaysShowSearchResultsAsList: Bool = false + var includeSavedPeersInSearchResults: Bool = false + private var skippedShowSearchResultsAsListAnimationOnce: Bool = false var inlineSearchResults: ComponentView? private var inlineSearchResultsReadyDisposable: Disposable? @@ -989,7 +991,13 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { let emptyNode = ChatEmptyNode(context: self.context, interaction: self.interfaceInteraction) emptyNode.isHidden = self.restrictedNode != nil self.emptyNode = emptyNode - self.historyNodeContainer.supernode?.insertSubnode(emptyNode, aboveSubnode: self.historyNodeContainer) + + if let inlineSearchResultsView = self.inlineSearchResults?.view { + self.contentContainerNode.view.insertSubview(emptyNode.view, belowSubview: inlineSearchResultsView) + } else { + self.contentContainerNode.insertSubnode(emptyNode, aboveSubnode: self.historyNodeContainer) + } + if let (size, insets) = self.validEmptyNodeLayout { let mappedType: ChatEmptyNode.Subject.EmptyType switch emptyType { @@ -2498,7 +2506,9 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } } - if let peerId = self.chatPresentationInterfaceState.chatLocation.peerId, displayInlineSearch { + if displayInlineSearch { + let peerId = self.chatPresentationInterfaceState.chatLocation.peerId + let inlineSearchResults: ComponentView var inlineSearchResultsTransition = Transition(transition) if let current = self.inlineSearchResults { @@ -2511,12 +2521,12 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { let mappedContents: ChatInlineSearchResultsListComponent.Contents if let _ = self.chatPresentationInterfaceState.search?.resultsState { - mappedContents = .search(query: self.chatPresentationInterfaceState.search?.query ?? "", includeSavedPeers: self.alwaysShowSearchResultsAsList) + mappedContents = .search(query: self.chatPresentationInterfaceState.search?.query ?? "", includeSavedPeers: self.alwaysShowSearchResultsAsList && self.includeSavedPeersInSearchResults) } else if let historyFilter = self.chatPresentationInterfaceState.historyFilter { mappedContents = .tag(historyFilter.customTag) } else if let search = self.chatPresentationInterfaceState.search, self.alwaysShowSearchResultsAsList { if !search.query.isEmpty { - mappedContents = .search(query: search.query, includeSavedPeers: self.alwaysShowSearchResultsAsList) + mappedContents = .search(query: search.query, includeSavedPeers: self.alwaysShowSearchResultsAsList && self.includeSavedPeersInSearchResults) } else { mappedContents = .empty } @@ -2529,6 +2539,11 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { let context = self.context let chatLocation = self.chatLocation + var showEmptyResults = false + if case let .customChatContents(contents) = self.chatPresentationInterfaceState.subject, case .hashTagSearch = contents.kind { + showEmptyResults = true + } + let _ = inlineSearchResults.update( transition: inlineSearchResultsTransition, component: AnyComponent(ChatInlineSearchResultsListComponent( @@ -2544,6 +2559,8 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { peerId: peerId, contents: mappedContents, insets: childContentInsets, + inputHeight: layout.inputHeight ?? 0.0, + showEmptyResults: showEmptyResults, messageSelected: { [weak self] message in guard let self else { return @@ -2731,46 +2748,51 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { guard let self, let controller = self.controller else { return } - guard let currentSearchState = controller.searchState, let currentResultsState = controller.presentationInterfaceState.search?.resultsState else { - return - } - self.loadMoreSearchResultsDisposable?.dispose() - self.loadMoreSearchResultsDisposable = (self.context.engine.messages.searchMessages(location: currentSearchState.location, query: currentSearchState.query, state: currentResultsState.state) - |> deliverOnMainQueue).startStrict(next: { [weak self] results, updatedState in - guard let self, let controller = self.controller else { + if case let .customChatContents(contents) = self.chatPresentationInterfaceState.subject { + contents.loadMore() + } else { + guard let currentSearchState = controller.searchState, let currentResultsState = controller.presentationInterfaceState.search?.resultsState else { return } - controller.searchResult.set(.single((results, updatedState, currentSearchState.location))) - - var navigateIndex: MessageIndex? - controller.updateChatPresentationInterfaceState(animated: true, interactive: true, { current in - if let data = current.search { - let messageIndices = results.messages.map({ $0.index }).sorted() - var currentIndex = messageIndices.last - if let previousResultId = data.resultsState?.currentId { - for index in messageIndices { - if index.id >= previousResultId { - currentIndex = index - break + self.loadMoreSearchResultsDisposable?.dispose() + self.loadMoreSearchResultsDisposable = (self.context.engine.messages.searchMessages(location: currentSearchState.location, query: currentSearchState.query, state: currentResultsState.state) + |> deliverOnMainQueue).startStrict(next: { [weak self] results, updatedState in + guard let self, let controller = self.controller else { + return + } + + controller.searchResult.set(.single((results, updatedState, currentSearchState.location))) + + var navigateIndex: MessageIndex? + controller.updateChatPresentationInterfaceState(animated: true, interactive: true, { current in + if let data = current.search { + let messageIndices = results.messages.map({ $0.index }).sorted() + var currentIndex = messageIndices.last + if let previousResultId = data.resultsState?.currentId { + for index in messageIndices { + if index.id >= previousResultId { + currentIndex = index + break + } } } + navigateIndex = currentIndex + return current.updatedSearch(data.withUpdatedResultsState(ChatSearchResultsState(messageIndices: messageIndices, currentId: currentIndex?.id, state: updatedState, totalCount: results.totalCount, completed: results.completed))) + } else { + return current + } + }) + if let navigateIndex = navigateIndex { + switch controller.chatLocation { + case .peer, .replyThread, .customChatContents: + controller.navigateToMessage(from: nil, to: .index(navigateIndex), forceInCurrentChat: true) } - navigateIndex = currentIndex - return current.updatedSearch(data.withUpdatedResultsState(ChatSearchResultsState(messageIndices: messageIndices, currentId: currentIndex?.id, state: updatedState, totalCount: results.totalCount, completed: results.completed))) - } else { - return current } + controller.updateItemNodesSearchTextHighlightStates() }) - if let navigateIndex = navigateIndex { - switch controller.chatLocation { - case .peer, .replyThread, .customChatContents: - controller.navigateToMessage(from: nil, to: .index(navigateIndex), forceInCurrentChat: true) - } - } - controller.updateItemNodesSearchTextHighlightStates() - }) + } } )), environment: {}, @@ -3108,6 +3130,10 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { self.controller?.customNavigationBarContentNode = self.searchNavigationNode } self.searchNavigationNode?.update(presentationInterfaceState: self.chatPresentationInterfaceState) + + if case let .customChatContents(contents) = self.chatPresentationInterfaceState.subject, case .hashTagSearch = contents.kind { + activate = false + } if activate { self.searchNavigationNode?.activate() } @@ -3666,7 +3692,11 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { } } } else { - self.historyNode.scrollScreenToTop() + if let inlineSearchResultsView = self.inlineSearchResults?.view as? ChatInlineSearchResultsListComponent.View { + inlineSearchResultsView.scrollToTop() + } else { + self.historyNode.scrollScreenToTop() + } } } @@ -4024,6 +4054,8 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { var postEmptyMessages = false if case let .customChatContents(customChatContents) = self.chatPresentationInterfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput: break case .businessLinkSetup: diff --git a/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift b/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift index 171c9805d2..292b53b3d9 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift @@ -57,6 +57,8 @@ func serviceTasksForChatPresentationIntefaceState(context: AccountContext, chatP func inputContextQueriesForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState) -> [ChatPresentationInputQuery] { if case let .customChatContents(customChatContents) = chatPresentationInterfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + return [] case .quickReplyMessageInput: break case .businessLinkSetup: @@ -231,6 +233,8 @@ func inputTextPanelStateForChatPresentationInterfaceState(_ chatPresentationInte if case let .customChatContents(customChatContents) = chatPresentationInterfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput: break case .businessLinkSetup: diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index f96d735bbf..a317165c29 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -1988,6 +1988,8 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState actions.removeAll() switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput: if !messageText.isEmpty || (resourceAvailable && isImage) || diceEmoji != nil { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuCopy, icon: { theme in diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateInputPanels.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateInputPanels.swift index 98cef19649..467dedcb39 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateInputPanels.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateInputPanels.swift @@ -52,7 +52,12 @@ func inputPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState currentPanel.interfaceInteraction = interfaceInteraction return (currentPanel, selectionPanel) } else { - let panel = ChatTagSearchInputPanelNode(theme: chatPresentationInterfaceState.theme) + var alwaysShowTotalMessagesCount = false + if case let .customChatContents(contents) = chatPresentationInterfaceState.subject, case .hashTagSearch = contents.kind { + alwaysShowTotalMessagesCount = true + } + + let panel = ChatTagSearchInputPanelNode(theme: chatPresentationInterfaceState.theme, alwaysShowTotalMessagesCount: alwaysShowTotalMessagesCount) panel.context = context panel.interfaceInteraction = interfaceInteraction return (panel, selectionPanel) @@ -403,6 +408,8 @@ func inputPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState if case let .customChatContents(customChatContents) = chatPresentationInterfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + displayInputTextPanel = false case .quickReplyMessageInput, .businessLinkSetup: displayInputTextPanel = true } diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift index b96441ca5f..32d85e591d 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift @@ -57,6 +57,8 @@ func leftNavigationButtonForChatInterfaceState(_ presentationInterfaceState: Cha if case let .customChatContents(customChatContents) = presentationInterfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput, .businessLinkSetup: if let currentButton = currentButton, currentButton.action == .dismiss { return currentButton @@ -124,6 +126,8 @@ func rightNavigationButtonForChatInterfaceState(context: AccountContext, present if case let .customChatContents(customChatContents) = presentationInterfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + return nil case let .quickReplyMessageInput(_, shortcutType): switch shortcutType { case .generic: diff --git a/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift b/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift index 2f8c67a0d9..67d0684389 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift @@ -54,6 +54,8 @@ func titlePanelForChatPresentationInterfaceState(_ chatPresentationInterfaceStat inhibitTitlePanelDisplay = true case let .customChatContents(customChatContents): switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput: break case .businessLinkSetup: diff --git a/submodules/TelegramUI/Sources/ChatRestrictedInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatRestrictedInputPanelNode.swift index 5ba7e6c67b..0e529f49c8 100644 --- a/submodules/TelegramUI/Sources/ChatRestrictedInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatRestrictedInputPanelNode.swift @@ -103,6 +103,8 @@ final class ChatRestrictedInputPanelNode: ChatInputPanelNode { } else if case let .customChatContents(customChatContents) = interfaceState.subject { let displayCount: Int switch customChatContents.kind { + case .hashTagSearch: + displayCount = 0 case .quickReplyMessageInput: displayCount = customChatContents.messageLimit ?? 20 case .businessLinkSetup: diff --git a/submodules/TelegramUI/Sources/ChatTagSearchInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTagSearchInputPanelNode.swift index 08755943c2..ae0a6a3767 100644 --- a/submodules/TelegramUI/Sources/ChatTagSearchInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTagSearchInputPanelNode.swift @@ -91,6 +91,8 @@ final class ChatTagSearchInputPanelNode: ChatInputPanelNode { private var isUpdating: Bool = false + private var alwaysShowTotalMessagesCount = false + private var currentLayout: Layout? private var tagMessageCount: (tag: MemoryBuffer, count: Int?, disposable: Disposable?)? @@ -103,7 +105,9 @@ final class ChatTagSearchInputPanelNode: ChatInputPanelNode { } } - init(theme: PresentationTheme) { + init(theme: PresentationTheme, alwaysShowTotalMessagesCount: Bool) { + self.alwaysShowTotalMessagesCount = alwaysShowTotalMessagesCount + super.init() } @@ -224,7 +228,7 @@ final class ChatTagSearchInputPanelNode: ChatInputPanelNode { if let currentId = results.currentId, let index = results.messageIndices.firstIndex(where: { $0.id == currentId }) { canChangeListMode = true - if params.interfaceState.displayHistoryFilterAsList { + if params.interfaceState.displayHistoryFilterAsList || self.alwaysShowTotalMessagesCount { resultsTextString = extractAnimatedTextString(string: params.interfaceState.strings.Chat_BottomSearchPanel_MessageCountFormat( ".", "." @@ -332,36 +336,41 @@ final class ChatTagSearchInputPanelNode: ChatInputPanelNode { } } - var nextLeftX: CGFloat = 12.0 + var nextLeftX: CGFloat = 16.0 - let calendarButtonSize = self.calendarButton.update( - transition: .immediate, - component: AnyComponent(PlainButtonComponent( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Search/Calendar", - tintColor: params.interfaceState.theme.rootController.navigationBar.accentTextColor - )), - effectAlignment: .center, - minSize: CGSize(width: 1.0, height: size.height), - contentInsets: UIEdgeInsets(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0), - action: { [weak self] in - guard let self else { - return + if !self.alwaysShowTotalMessagesCount { + nextLeftX = 12.0 + let calendarButtonSize = self.calendarButton.update( + transition: .immediate, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Search/Calendar", + tintColor: params.interfaceState.theme.rootController.navigationBar.accentTextColor + )), + effectAlignment: .center, + minSize: CGSize(width: 1.0, height: size.height), + contentInsets: UIEdgeInsets(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0), + action: { [weak self] in + guard let self else { + return + } + self.interfaceInteraction?.openCalendarSearch() } - self.interfaceInteraction?.openCalendarSearch() + )), + environment: {}, + containerSize: size + ) + let calendarButtonFrame = CGRect(origin: CGPoint(x: nextLeftX, y: floor((size.height - calendarButtonSize.height) * 0.5)), size: calendarButtonSize) + if let calendarButtonView = self.calendarButton.view { + if calendarButtonView.superview == nil { + self.view.addSubview(calendarButtonView) } - )), - environment: {}, - containerSize: size - ) - let calendarButtonFrame = CGRect(origin: CGPoint(x: nextLeftX, y: floor((size.height - calendarButtonSize.height) * 0.5)), size: calendarButtonSize) - if let calendarButtonView = self.calendarButton.view { - if calendarButtonView.superview == nil { - self.view.addSubview(calendarButtonView) + transition.setFrame(view: calendarButtonView, frame: calendarButtonFrame) } - transition.setFrame(view: calendarButtonView, frame: calendarButtonFrame) + nextLeftX += calendarButtonSize.width + 8.0 + } else if let calendarButtonView = self.calendarButton.view { + calendarButtonView.removeFromSuperview() } - nextLeftX += calendarButtonSize.width + 8.0 if displaySearchMembers { let membersButton: ComponentView diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index 61084aecd2..2b89509a57 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -430,6 +430,7 @@ enum ChatTextInputPanelPasteData { case video(Data) case gif(Data) case sticker(UIImage, Bool) + case animatedSticker(Data) } final class ChatTextViewForOverlayContent: UIView, ChatInputPanelViewForOverlayContent { @@ -1497,6 +1498,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch var displayMediaButton = true if case let .customChatContents(customChatContents) = interfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput: break case .businessLinkSetup: @@ -1863,6 +1866,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch } if case let .customChatContents(customChatContents) = interfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + placeholder = "" case let .quickReplyMessageInput(_, shortcutType): switch shortcutType { case .generic: @@ -1895,6 +1900,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch if let interfaceState = self.presentationInterfaceState { if case let .customChatContents(customChatContents) = interfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput: break case .businessLinkSetup: @@ -3673,6 +3680,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch if case let .customChatContents(customChatContents) = presentationInterfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput: break case .businessLinkSetup: @@ -3791,6 +3800,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch if let interfaceState = self.presentationInterfaceState { if case let .customChatContents(customChatContents) = interfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput: break case .businessLinkSetup: @@ -3894,6 +3905,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch var sendButtonHasApplyIcon = interfaceState.interfaceState.editMessage != nil if case let .customChatContents(customChatContents) = interfaceState.subject { switch customChatContents.kind { + case .hashTagSearch: + break case .quickReplyMessageInput: break case .businessLinkSetup: @@ -4379,6 +4392,9 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate, Ch } else if let data = pasteboard.data(forPasteboardType: "public.mpeg-4") { self.paste(.video(data)) return false + } else if let data = pasteboard.data(forPasteboardType: "public.heics") { + self.paste(.animatedSticker(data)) + return false } else { var isPNG = false var isMemoji = false diff --git a/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift b/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift index c734108683..cba2c06b8c 100644 --- a/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift +++ b/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift @@ -103,6 +103,7 @@ private enum ApplicationSpecificOrderedItemListCollectionIdValues: Int32 { case localThemes = 3 case storyDrafts = 4 case storySources = 5 + case hashtagSearchRecentQueries = 6 } public struct ApplicationSpecificOrderedItemListCollectionId { @@ -112,4 +113,5 @@ public struct ApplicationSpecificOrderedItemListCollectionId { public static let localThemes = applicationSpecificOrderedItemListCollectionId(ApplicationSpecificOrderedItemListCollectionIdValues.localThemes.rawValue) public static let storyDrafts = applicationSpecificOrderedItemListCollectionId(ApplicationSpecificOrderedItemListCollectionIdValues.storyDrafts.rawValue) public static let storySources = applicationSpecificOrderedItemListCollectionId(ApplicationSpecificOrderedItemListCollectionIdValues.storySources.rawValue) + public static let hashtagSearchRecentQueries = applicationSpecificOrderedItemListCollectionId(ApplicationSpecificOrderedItemListCollectionIdValues.hashtagSearchRecentQueries.rawValue) }