From 9c15b8c27d7ae208a7dbe26ff98f51ef55c51b3b Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Sat, 25 Apr 2026 17:32:26 +0200 Subject: [PATCH 1/2] Various improvements --- .../BrowserUI/Sources/BrowserMarkdown.swift | 150 ++- submodules/InstantPageUI/BUILD | 1 + .../InstantPageChecklistMarkerItem.swift | 101 ++ .../Sources/InstantPageLayout.swift | 77 +- submodules/TelegramApi/Sources/Api0.swift | 1 + submodules/TelegramApi/Sources/Api27.swift | 11 + submodules/TelegramApi/Sources/Api40.swift | 19 + .../Messages/TelegramEngineMessages.swift | 2 +- .../TelegramEngine/Peers/RecentPeers.swift | 5 +- .../Sources/AdminUserActionsSheet.swift | 73 +- .../Sources/ChatTextInputPanelNode.swift | 2 +- .../ChatEntityKeyboardInputNode/BUILD | 5 + .../Sources/PaneSearchContainerNode.swift | 285 +++++- .../StickerPaneSearchContentNode.swift | 890 +++++++++++++++--- ...tyKeyboardTopContainerPanelComponent.swift | 2 +- .../EntityKeyboardTopPanelComponent.swift | 240 +++-- .../Sources/PeerInfoScreen.swift | 13 + .../Sources/ChatControllerAdminBanUsers.swift | 8 +- 18 files changed, 1551 insertions(+), 334 deletions(-) create mode 100644 submodules/InstantPageUI/Sources/InstantPageChecklistMarkerItem.swift diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift index debef1e807..513f711ef9 100644 --- a/submodules/BrowserUI/Sources/BrowserMarkdown.swift +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -20,6 +20,8 @@ private let markdownInlineHTMLInlineIntent = InlinePresentationIntent(rawValue: private let markdownDefaultBlockImageDimensions = PixelDimensions(width: 1200, height: 900) private let markdownDefaultInlineImageDimensions = PixelDimensions(width: 18, height: 18) +private let markdownTaskListUncheckedNumber = "\u{001f}tg-md-task:unchecked" +private let markdownTaskListCheckedNumber = "\u{001f}tg-md-task:checked" private struct MarkdownPageResult { let blocks: [InstantPageBlock] @@ -88,6 +90,11 @@ private enum MarkdownResolvedImageSource { case unsupported } +private enum MarkdownTaskListState { + case unchecked + case checked +} + private final class MarkdownConversionContext { private let context: AccountContext fileprivate let documentURL: URL @@ -326,8 +333,6 @@ private func markdownBlocks(from node: MarkdownIntentNode, context: MarkdownConv return [] } if level <= 1 { - return [.title(text)] - } else if level == 2 { return [.header(text)] } else { return [.heading(text: text, level: Int32(max(3, min(level, 6))))] @@ -396,18 +401,28 @@ private func markdownListItems(from nodes: [MarkdownIntentNode], ordered: Bool, guard case let .listItem(ordinal) = node.kind else { continue } - let blocks = markdownBlocks(from: node.children, context: context) - guard !blocks.isEmpty else { - continue - } + var blocks = markdownBlocks(from: node.children, context: context) + let taskListState = markdownApplyTaskListMarker(to: &blocks) let number: String? - if ordered { + if let taskListState { + number = markdownTaskListNumber(for: taskListState) + } else if ordered { number = "\(ordinal)" } else { number = nil } + if blocks.isEmpty { + if let number { + result.append(.text(.plain(" "), number)) + } + continue + } if blocks.count == 1, case let .paragraph(text) = blocks[0] { - result.append(.text(text, number)) + if number != nil && markdownIsWhitespaceOnly(text) { + result.append(.text(.plain(" "), number)) + } else { + result.append(.text(text, number)) + } } else { result.append(.blocks(blocks, number)) } @@ -415,6 +430,52 @@ private func markdownListItems(from nodes: [MarkdownIntentNode], ordered: Bool, return result } +private func markdownTaskListNumber(for state: MarkdownTaskListState) -> String { + switch state { + case .unchecked: + return markdownTaskListUncheckedNumber + case .checked: + return markdownTaskListCheckedNumber + } +} + +private func markdownApplyTaskListMarker(to blocks: inout [InstantPageBlock]) -> MarkdownTaskListState? { + guard !blocks.isEmpty, case let .paragraph(text) = blocks[0] else { + return nil + } + guard let (state, strippedText) = markdownStrippingTaskListMarker(from: text) else { + return nil + } + if blocks.count > 1 && markdownIsWhitespaceOnly(strippedText) { + blocks.removeFirst() + } else { + blocks[0] = .paragraph(strippedText) + } + return state +} + +private func markdownStrippingTaskListMarker(from text: RichText) -> (MarkdownTaskListState, RichText)? { + guard let (state, markerLength) = markdownTaskListMarker(in: text.plainText) else { + return nil + } + return (state, markdownDroppingPrefixLength(markerLength, from: text)) +} + +private func markdownTaskListMarker(in plainText: String) -> (MarkdownTaskListState, Int)? { + switch plainText { + case _ where plainText.hasPrefix("[ ] "): + return (.unchecked, 4) + case "[ ]": + return (.unchecked, 3) + case _ where plainText.hasPrefix("[x] "), _ where plainText.hasPrefix("[X] "): + return (.checked, 4) + case "[x]", "[X]": + return (.checked, 3) + default: + return nil + } +} + private func markdownTableRows(from nodes: [MarkdownIntentNode], alignments: [TableHorizontalAlignment], context: MarkdownConversionContext) -> [InstantPageTableRow] { var result: [InstantPageTableRow] = [] for node in nodes { @@ -831,6 +892,79 @@ private func markdownCompact(_ fragments: [RichText]) -> RichText { } } +private func markdownDroppingPrefixLength(_ length: Int, from text: RichText) -> RichText { + guard length > 0 else { + return text + } + switch text { + case .empty: + return .empty + case let .plain(string): + let nsString = string as NSString + if nsString.length <= length { + return .empty + } else { + return .plain(nsString.substring(from: length)) + } + case let .bold(inner): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .bold(dropped) + case let .italic(inner): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .italic(dropped) + case let .underline(inner): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .underline(dropped) + case let .strikethrough(inner): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .strikethrough(dropped) + case let .fixed(inner): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .fixed(dropped) + case let .url(inner, url, webpageId): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .url(text: dropped, url: url, webpageId: webpageId) + case let .email(inner, email): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .email(text: dropped, email: email) + case let .concat(items): + var remainingLength = length + var result: [RichText] = [] + result.reserveCapacity(items.count) + for item in items { + if remainingLength > 0 { + let itemLength = (item.plainText as NSString).length + if itemLength <= remainingLength { + remainingLength -= itemLength + continue + } + result.append(markdownDroppingPrefixLength(remainingLength, from: item)) + remainingLength = 0 + } else { + result.append(item) + } + } + return markdownCompact(result) + case let .subscript(inner): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .subscript(dropped) + case let .superscript(inner): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .superscript(dropped) + case let .marked(inner): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .marked(dropped) + case let .phone(inner, phone): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .phone(text: dropped, phone: phone) + case .image: + return text + case let .anchor(inner, name): + let dropped = markdownDroppingPrefixLength(length, from: inner) + return dropped == .empty ? .empty : .anchor(text: dropped, name: name) + } +} + private func markdownHasDisplayableContent(_ richText: RichText) -> Bool { switch richText { case .empty: diff --git a/submodules/InstantPageUI/BUILD b/submodules/InstantPageUI/BUILD index 34ee3765ae..58c18d70ea 100644 --- a/submodules/InstantPageUI/BUILD +++ b/submodules/InstantPageUI/BUILD @@ -12,6 +12,7 @@ swift_library( deps = [ "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", "//submodules/AsyncDisplayKit:AsyncDisplayKit", + "//submodules/CheckNode:CheckNode", "//submodules/Display:Display", "//submodules/Postbox:Postbox", "//submodules/TelegramCore:TelegramCore", diff --git a/submodules/InstantPageUI/Sources/InstantPageChecklistMarkerItem.swift b/submodules/InstantPageUI/Sources/InstantPageChecklistMarkerItem.swift new file mode 100644 index 0000000000..ea145d81a2 --- /dev/null +++ b/submodules/InstantPageUI/Sources/InstantPageChecklistMarkerItem.swift @@ -0,0 +1,101 @@ +import Foundation +import UIKit +import TelegramCore +import AsyncDisplayKit +import Display +import TelegramPresentationData +import TelegramUIPreferences +import AccountContext +import ContextUI +import CheckNode + +final class InstantPageChecklistMarkerItem: InstantPageItem { + var frame: CGRect + let checked: Bool + + let wantsNode: Bool = true + let separatesTiles: Bool = false + let medias: [InstantPageMedia] = [] + + init(frame: CGRect, checked: Bool) { + self.frame = frame + self.checked = checked + } + + func matchesAnchor(_ anchor: String) -> Bool { + return false + } + + func drawInTile(context: CGContext) { + } + + func node(context: AccountContext, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, theme: InstantPageTheme, sourceLocation: InstantPageSourceLocation, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, openPeer: @escaping (EnginePeer) -> Void, openUrl: @escaping (InstantPageUrlItem) -> Void, updateWebEmbedHeight: @escaping (CGFloat) -> Void, updateDetailsExpanded: @escaping (Bool) -> Void, currentExpandedDetails: [Int : Bool]?, getPreloadedResource: @escaping (String) -> Data?) -> InstantPageNode? { + return InstantPageChecklistMarkerNode(theme: theme, checked: self.checked) + } + + func matchesNode(_ node: InstantPageNode) -> Bool { + if let node = node as? InstantPageChecklistMarkerNode { + return node.checked == self.checked + } else { + return false + } + } + + func linkSelectionRects(at point: CGPoint) -> [CGRect] { + return [] + } + + func distanceThresholdGroup() -> Int? { + return nil + } + + func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat { + return 0.0 + } +} + +private func instantPageChecklistMarkerTheme(theme: InstantPageTheme) -> CheckNodeTheme { + return CheckNodeTheme( + backgroundColor: theme.panelAccentColor, + strokeColor: theme.pageBackgroundColor, + borderColor: theme.controlColor, + overlayBorder: false, + hasInset: false, + hasShadow: false + ) +} + +final class InstantPageChecklistMarkerNode: ASDisplayNode, InstantPageNode { + let checked: Bool + private let checkNode: CheckNode + + init(theme: InstantPageTheme, checked: Bool) { + self.checked = checked + self.checkNode = CheckNode(theme: instantPageChecklistMarkerTheme(theme: theme), content: .check(isRectangle: true)) + + super.init() + + self.isUserInteractionEnabled = false + self.checkNode.isUserInteractionEnabled = false + self.addSubnode(self.checkNode) + self.checkNode.setSelected(checked, animated: false) + } + + func updateIsVisible(_ isVisible: Bool) { + } + + func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + return nil + } + + func updateHiddenMedia(media: InstantPageMedia?) { + } + + func update(strings: PresentationStrings, theme: InstantPageTheme) { + self.checkNode.theme = instantPageChecklistMarkerTheme(theme: theme) + } + + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + transition.updateFrame(node: self.checkNode, frame: CGRect(origin: .zero, size: size)) + } +} diff --git a/submodules/InstantPageUI/Sources/InstantPageLayout.swift b/submodules/InstantPageUI/Sources/InstantPageLayout.swift index 2f07bcd4dc..c5bfaca6b9 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayout.swift @@ -131,6 +131,40 @@ private func attributedStringForPreformattedText(_ text: RichText, language: Str return attributedString } +private let instantPageTaskListUncheckedNumber = "\u{001f}tg-md-task:unchecked" +private let instantPageTaskListCheckedNumber = "\u{001f}tg-md-task:checked" +private let instantPageChecklistMarkerSize = CGSize(width: 18.0, height: 18.0) + +private func instantPageTaskListMarkerState(_ number: String?) -> Bool? { + switch number { + case instantPageTaskListUncheckedNumber: + return false + case instantPageTaskListCheckedNumber: + return true + default: + return nil + } +} + +private func instantPageFirstTextLineMidY(in items: [InstantPageItem]) -> CGFloat? { + for item in items { + if let textItem = item as? InstantPageTextItem { + if let line = textItem.lines.first { + return textItem.frame.minY + line.frame.midY + } else { + return textItem.frame.midY + } + } else if let scrollableTextItem = item as? InstantPageScrollableTextItem { + if let line = scrollableTextItem.item.lines.first { + return scrollableTextItem.frame.minY + scrollableTextItem.item.frame.minY + line.frame.midY + } else { + return scrollableTextItem.frame.midY + } + } + } + return nil +} + public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: MediaResourceUserLocation, rtl: Bool, block: InstantPageBlock, boundingWidth: CGFloat, horizontalInset: CGFloat, safeInset: CGFloat, isCover: Bool, previousItems: [InstantPageItem], fillToSize: CGSize?, media: [EngineMedia.Id: EngineMedia], mediaIndexCounter: inout Int, embedIndexCounter: inout Int, detailsIndexCounter: inout Int, theme: InstantPageTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, excludeCaptions: Bool) -> InstantPageLayout { let layoutCaption: (InstantPageCaption, CGSize) -> ([InstantPageItem], CGSize) = { caption, contentSize in @@ -298,12 +332,21 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: var maxIndexWidth: CGFloat = 0.0 var listItems: [InstantPageItem] = [] var indexItems: [InstantPageItem] = [] + var hasTaskMarkers = false var hasNums = false if ordered { for item in contentItems { - if let num = item.num, !num.isEmpty { + if instantPageTaskListMarkerState(item.num) != nil { + hasTaskMarkers = true + } else if let num = item.num, !num.isEmpty { hasNums = true + } + } + } else { + for item in contentItems { + if instantPageTaskListMarkerState(item.num) != nil { + hasTaskMarkers = true break } } @@ -311,7 +354,13 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: for i in 0 ..< contentItems.count { let item = contentItems[i] - if ordered { + if let checked = instantPageTaskListMarkerState(item.num) { + let checklistItem = InstantPageChecklistMarkerItem(frame: CGRect(origin: .zero, size: instantPageChecklistMarkerSize), checked: checked) + if ordered { + maxIndexWidth = max(maxIndexWidth, instantPageChecklistMarkerSize.width) + } + indexItems.append(checklistItem) + } else if ordered { let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) let value: String @@ -335,7 +384,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: indexItems.append(shapeItem) } } - let indexSpacing: CGFloat = ordered ? 12.0 : 20.0 + let indexSpacing: CGFloat = ordered ? (hasTaskMarkers ? 16.0 : 12.0) : (hasTaskMarkers ? 24.0 : 20.0) for (i, item) in contentItems.enumerated() { if (i != 0) { contentSize.height += 18.0 @@ -366,6 +415,12 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: if let textIndexItem = indexItem as? InstantPageTextItem, let line = textIndexItem.lines.first { itemFrame = itemFrame.offsetBy(dx: horizontalInset + maxIndexWidth - line.frame.width, dy: floorToScreenPixels(lineMidY - (itemFrame.height / 2.0))) + } else if indexItem is InstantPageChecklistMarkerItem { + if ordered { + itemFrame = itemFrame.offsetBy(dx: horizontalInset + maxIndexWidth - itemFrame.width, dy: floorToScreenPixels(lineMidY - (itemFrame.height / 2.0))) + } else { + itemFrame = itemFrame.offsetBy(dx: horizontalInset, dy: floorToScreenPixels(lineMidY - (itemFrame.height / 2.0))) + } } else { itemFrame = itemFrame.offsetBy(dx: horizontalInset, dy: floorToScreenPixels(lineMidY - itemFrame.height / 2.0)) } @@ -375,6 +430,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: case let .blocks(blocks, _): var previousBlock: InstantPageBlock? var originY: CGFloat = contentSize.height + var firstBlockLineMidY: CGFloat? for subBlock in blocks { let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: listItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false) @@ -383,6 +439,9 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: if previousBlock == nil { originY += spacing } + if firstBlockLineMidY == nil { + firstBlockLineMidY = instantPageFirstTextLineMidY(in: blockItems) + } listItems.append(contentsOf: blockItems) contentSize.height += subLayout.contentSize.height + spacing previousBlock = subBlock @@ -391,6 +450,18 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: var indexItemFrame = indexItem.frame if let textIndexItem = indexItem as? InstantPageTextItem, let line = textIndexItem.lines.first { indexItemFrame = indexItemFrame.offsetBy(dx: horizontalInset + maxIndexWidth - line.frame.width, dy: originY) + } else if indexItem is InstantPageChecklistMarkerItem { + let markerOriginY: CGFloat + if let firstBlockLineMidY { + markerOriginY = floorToScreenPixels(firstBlockLineMidY - indexItemFrame.height / 2.0) + } else { + markerOriginY = originY + } + if ordered { + indexItemFrame = indexItemFrame.offsetBy(dx: horizontalInset + maxIndexWidth - indexItemFrame.width, dy: markerOriginY) + } else { + indexItemFrame = indexItemFrame.offsetBy(dx: horizontalInset, dy: markerOriginY) + } } else { indexItemFrame = indexItemFrame.offsetBy(dx: horizontalInset, dy: originY) } diff --git a/submodules/TelegramApi/Sources/Api0.swift b/submodules/TelegramApi/Sources/Api0.swift index dc076e419d..b097a2a091 100644 --- a/submodules/TelegramApi/Sources/Api0.swift +++ b/submodules/TelegramApi/Sources/Api0.swift @@ -1093,6 +1093,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[1236871718] = { return Api.TodoList.parse_todoList($0) } dict[-305282981] = { return Api.TopPeer.parse_topPeer($0) } dict[-39945236] = { return Api.TopPeerCategory.parse_topPeerCategoryBotsApp($0) } + dict[1814361053] = { return Api.TopPeerCategory.parse_topPeerCategoryBotsGuestChat($0) } dict[344356834] = { return Api.TopPeerCategory.parse_topPeerCategoryBotsInline($0) } dict[-1419371685] = { return Api.TopPeerCategory.parse_topPeerCategoryBotsPM($0) } dict[371037736] = { return Api.TopPeerCategory.parse_topPeerCategoryChannels($0) } diff --git a/submodules/TelegramApi/Sources/Api27.swift b/submodules/TelegramApi/Sources/Api27.swift index f4538147e6..018d2238a9 100644 --- a/submodules/TelegramApi/Sources/Api27.swift +++ b/submodules/TelegramApi/Sources/Api27.swift @@ -1694,6 +1694,7 @@ public extension Api { public extension Api { enum TopPeerCategory: TypeConstructorDescription { case topPeerCategoryBotsApp + case topPeerCategoryBotsGuestChat case topPeerCategoryBotsInline case topPeerCategoryBotsPM case topPeerCategoryChannels @@ -1710,6 +1711,11 @@ public extension Api { buffer.appendInt32(-39945236) } break + case .topPeerCategoryBotsGuestChat: + if boxed { + buffer.appendInt32(1814361053) + } + break case .topPeerCategoryBotsInline: if boxed { buffer.appendInt32(344356834) @@ -1757,6 +1763,8 @@ public extension Api { switch self { case .topPeerCategoryBotsApp: return ("topPeerCategoryBotsApp", []) + case .topPeerCategoryBotsGuestChat: + return ("topPeerCategoryBotsGuestChat", []) case .topPeerCategoryBotsInline: return ("topPeerCategoryBotsInline", []) case .topPeerCategoryBotsPM: @@ -1779,6 +1787,9 @@ public extension Api { public static func parse_topPeerCategoryBotsApp(_ reader: BufferReader) -> TopPeerCategory? { return Api.TopPeerCategory.topPeerCategoryBotsApp } + public static func parse_topPeerCategoryBotsGuestChat(_ reader: BufferReader) -> TopPeerCategory? { + return Api.TopPeerCategory.topPeerCategoryBotsGuestChat + } public static func parse_topPeerCategoryBotsInline(_ reader: BufferReader) -> TopPeerCategory? { return Api.TopPeerCategory.topPeerCategoryBotsInline } diff --git a/submodules/TelegramApi/Sources/Api40.swift b/submodules/TelegramApi/Sources/Api40.swift index 6960f0e58b..a872989b8d 100644 --- a/submodules/TelegramApi/Sources/Api40.swift +++ b/submodules/TelegramApi/Sources/Api40.swift @@ -7486,6 +7486,25 @@ public extension Api.functions.messages { }) } } +public extension Api.functions.messages { + static func getPersonalChannelHistory(userId: Api.InputUser, limit: Int32, maxId: Int32, minId: Int32, hash: Int64) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + let buffer = Buffer() + buffer.appendInt32(1442515350) + userId.serialize(buffer, true) + serializeInt32(limit, buffer: buffer, boxed: false) + serializeInt32(maxId, buffer: buffer, boxed: false) + serializeInt32(minId, buffer: buffer, boxed: false) + serializeInt64(hash, buffer: buffer, boxed: false) + return (FunctionDescription(name: "messages.getPersonalChannelHistory", parameters: [("userId", ConstructorParameterDescription(userId)), ("limit", ConstructorParameterDescription(limit)), ("maxId", ConstructorParameterDescription(maxId)), ("minId", ConstructorParameterDescription(minId)), ("hash", ConstructorParameterDescription(hash))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.messages.Messages? in + let reader = BufferReader(buffer) + var result: Api.messages.Messages? + if let signature = reader.readInt32() { + result = Api.parse(reader, signature: signature) as? Api.messages.Messages + } + return result + }) + } +} public extension Api.functions.messages { static func getPinnedDialogs(folderId: Int32) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 8670dbab50..f79cd4ce72 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -174,7 +174,7 @@ public extension TelegramEngine { return _internal_deleteAllReactionsWithAuthor(account: self.account, peerId: peerId, authorId: authorId) } - public func deleteReaction(peerId: EnginePeer.Id, messageId: EngineMessage.Id, authorId: EnginePeer.Id) -> Signal { + public func deleteReaction(messageId: EngineMessage.Id, authorId: EnginePeer.Id) -> Signal { return _internal_deleteReaction(account: self.account, messageId: messageId, authorId: authorId) } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/RecentPeers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/RecentPeers.swift index c4de6ebe66..bee4cf3f35 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/RecentPeers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/RecentPeers.swift @@ -151,7 +151,10 @@ func _internal_updateRecentPeersEnabled(postbox: Postbox, network: Network, enab } func _internal_managedRecentlyUsedInlineBots(postbox: Postbox, network: Network, accountPeerId: PeerId) -> Signal { - let remotePeers = network.request(Api.functions.contacts.getTopPeers(flags: 1 << 2, offset: 0, limit: 16, hash: 0)) + var flags: Int32 = 0 + flags |= 1 << 2 + flags |= 1 << 17 + let remotePeers = network.request(Api.functions.contacts.getTopPeers(flags: flags, offset: 0, limit: 24, hash: 0)) |> retryRequestIfNotFrozen |> map { result -> (AccumulatedPeers, [(PeerId, Double)])? in switch result { diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift index 5396505457..29b262b6a7 100644 --- a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift @@ -196,6 +196,11 @@ private enum AdminUserActionOptionSection { case ban } +private enum AdminUserDeleteAllOption { + case messages + case reactions +} + private enum AdminUserActionConfigItem: Hashable, CaseIterable { case sendMessages case sendMedia @@ -318,6 +323,7 @@ private final class AdminUserActionsContentComponent: Component { let toggleOptionSelection: (AdminUserActionOptionSection) -> Void let toggleOptionExpansion: (AdminUserActionOptionSection) -> Void let togglePeerSelection: (AdminUserActionOptionSection, EnginePeer) -> Void + let toggleDeleteAllOptionPeerSelection: (AdminUserDeleteAllOption, EnginePeer) -> Void let toggleConfiguration: () -> Void let toggleConfigItem: (AdminUserActionConfigItem) -> Void let toggleMediaSectionExpansion: () -> Void @@ -336,6 +342,7 @@ private final class AdminUserActionsContentComponent: Component { toggleOptionSelection: @escaping (AdminUserActionOptionSection) -> Void, toggleOptionExpansion: @escaping (AdminUserActionOptionSection) -> Void, togglePeerSelection: @escaping (AdminUserActionOptionSection, EnginePeer) -> Void, + toggleDeleteAllOptionPeerSelection: @escaping (AdminUserDeleteAllOption, EnginePeer) -> Void, toggleConfiguration: @escaping () -> Void, toggleConfigItem: @escaping (AdminUserActionConfigItem) -> Void, toggleMediaSectionExpansion: @escaping () -> Void, @@ -353,6 +360,7 @@ private final class AdminUserActionsContentComponent: Component { self.toggleOptionSelection = toggleOptionSelection self.toggleOptionExpansion = toggleOptionExpansion self.togglePeerSelection = togglePeerSelection + self.toggleDeleteAllOptionPeerSelection = toggleDeleteAllOptionPeerSelection self.toggleConfiguration = toggleConfiguration self.toggleConfigItem = toggleConfigItem self.toggleMediaSectionExpansion = toggleMediaSectionExpansion @@ -436,11 +444,12 @@ private final class AdminUserActionsContentComponent: Component { var accessory: ListActionItemComponent.Accessory? var isExpandable = false if component.peers.count > 1 { + let selectedCount = selectedPeers.union(additionalSelectedPeers).count accessory = .custom(ListActionItemComponent.CustomAccessory( component: AnyComponentWithIdentity(id: 0, component: AnyComponent(PlainButtonComponent( content: AnyComponent(OptionSectionExpandIndicatorComponent( theme: component.theme, - count: selectedPeers.isEmpty ? component.peers.count : selectedPeers.count, + count: selectedCount == 0 ? component.peers.count : selectedCount, isExpanded: isExpanded )), effectAlignment: .center, @@ -464,7 +473,7 @@ private final class AdminUserActionsContentComponent: Component { AnyComponentWithIdentity(id: 1, component: AnyComponent(MediaSectionExpandIndicatorComponent( theme: component.theme, title: "\(count)/2", - isExpanded: component.sheetState.isMediaSectionExpanded + isExpanded: isExpanded ))) ) isExpandable = true @@ -521,7 +530,7 @@ private final class AdminUserActionsContentComponent: Component { sideInset: 0.0, title: EnginePeer(peer.peer).displayTitle(strings: component.strings, displayOrder: .firstLast), peer: EnginePeer(peer.peer), - selectionState: .editing(isSelected: selectedPeers.contains(peer.peer.id)), + selectionState: .editing(isSelected: selectedPeers.contains(peer.peer.id) || additionalSelectedPeers.contains(peer.peer.id)), action: { peer in component.togglePeerSelection(section, peer) } @@ -544,13 +553,13 @@ private final class AdminUserActionsContentComponent: Component { leftIcon: .check(ListActionItemComponent.LeftIcon.Check( isSelected: !selectedPeers.isEmpty, toggle: { - component.toggleOptionSelection(section) + component.toggleDeleteAllOptionPeerSelection(.messages, EnginePeer(component.peers[0].peer)) } )), icon: .none, accessory: nil, action: { _ in - component.toggleOptionSelection(section) + component.toggleDeleteAllOptionPeerSelection(.messages, EnginePeer(component.peers[0].peer)) }, highlighting: .disabled ))) @@ -570,13 +579,13 @@ private final class AdminUserActionsContentComponent: Component { leftIcon: .check(ListActionItemComponent.LeftIcon.Check( isSelected: !additionalSelectedPeers.isEmpty, toggle: { - component.toggleOptionSelection(section) + component.toggleDeleteAllOptionPeerSelection(.reactions, EnginePeer(component.peers[0].peer)) } )), icon: .none, accessory: nil, action: { _ in - component.toggleOptionSelection(section) + component.toggleDeleteAllOptionPeerSelection(.reactions, EnginePeer(component.peers[0].peer)) }, highlighting: .disabled ))) @@ -992,6 +1001,7 @@ private final class AdminUserActionsSheetComponent: Component { private var optionReportSelectedPeers = Set() private var isOptionDeleteAllExpanded: Bool = false private var optionDeleteAllSelectedPeers = Set() + private var optionDeleteAllReactionsSelectedPeers = Set() private var isOptionBanExpanded: Bool = false private var optionBanSelectedPeers = Set() @@ -1034,7 +1044,7 @@ private final class AdminUserActionsSheetComponent: Component { deleteAllFromPeers.append(id) } - for id in self.optionDeleteAllSelectedPeers.sorted() { + for id in self.optionDeleteAllReactionsSelectedPeers.sorted() { deleteAllReactionsFromPeers.append(id) } @@ -1182,7 +1192,7 @@ private final class AdminUserActionsSheetComponent: Component { optionReportSelectedPeers: self.optionReportSelectedPeers, isOptionDeleteAllExpanded: self.isOptionDeleteAllExpanded, optionDeleteAllSelectedPeers: self.optionDeleteAllSelectedPeers, - optionDeleteAllReactionsSelectedPeers: self.optionDeleteAllSelectedPeers, + optionDeleteAllReactionsSelectedPeers: self.optionDeleteAllReactionsSelectedPeers, isOptionBanExpanded: self.isOptionBanExpanded, optionBanSelectedPeers: self.optionBanSelectedPeers, isConfigurationExpanded: self.isConfigurationExpanded, @@ -1238,7 +1248,17 @@ private final class AdminUserActionsSheetComponent: Component { case .report: selectedPeers = self.optionReportSelectedPeers case .deleteAll: - selectedPeers = self.optionDeleteAllSelectedPeers + let allPeerIds = Set(component.peers.map { $0.peer.id }) + if self.optionDeleteAllSelectedPeers.isEmpty && self.optionDeleteAllReactionsSelectedPeers.isEmpty { + self.optionDeleteAllSelectedPeers = allPeerIds + self.optionDeleteAllReactionsSelectedPeers = allPeerIds + } else { + self.optionDeleteAllSelectedPeers.removeAll() + self.optionDeleteAllReactionsSelectedPeers.removeAll() + } + + self.state?.updated(transition: .spring(duration: 0.35)) + return case .ban: selectedPeers = self.optionBanSelectedPeers } @@ -1291,7 +1311,16 @@ private final class AdminUserActionsSheetComponent: Component { case .report: selectedPeers = self.optionReportSelectedPeers case .deleteAll: - selectedPeers = self.optionDeleteAllSelectedPeers + if self.optionDeleteAllSelectedPeers.contains(peer.id) || self.optionDeleteAllReactionsSelectedPeers.contains(peer.id) { + self.optionDeleteAllSelectedPeers.remove(peer.id) + self.optionDeleteAllReactionsSelectedPeers.remove(peer.id) + } else { + self.optionDeleteAllSelectedPeers.insert(peer.id) + self.optionDeleteAllReactionsSelectedPeers.insert(peer.id) + } + + self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.3, curve: .easeInOut))) + return case .ban: selectedPeers = self.optionBanSelectedPeers } @@ -1313,6 +1342,28 @@ private final class AdminUserActionsSheetComponent: Component { self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.3, curve: .easeInOut))) }, + toggleDeleteAllOptionPeerSelection: { [weak self] option, peer in + guard let self else { + return + } + + switch option { + case .messages: + if self.optionDeleteAllSelectedPeers.contains(peer.id) { + self.optionDeleteAllSelectedPeers.remove(peer.id) + } else { + self.optionDeleteAllSelectedPeers.insert(peer.id) + } + case .reactions: + if self.optionDeleteAllReactionsSelectedPeers.contains(peer.id) { + self.optionDeleteAllReactionsSelectedPeers.remove(peer.id) + } else { + self.optionDeleteAllReactionsSelectedPeers.insert(peer.id) + } + } + + self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.3, curve: .easeInOut))) + }, toggleConfiguration: { [weak self] in guard let self, let component = self.component else { return diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index 92ecd7dc37..8edb6af71b 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -2634,7 +2634,7 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg size: CGSize(width: baseWidth, height: panelHeight) ) - let audioRecordingTimeFrame = CGRect(origin: CGPoint(x: hideOffset.x + leftInset + leftMenuInset + 8.0 + 22.0, y: (accessoryPanel != nil ? 52.0 : 0.0) + panelHeight - minimalHeight + floor((minimalHeight - audioRecordingTimeSize.height) / 2.0) + 1.0 - UIScreenPixel), size: audioRecordingTimeSize) + let audioRecordingTimeFrame = CGRect(origin: CGPoint(x: hideOffset.x + leftInset + leftMenuInset + 8.0 + 34.0, y: (accessoryPanel != nil ? 52.0 : 0.0) + panelHeight - minimalHeight + floor((minimalHeight - audioRecordingTimeSize.height) / 2.0) + 1.0 - UIScreenPixel), size: audioRecordingTimeSize) if animateTimeSlideIn { var previousAudioRecordingTimeFrame = audioRecordingTimeFrame diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD index d1c7e8b9d7..85a17851ad 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD @@ -17,9 +17,13 @@ swift_library( "//submodules/TelegramCore:TelegramCore", "//submodules/AccountContext:AccountContext", "//submodules/ChatPresentationInterfaceState:ChatPresentationInterfaceState", + "//submodules/TelegramUI/Components/ButtonComponent", "//submodules/ComponentFlow:ComponentFlow", "//submodules/Components/ComponentDisplayAdapters:ComponentDisplayAdapters", + "//submodules/Components/MultilineTextComponent", + "//submodules/TelegramUI/Components/EdgeEffect", "//submodules/TelegramUI/Components/EntityKeyboard:EntityKeyboard", + "//submodules/TelegramUI/Components/EmojiTextAttachmentView", "//submodules/TelegramUI/Components/AnimationCache:AnimationCache", "//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer", "//submodules/TextFormat:TextFormat", @@ -39,6 +43,7 @@ swift_library( "//submodules/TelegramUI/Components/ChatControllerInteraction:ChatControllerInteraction", "//submodules/FeaturedStickersScreen:FeaturedStickersScreen", "//submodules/TelegramUI/Components/EntityKeyboardGifContent:EntityKeyboardGifContent", + "//submodules/TelegramUI/Components/GlassControls", "//submodules/TelegramUI/Components/LegacyMessageInputPanelInputView:LegacyMessageInputPanelInputView", "//submodules/TelegramUI/Components/BatchVideoRendering", "//submodules/TelegramUI/Components/GlassBackgroundComponent", diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift index 31d8c7e2a5..51f85faf3c 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import AsyncDisplayKit import Display +import ComponentFlow import SearchBarNode import SwiftSignalKit import Postbox @@ -10,12 +11,16 @@ import TelegramPresentationData import AccountContext import ChatPresentationInterfaceState import EntityKeyboard +import ContextUI +import GlassControls +import MultilineTextComponent import ChatControllerInteraction import MultiplexedVideoNode import FeaturedStickersScreen import StickerPeekUI import EntityKeyboardGifContent import BatchVideoRendering +import UndoUI private let searchBarHeight: CGFloat = 76.0 private let searchBarTopInset: CGFloat = 16.0 @@ -39,14 +44,14 @@ public protocol PaneSearchContentNode { var ready: Signal { get } var deactivateSearchBar: (() -> Void)? { get set } var updateActivity: ((Bool) -> Void)? { get set } - + func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) func updateText(_ text: String, languageCode: String?) func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics, transition: ContainedViewLayoutTransition) - + func animateIn(additivePosition: CGFloat, transition: ContainedViewLayoutTransition) func animateOut(transition: ContainedViewLayoutTransition) - + func updatePreviewing(animated: Bool) func itemAt(point: CGPoint) -> (ASDisplayNode, Any)? } @@ -58,27 +63,34 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer private let interaction: ChatEntityKeyboardInputNode.Interaction private let inputNodeInteraction: ChatMediaInputNodeInteraction private let peekBehavior: EmojiContentPeekBehavior? - + private let backgroundNode: ASDisplayNode private let searchBar: SearchBarNode - - private var validLayout: CGSize? + private let navigationButtons = ComponentView() + private let selectedPackTitle = ComponentView() + + private var theme: PresentationTheme + private var strings: PresentationStrings + private var validLayout: (size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics)? private weak var animatedPlaceholder: PaneSearchBarPlaceholderNode? - + private var selectedStickerPack: StickerPaneSearchSelectedPack? + public var onCancel: (() -> Void)? - + public var openGifContextMenu: ((MultiplexedVideoNodeFile, ASDisplayNode, CGRect, ContextGesture, Bool) -> Void)? - + public var ready: Signal { return self.contentNode.ready } - + public init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, interaction: ChatEntityKeyboardInputNode.Interaction, inputNodeInteraction: ChatMediaInputNodeInteraction, mode: ChatMediaInputSearchMode, batchVideoRenderingContext: BatchVideoRenderingContext?, stickerActionTitle: String? = nil, trendingGifsPromise: Promise, cancel: @escaping () -> Void, peekBehavior: EmojiContentPeekBehavior?) { self.context = context self.mode = mode self.interaction = interaction self.inputNodeInteraction = inputNodeInteraction self.peekBehavior = peekBehavior + self.theme = theme + self.strings = strings switch mode { case .gif: self.contentNode = GifPaneSearchContentNode(context: context, theme: theme, strings: strings, interaction: interaction, inputNodeInteraction: inputNodeInteraction, batchVideoRenderingContext: batchVideoRenderingContext ?? BatchVideoRenderingContext(context: context), trendingPromise: trendingGifsPromise) @@ -86,7 +98,7 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer self.contentNode = StickerPaneSearchContentNode(context: context, theme: theme, strings: strings, interaction: interaction, inputNodeInteraction: inputNodeInteraction, stickerActionTitle: stickerActionTitle) } self.backgroundNode = ASDisplayNode() - + self.searchBar = SearchBarNode( theme: paneSearchBarTheme(theme), presentationTheme: theme, @@ -94,35 +106,35 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer fieldStyle: .glass, displayBackground: false ) - + super.init() - + self.clipsToBounds = true - + self.addSubnode(self.backgroundNode) self.addSubnode(self.contentNode) self.addSubnode(self.searchBar) - + self.contentNode.deactivateSearchBar = { [weak self] in self?.searchBar.deactivate(clear: false) } self.contentNode.updateActivity = { [weak self] active in self?.searchBar.activity = active } - + self.searchBar.cancel = { [weak self] in self?.searchBar.deactivate(clear: false) cancel() self?.onCancel?() } self.searchBar.activate() - + self.searchBar.textUpdated = { [weak self] text, languageCode in self?.contentNode.updateText(text, languageCode: languageCode) } - + self.updateThemeAndStrings(theme: theme, strings: strings) - + if let contentNode = self.contentNode as? GifPaneSearchContentNode { contentNode.requestUpdateQuery = { [weak self] query in self?.updateQuery(query) @@ -131,7 +143,20 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer self?.openGifContextMenu?(file, node, rect, gesture, isSaved) } } - + + if let contentNode = self.contentNode as? StickerPaneSearchContentNode { + contentNode.selectedPackUpdated = { [weak self] pack in + guard let self else { + return + } + self.selectedStickerPack = pack + if pack != nil { + self.searchBar.deactivate(clear: false) + } + self.requestLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) + } + } + if let contentNode = self.contentNode as? StickerPaneSearchContentNode, let peekBehavior = self.peekBehavior { peekBehavior.setGestureRecognizerEnabled(view: self.contentNode.view, isEnabled: true, itemAtPoint: { [weak contentNode] point in guard let contentNode else { @@ -140,7 +165,7 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer guard let (itemNode, item) = contentNode.itemAt(point: point) else { return nil } - + var maybeFile: TelegramMediaFile? if let item = item as? StickerPreviewPeekItem { switch item { @@ -155,7 +180,7 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer guard let file = maybeFile else { return nil } - + var groupId: AnyHashable = AnyHashable("search") for attribute in file.attributes { if case let .Sticker(_, packReference, _) = attribute { @@ -164,17 +189,19 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer } } } - + return (groupId, itemNode.layer, file) }) } } - + public func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { + self.theme = theme + self.strings = strings self.backgroundNode.backgroundColor = theme.chat.inputMediaPanel.stickersBackgroundColor.withAlphaComponent(1.0) self.contentNode.updateThemeAndStrings(theme: theme, strings: strings) self.searchBar.updateThemeAndStrings(theme: paneSearchBarTheme(theme), presentationTheme: theme, strings: strings) - + let placeholder: String switch mode { case .gif: @@ -184,61 +211,203 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer } self.searchBar.placeholderString = NSAttributedString(string: placeholder, font: Font.regular(17.0), textColor: theme.rootController.navigationSearchBar.inputPlaceholderTextColor) } - + public func updateQuery(_ query: String) { self.searchBar.text = query } - + public func itemAt(point: CGPoint) -> (ASDisplayNode, Any)? { return self.contentNode.itemAt(point: CGPoint(x: point.x, y: point.y - searchBarHeight)) } - + + private func openSelectedPackMoreMenu() { + guard let selectedStickerPack = self.selectedStickerPack, let controlsView = self.navigationButtons.view as? GlassControlPanelComponent.View, let rightItemView = controlsView.rightItemView, let sourceView = rightItemView.itemView(id: AnyHashable("more")) else { + return + } + + let link = "https://t.me/addstickers/\(selectedStickerPack.info.shortName)" + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: self.theme) + let strings = self.strings + + var items: [ContextMenuItem] = [] + items.append(.action(ContextMenuActionItem(text: strings.StickerPack_Share, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Share"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + guard let self else { + return + } + let shareController = self.context.sharedContext.makeShareController( + context: self.context, + params: ShareControllerParams( + subject: .url(link), + externalShare: false, + actionCompleted: { [weak self] in + guard let self else { + return + } + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: self.theme) + self.interaction.presentController(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in + return false + }), nil) + } + ) + ) + self.interaction.presentController(shareController, nil) + }))) + + items.append(.action(ContextMenuActionItem(text: strings.StickerPack_CopyLink, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor) + }, action: { [weak self] _, f in + f(.default) + + UIPasteboard.general.string = link + guard let self else { + return + } + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: self.theme) + self.interaction.presentController(UndoOverlayController(presentationData: presentationData, content: .linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in + return false + }), nil) + }))) + + let contextController = makeContextController( + presentationData: presentationData, + source: .reference(StickerPaneSearchHeaderContextReferenceContentSource(sourceView: sourceView)), + items: .single(ContextController.Items(content: .list(items))), + gesture: nil + ) + self.interaction.presentGlobalOverlayController(contextController, nil) + } + + private func requestLayout(transition: ContainedViewLayoutTransition) { + guard let (size, leftInset, rightInset, bottomInset, inputHeight, deviceMetrics) = self.validLayout else { + return + } + self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, inputHeight: inputHeight, deviceMetrics: deviceMetrics, transition: transition) + } + public func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics, transition: ContainedViewLayoutTransition) { - self.validLayout = size + self.validLayout = (size, leftInset, rightInset, bottomInset, inputHeight, deviceMetrics) transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: size)) - + let searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: searchBarTopInset), size: CGSize(width: size.width, height: searchBarFieldHeight)) transition.updateFrame(node: self.searchBar, frame: searchBarFrame) self.searchBar.updateLayout(boundingSize: searchBarFrame.size, leftInset: leftInset, rightInset: rightInset, transition: transition) + self.searchBar.isUserInteractionEnabled = self.selectedStickerPack == nil + transition.updateAlpha(node: self.searchBar, alpha: self.selectedStickerPack == nil ? 1.0 : 0.0) + + let componentTransition = ComponentTransition(transition) + let navigationButtonsFrame = CGRect(origin: CGPoint(x: leftInset + 16.0, y: searchBarTopInset), size: CGSize(width: max(1.0, size.width - leftInset - rightInset - 16.0 * 2.0), height: 48.0)) + + let navigationButtonsSize = self.navigationButtons.update( + transition: componentTransition, + component: AnyComponent(GlassControlPanelComponent( + theme: self.theme, + leftItem: self.selectedStickerPack == nil ? nil : GlassControlPanelComponent.Item( + items: [ + GlassControlGroupComponent.Item( + id: AnyHashable("back"), + content: .icon("Navigation/Back"), + action: { [weak self] in + guard let self, let contentNode = self.contentNode as? StickerPaneSearchContentNode else { + return + } + contentNode.clearSelectedPack() + } + ) + ], + background: .panel + ), + centralItem: nil, + rightItem: self.selectedStickerPack == nil ? nil : GlassControlPanelComponent.Item( + items: [ + GlassControlGroupComponent.Item( + id: AnyHashable("more"), + content: .animation("anim_morewide"), + action: { [weak self] in + self?.openSelectedPackMoreMenu() + } + ) + ], + background: .panel + ), + centerAlignmentIfPossible: true, + isDark: self.theme.overallDarkAppearance + )), + environment: {}, + containerSize: navigationButtonsFrame.size + ) + if let navigationButtons = self.navigationButtons.view { + if navigationButtons.superview == nil { + self.view.addSubview(navigationButtons) + } + navigationButtons.isUserInteractionEnabled = self.selectedStickerPack != nil + componentTransition.setFrame(view: navigationButtons, frame: CGRect(origin: navigationButtonsFrame.origin, size: navigationButtonsSize)) + //componentTransition.setAlpha(view: navigationButtons, alpha: self.selectedStickerPack != nil ? 1.0 : 0.0) + } + + let title = self.selectedStickerPack?.info.title ?? "" + let titleSize = self.selectedPackTitle.update( + transition: componentTransition, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: title, font: Font.semibold(17.0), textColor: self.theme.chat.inputPanel.primaryTextColor)), + horizontalAlignment: .center + )), + environment: {}, + containerSize: CGSize(width: max(1.0, size.width - leftInset - rightInset - 140.0), height: searchBarFieldHeight) + ) + if let titleView = self.selectedPackTitle.view { + if titleView.superview == nil { + self.view.addSubview(titleView) + } + titleView.isUserInteractionEnabled = false + let titleOrigin = CGPoint(x: leftInset + floor((size.width - leftInset - rightInset - titleSize.width) / 2.0), y: searchBarTopInset + floor((searchBarFieldHeight - titleSize.height) / 2.0)) + titleView.frame = CGRect(origin: titleOrigin, size: titleSize) + componentTransition.setAlpha(view: titleView, alpha: self.selectedStickerPack != nil ? 1.0 : 0.0) + } let contentFrame = CGRect(origin: CGPoint(x: leftInset, y: searchBarHeight), size: CGSize(width: size.width - leftInset - rightInset, height: size.height - searchBarHeight)) - transition.updateFrame(node: self.contentNode, frame: contentFrame) self.contentNode.updateLayout(size: contentFrame.size, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, inputHeight: inputHeight, deviceMetrics: deviceMetrics, transition: transition) } - + public func deactivate() { + if let contentNode = self.contentNode as? StickerPaneSearchContentNode { + contentNode.clearSelectedPack() + } self.searchBar.deactivate(clear: true) } - + public func animateIn(from placeholder: PaneSearchBarPlaceholderNode?, anchorTop: CGPoint, anhorTopView: UIView, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) { var verticalOrigin: CGFloat = anhorTopView.convert(anchorTop, to: self.view).y if let placeholder = placeholder { self.animatedPlaceholder = placeholder placeholder.isHidden = true - + let placeholderFrame = placeholder.view.convert(placeholder.bounds, to: self.view) verticalOrigin = placeholderFrame.minY - 4.0 self.contentNode.animateIn(additivePosition: verticalOrigin, transition: transition) } else { self.contentNode.animateIn(additivePosition: 0.0, transition: transition) } - + let searchBarFrame = self.searchBar.frame let initialSearchBarFrame = CGRect(origin: CGPoint(x: searchBarFrame.minX, y: verticalOrigin), size: searchBarFrame.size) - + switch transition { case let .animated(duration, curve): self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration / 2.0) - + self.searchBar.alpha = 1.0 self.searchBar.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration, timingFunction: curve.timingFunction, completion: { _ in completion() }) self.searchBar.layer.animateFrame(from: initialSearchBarFrame, to: searchBarFrame, duration: duration, timingFunction: curve.timingFunction) - - if let size = self.validLayout { - let initialBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: verticalOrigin), size: CGSize(width: size.width, height: max(0.0, size.height - verticalOrigin))) + + if let layout = self.validLayout { + let initialBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: verticalOrigin), size: CGSize(width: layout.size.width, height: max(0.0, layout.size.height - verticalOrigin))) self.backgroundNode.layer.animateFrame(from: initialBackgroundFrame, to: self.backgroundNode.frame, duration: duration, timingFunction: curve.timingFunction) } case .immediate: @@ -246,7 +415,7 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer break } } - + public func animateOut(to placeholder: PaneSearchBarPlaceholderNode, animateOutSearchBar: Bool, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) { let finish: () -> Void = { [weak self] in placeholder.isHidden = false @@ -255,16 +424,16 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer } completion() } - + if case let .animated(duration, curve) = transition { let placeholderFrame = placeholder.view.convert(placeholder.bounds, to: self.view) let verticalOrigin = placeholderFrame.minY - 4.0 let targetSearchBarFrame = CGRect(origin: CGPoint(x: self.searchBar.frame.minX, y: verticalOrigin), size: self.searchBar.frame.size) - - if let size = self.validLayout { - self.backgroundNode.layer.animateFrame(from: self.backgroundNode.frame, to: CGRect(origin: CGPoint(x: 0.0, y: verticalOrigin), size: CGSize(width: size.width, height: max(0.0, size.height - verticalOrigin))), duration: duration, timingFunction: curve.timingFunction, removeOnCompletion: false) + + if let layout = self.validLayout { + self.backgroundNode.layer.animateFrame(from: self.backgroundNode.frame, to: CGRect(origin: CGPoint(x: 0.0, y: verticalOrigin), size: CGSize(width: layout.size.width, height: max(0.0, layout.size.height - verticalOrigin))), duration: duration, timingFunction: curve.timingFunction, removeOnCompletion: false) } - + self.searchBar.layer.animateFrame(from: self.searchBar.frame, to: targetSearchBarFrame, duration: duration, timingFunction: curve.timingFunction, removeOnCompletion: false) if animateOutSearchBar { self.searchBar.alpha = 0.0 @@ -282,12 +451,34 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer } finish() } - + transition.updateAlpha(node: self.backgroundNode, alpha: 0.0) if animateOutSearchBar { transition.updateAlpha(node: self.searchBar, alpha: 0.0) } + let componentTransition = ComponentTransition(transition) + if let headerView = self.navigationButtons.view { + componentTransition.setAlpha(view: headerView, alpha: 0.0) + } + if let titleView = self.selectedPackTitle.view { + componentTransition.setAlpha(view: titleView, alpha: 0.0) + } self.contentNode.animateOut(transition: transition) self.deactivate() } } + +private final class StickerPaneSearchHeaderContextReferenceContentSource: ContextReferenceContentSource { + private weak var sourceView: UIView? + + init(sourceView: UIView) { + self.sourceView = sourceView + } + + func transitionInfo() -> ContextControllerReferenceViewInfo? { + guard let sourceView = self.sourceView else { + return nil + } + return ContextControllerReferenceViewInfo(referenceView: sourceView, contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift index cc115ade09..0648188eac 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift @@ -2,6 +2,9 @@ import Foundation import UIKit import AsyncDisplayKit import Display +import ButtonComponent +import ComponentFlow +import EdgeEffect import SwiftSignalKit import Postbox import TelegramCore @@ -19,93 +22,230 @@ import ChatControllerInteraction import FeaturedStickersScreen import ChatPresentationInterfaceState import StickerResources +import EntityKeyboard +import EmojiTextAttachmentView +import MultilineTextComponent +import TextFormat + +private let packPanelHeight: CGFloat = 76.0 +private let collapsedPackPanelHeight: CGFloat = 40.0 private enum StickerSearchEntryId: Equatable, Hashable { case sticker(String?, Int64) - case global(ItemCollectionId) } private enum StickerSearchEntry: Identifiable, Comparable { case sticker(index: Int, code: String?, stickerItem: FoundStickerItem, theme: PresentationTheme) - case global(index: Int, info: StickerPackCollectionInfo, topItems: [StickerPackItem], installed: Bool, topSeparator: Bool) - + var stableId: StickerSearchEntryId { switch self { case let .sticker(_, code, stickerItem, _): return .sticker(code, stickerItem.file.fileId.id) - case let .global(_, info, _, _, _): - return .global(info.id) } } - + static func ==(lhs: StickerSearchEntry, rhs: StickerSearchEntry) -> Bool { - switch lhs { - case let .sticker(lhsIndex, lhsCode, lhsStickerItem, lhsTheme): - if case let .sticker(rhsIndex, rhsCode, rhsStickerItem, rhsTheme) = rhs { - if lhsIndex != rhsIndex { - return false - } - if lhsCode != rhsCode { - return false - } - if lhsStickerItem != rhsStickerItem { - return false - } - if lhsTheme !== rhsTheme { - return false - } - return true - } else { + switch (lhs, rhs) { + case let (.sticker(lhsIndex, lhsCode, lhsStickerItem, lhsTheme), .sticker(rhsIndex, rhsCode, rhsStickerItem, rhsTheme)): + if lhsIndex != rhsIndex { return false } - case let .global(index, info, topItems, installed, topSeparator): - if case .global(index, info, topItems, installed, topSeparator) = rhs { - return true - } else { + if lhsCode != rhsCode { return false } + if lhsStickerItem != rhsStickerItem { + return false + } + if lhsTheme !== rhsTheme { + return false + } + return true } } - + static func <(lhs: StickerSearchEntry, rhs: StickerSearchEntry) -> Bool { - switch lhs { - case let .sticker(lhsIndex, _, _, _): - switch rhs { - case let .sticker(rhsIndex, _, _, _): - return lhsIndex < rhsIndex - default: - return true - } - case let .global(lhsIndex, _, _, _, _): - switch rhs { - case .sticker: - return false - case let .global(rhsIndex, _, _, _, _): - return lhsIndex < rhsIndex - } + switch (lhs, rhs) { + case let (.sticker(lhsIndex, _, _, _), .sticker(rhsIndex, _, _, _)): + return lhsIndex < rhsIndex } } - + func item(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, interaction: StickerPaneSearchInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction) -> GridItem { switch self { case let .sticker(_, code, stickerItem, theme): return StickerPaneSearchStickerItem(context: context, theme: theme, code: code, stickerItem: stickerItem, inputNodeInteraction: inputNodeInteraction, selected: { node, layer, rect in interaction.sendSticker(.standalone(media: stickerItem.file), node.view, layer, rect) }) - case let .global(_, info, topItems, installed, topSeparator): - let itemContext = StickerPaneSearchGlobalItemContext() - itemContext.canPlayMedia = true - return StickerPaneSearchGlobalItem(context: context, theme: theme, strings: strings, listAppearance: false, info: StickerPackCollectionInfo.Accessor(info), topItems: topItems, topSeparator: topSeparator, regularInsets: false, installed: installed, unread: false, open: { - interaction.open(info) - }, install: { - interaction.install(info, topItems, !installed) - }, getItemIsPreviewed: { item in - return interaction.getItemIsPreviewed(item) - }, itemContext: itemContext) } } } +struct StickerPaneSearchSelectedPack { + let info: StickerPackCollectionInfo +} + +private struct StickerPaneSearchPack: Equatable { + let info: StickerPackCollectionInfo + let topItems: [StickerPackItem] + let installed: Bool +} + +private final class StickerSearchPackTopPanelItemComponent: Component { + typealias EnvironmentType = EntityKeyboardTopPanelItemEnvironment + + let context: AccountContext + let theme: PresentationTheme + let info: StickerPackCollectionInfo + let topItem: StickerPackItem? + let pressed: () -> Void + + init( + context: AccountContext, + theme: PresentationTheme, + info: StickerPackCollectionInfo, + topItem: StickerPackItem?, + pressed: @escaping () -> Void + ) { + self.context = context + self.theme = theme + self.info = info + self.topItem = topItem + self.pressed = pressed + } + + static func ==(lhs: StickerSearchPackTopPanelItemComponent, rhs: StickerSearchPackTopPanelItemComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.info != rhs.info { + return false + } + if lhs.topItem != rhs.topItem { + return false + } + return true + } + + final class View: UIView { + private var itemLayer: InlineStickerItemLayer? + private var itemFileId: MediaId? + private var titleView: ComponentView? + private var component: StickerSearchPackTopPanelItemComponent? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.component?.pressed() + } + } + + func update(component: StickerSearchPackTopPanelItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + + let itemEnvironment = environment[EntityKeyboardTopPanelItemEnvironment.self].value + let file = component.topItem?.file._parse() + let fileId = file?.fileId + if self.itemFileId != fileId { + self.itemFileId = fileId + if let itemLayer = self.itemLayer { + self.itemLayer = nil + itemLayer.removeFromSuperlayer() + } + + if let file { + let itemDimensions = file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0) + let displaySize = itemDimensions.aspectFitted(CGSize(width: 44.0, height: 44.0)) + let itemLayer = InlineStickerItemLayer( + context: component.context, + userLocation: .other, + attemptSynchronousLoad: false, + emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file), + file: file, + cache: component.context.animationCache, + renderer: component.context.animationRenderer, + placeholderColor: component.theme.chat.inputPanel.primaryTextColor.withMultipliedAlpha(0.1), + pointSize: displaySize, + dynamicColor: .white + ) + self.itemLayer = itemLayer + self.layer.addSublayer(itemLayer) + } + } + + let iconFitSize: CGSize = itemEnvironment.isExpanded ? CGSize(width: 44.0, height: 44.0) : CGSize(width: 24.0, height: 24.0) + if let itemLayer = self.itemLayer, let file { + let itemDimensions = file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0) + let iconSize = itemDimensions.aspectFitted(iconFitSize) + let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) / 2.0), y: floor((iconFitSize.height - iconSize.height) / 2.0)), size: iconSize) + transition.setPosition(layer: itemLayer, position: CGPoint(x: iconFrame.midX, y: iconFrame.midY)) + transition.setBounds(layer: itemLayer, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) + itemLayer.isVisibleForAnimations = itemEnvironment.isContentInFocus && component.context.sharedContext.energyUsageSettings.loopStickers + } + + if itemEnvironment.isExpanded { + let titleView: ComponentView + if let current = self.titleView { + titleView = current + } else { + titleView = ComponentView() + self.titleView = titleView + } + let titleSize = titleView.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: component.info.title, font: Font.regular(10.0), textColor: component.theme.chat.inputPanel.primaryTextColor)), + insets: UIEdgeInsets(top: 1.0, left: 1.0, bottom: 1.0, right: 1.0) + )), + environment: {}, + containerSize: CGSize(width: 62.0, height: 100.0) + ) + if let view = titleView.view { + if view.superview == nil { + view.alpha = 0.0 + self.addSubview(view) + } + view.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) / 2.0), y: availableSize.height - titleSize.height - 1.0), size: titleSize) + transition.setAlpha(view: view, alpha: 1.0) + } + } else if let titleView = self.titleView { + self.titleView = nil + if let view = titleView.view { + if !transition.animation.isImmediate { + view.alpha = 0.0 + view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.08, completion: { [weak view] _ in + view?.removeFromSuperview() + }) + } else { + view.removeFromSuperview() + } + } + } + + return availableSize + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + private struct StickerPaneSearchGridTransition { let deletions: [Int] let insertions: [GridNodeInsertItem] @@ -114,6 +254,7 @@ private struct StickerPaneSearchGridTransition { let stationaryItems: GridNodeStationaryItems let scrollToItem: GridNodeScrollToItem? let animated: Bool + let crossfade: Bool } private struct StickerPaneSearchStickerState { @@ -122,21 +263,21 @@ private struct StickerPaneSearchStickerState { let isLoadingMore: Bool } -private func preparedChatMediaInputGridEntryTransition(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, from fromEntries: [StickerSearchEntry], to toEntries: [StickerSearchEntry], interaction: StickerPaneSearchInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction) -> StickerPaneSearchGridTransition { +private func preparedChatMediaInputGridEntryTransition(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, from fromEntries: [StickerSearchEntry], to toEntries: [StickerSearchEntry], interaction: StickerPaneSearchInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction, crossfade: Bool) -> StickerPaneSearchGridTransition { let stationaryItems: GridNodeStationaryItems = .none let scrollToItem: GridNodeScrollToItem? = nil var animated = false animated = true - + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) - + let deletions = deleteIndices let insertions = indicesAndItems.map { GridNodeInsertItem(index: $0.0, item: $0.1.item(context: context, theme: theme, strings: strings, interaction: interaction, inputNodeInteraction: inputNodeInteraction), previousIndex: $0.2) } let updates = updateIndices.map { GridNodeUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, theme: theme, strings: strings, interaction: interaction, inputNodeInteraction: inputNodeInteraction)) } - + let firstIndexInSectionOffset = 0 - - return StickerPaneSearchGridTransition(deletions: deletions, insertions: insertions, updates: updates, updateFirstIndexInSectionOffset: firstIndexInSectionOffset, stationaryItems: stationaryItems, scrollToItem: scrollToItem, animated: animated) + + return StickerPaneSearchGridTransition(deletions: deletions, insertions: insertions, updates: updates, updateFirstIndexInSectionOffset: firstIndexInSectionOffset, stationaryItems: stationaryItems, scrollToItem: scrollToItem, animated: animated, crossfade: crossfade) } final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { @@ -144,105 +285,131 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { private let interaction: ChatEntityKeyboardInputNode.Interaction private let inputNodeInteraction: ChatMediaInputNodeInteraction private var searchInteraction: StickerPaneSearchInteraction? - + private var theme: PresentationTheme private var strings: PresentationStrings - + private let trendingPane: ChatMediaInputTrendingPane private let gridNode: GridNode private let notFoundNode: ASImageNode private let notFoundLabel: ImmediateTextNode - - private var validLayout: CGSize? - + private let packPanel = ComponentView() + private let topEdgeEffectView = EdgeEffectView() + private let bottomEdgeEffectView = EdgeEffectView() + private let selectedPackAddButton = ComponentView() + private let packPanelVisibilityFractionUpdated = ActionSlot<(CGFloat, ComponentTransition)>() + private let packPanelActiveItemUpdated = ActionSlot<(AnyHashable, AnyHashable?, ComponentTransition)>() + + private var validLayout: (size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics)? + private var enqueuedTransitions: [StickerPaneSearchGridTransition] = [] - + private let searchDisposable = MetaDisposable() - + private let selectedPackDisposable = MetaDisposable() + private let queue = Queue() private let currentEntries = Atomic<[StickerSearchEntry]?>(value: nil) private let currentRemotePacks = Atomic(value: nil) + private var currentSearchEntries: [StickerSearchEntry] = [] + private var currentPacks: [StickerPaneSearchPack] = [] + private var currentSearchIsFinal: Bool = false + private var searchIsActive: Bool = false + private var selectedPack: StickerPaneSearchPack? + private var isPackPanelExpanded: Bool = true + private var installedPackIds = Set() private var stickerSearchContext: StickerSearchContext? + private var currentSearchStickerCount: Int = 0 private var currentStickerCount: Int = 0 - + private let _ready = Promise() var ready: Signal { return self._ready.get() } - + var deactivateSearchBar: (() -> Void)? var updateActivity: ((Bool) -> Void)? - + var selectedPackUpdated: ((StickerPaneSearchSelectedPack?) -> Void)? + private let installDisposable = MetaDisposable() - + init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, interaction: ChatEntityKeyboardInputNode.Interaction, inputNodeInteraction: ChatMediaInputNodeInteraction, stickerActionTitle: String?) { self.context = context self.interaction = interaction self.inputNodeInteraction = inputNodeInteraction - + self.theme = theme self.strings = strings - + let trendingPaneInteraction = ChatMediaInputTrendingPane.Interaction( sendSticker: interaction.sendSticker, presentController: interaction.presentController, getNavigationController: interaction.getNavigationController ) - + self.trendingPane = ChatMediaInputTrendingPane(context: context, forceTheme: theme, interaction: trendingPaneInteraction, getItemIsPreviewed: { [weak inputNodeInteraction] item in return inputNodeInteraction?.previewedStickerPackItemFile?.id == item.file.id }, isPane: false) self.trendingPane.stickerActionTitle = stickerActionTitle - + self.gridNode = GridNode() - + self.notFoundNode = ASImageNode() self.notFoundNode.displayWithoutProcessing = true self.notFoundNode.displaysAsynchronously = false self.notFoundNode.clipsToBounds = false - + self.notFoundLabel = ImmediateTextNode() self.notFoundLabel.displaysAsynchronously = false self.notFoundLabel.isUserInteractionEnabled = false self.notFoundNode.addSubnode(self.notFoundLabel) - + self.gridNode.isHidden = true self.trendingPane.isHidden = false self.notFoundNode.isHidden = true - + self.topEdgeEffectView.isUserInteractionEnabled = false + self.bottomEdgeEffectView.isUserInteractionEnabled = false + self.bottomEdgeEffectView.alpha = 0.0 + super.init() - + self.addSubnode(self.trendingPane) self.addSubnode(self.gridNode) self.addSubnode(self.notFoundNode) - + self.view.addSubview(self.topEdgeEffectView) + self.view.addSubview(self.bottomEdgeEffectView) + self.gridNode.scrollView.alwaysBounceVertical = true self.gridNode.scrollingInitiated = { [weak self] in self?.deactivateSearchBar?() } self.gridNode.visibleItemsUpdated = { [weak self] visibleItems in - guard let self, let (bottomVisible, _) = visibleItems.bottomVisible else { + guard let self else { return } - guard self.currentStickerCount != 0 else { + self.updatePackPanelExpansionFromScroll() + + guard let (bottomVisible, _) = visibleItems.bottomVisible else { + return + } + guard self.selectedPack == nil, self.currentStickerCount != 0 else { return } if bottomVisible >= max(0, self.currentStickerCount - 8) { self.stickerSearchContext?.loadMore() } } - + self.trendingPane.scrollingInitiated = { [weak self] in self?.deactivateSearchBar?() } - + self.searchInteraction = StickerPaneSearchInteraction(open: { [weak self] info in if let strongSelf = self { strongSelf.view.window?.endEditing(true) let packReference: StickerPackReference = .id(id: info.id.id, accessHash: info.accessHash) - + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: theme) - + let controller = strongSelf.context.sharedContext.makeStickerPackScreen( context: strongSelf.context, updatedPresentationData: (presentationData, .single(presentationData)), @@ -297,7 +464,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { return .complete() } |> deliverOnMainQueue - + let context = strongSelf.context var cancelImpl: (() -> Void)? let progressSignal = Signal { subscriber in @@ -315,7 +482,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { |> runOn(Queue.mainQueue()) |> delay(0.12, queue: Queue.mainQueue()) let progressDisposable = progressSignal.start() - + installSignal = installSignal |> afterDisposed { Queue.mainQueue().async { @@ -325,12 +492,12 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { cancelImpl = { self?.installDisposable.set(nil) } - + strongSelf.installDisposable.set(installSignal.start(next: { info, items in guard let strongSelf = self else { return } - + var animateInAsReplacement = false if let navigationController = strongSelf.interaction.getNavigationController() { for controller in navigationController.overlayControllers { @@ -340,7 +507,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { } } } - + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: theme) strongSelf.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: strongSelf.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return true @@ -359,30 +526,36 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { }, getItemIsPreviewed: { item in return inputNodeInteraction.previewedStickerPackItemFile?.id == item.file.id }) - + self._ready.set(self.trendingPane.ready) self.trendingPane.activate() - + self.updateThemeAndStrings(theme: theme, strings: strings) } - + deinit { self.searchDisposable.dispose() + self.selectedPackDisposable.dispose() self.installDisposable.dispose() } - + func updateText(_ text: String, languageCode: String?) { + if self.selectedPack != nil { + self.clearSelectedPack(applySearchResults: false) + } self.stickerSearchContext = nil + self.currentSearchStickerCount = 0 self.currentStickerCount = 0 + self.isPackPanelExpanded = true let _ = self.currentRemotePacks.swap(nil) - + + let query = text.trimmingCharacters(in: .whitespacesAndNewlines) let signal: Signal<(StickerPaneSearchStickerState, FoundStickerSets, Bool, FoundStickerSets?)?, NoError> - if !text.isEmpty { + if query.count >= 2 { let context = self.context - let query = text.trimmingCharacters(in: .whitespacesAndNewlines) let stickers: Signal if query.isSingleEmoji { - let searchContext = context.engine.stickers.stickerSearchContext(query: nil, emoticon: [text.basicEmoji.0]) + let searchContext = context.engine.stickers.stickerSearchContext(query: nil, emoticon: [query.basicEmoji.0]) stickers = searchContext.state |> map { state -> StickerPaneSearchStickerState in return StickerPaneSearchStickerState(context: searchContext, items: state.items, isLoadingMore: state.isLoadingMore) @@ -415,9 +588,9 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { } else { stickers = .single(StickerPaneSearchStickerState(context: nil, items: [], isLoadingMore: false)) } - - let local = context.engine.stickers.searchStickerSets(query: text) - let remote = context.engine.stickers.searchStickerSetsRemotely(query: text) + + let local = context.engine.stickers.searchStickerSets(query: query) + let remote = context.engine.stickers.searchStickerSetsRemotely(query: query) |> delay(0.2, queue: Queue.mainQueue()) let rawPacks = local |> mapToSignal { result -> Signal<(FoundStickerSets, Bool, FoundStickerSets?), NoError> in @@ -433,7 +606,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { } ) } - + let installedPackIds = context.account.postbox.combinedView(keys: [.itemCollectionInfos(namespaces: [Namespaces.ItemCollection.CloudStickerPacks])]) |> map { view -> Set in var installedPacks = Set() @@ -450,14 +623,14 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { let packs = combineLatest(rawPacks, installedPackIds) |> map { packs, installedPackIds -> (FoundStickerSets, Bool, FoundStickerSets?) in var (localPacks, completed, remotePacks) = packs - + for i in 0 ..< localPacks.infos.count { let installed = installedPackIds.contains(localPacks.infos[i].0) if installed != localPacks.infos[i].3 { localPacks.infos[i].3 = installed } } - + if remotePacks != nil { for i in 0 ..< remotePacks!.infos.count { let installed = installedPackIds.contains(remotePacks!.infos[i].0) @@ -466,10 +639,10 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { } } } - + return (localPacks, completed, remotePacks) } - + signal = combineLatest(stickers, packs) |> map { stickers, packs -> (StickerPaneSearchStickerState, FoundStickerSets, Bool, FoundStickerSets?)? in return (stickers, packs.0, packs.1 && !stickers.isLoadingMore, packs.2) @@ -478,51 +651,76 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { signal = .single(nil) self.updateActivity?(false) } - + self.searchDisposable.set((signal |> deliverOn(self.queue)).start(next: { [weak self] result in Queue.mainQueue().async { guard let strongSelf = self, let interaction = strongSelf.searchInteraction else { return } - - var entries: [StickerSearchEntry] = [] + if let (stickers, packs, final, remote) = result { strongSelf.stickerSearchContext = stickers.context + strongSelf.currentSearchStickerCount = stickers.items.count strongSelf.currentStickerCount = stickers.items.count strongSelf.updateActivity?(stickers.items.isEmpty && stickers.isLoadingMore) - + if let remote = remote { let _ = strongSelf.currentRemotePacks.swap(remote) } strongSelf.gridNode.isHidden = false strongSelf.trendingPane.isHidden = true + + let previousPacks = strongSelf.currentPacks + let entries = strongSelf.entries(stickers: stickers.items) - entries = strongSelf.entries(stickers: stickers.items, packs: packs) - - if final || !entries.isEmpty { - strongSelf.notFoundNode.isHidden = !entries.isEmpty - } else { - strongSelf.notFoundNode.isHidden = true + var packItems: [StickerPaneSearchPack] = strongSelf.packs(from: packs) + if !strongSelf.installedPackIds.isEmpty { + packItems = packItems.map { pack in + if strongSelf.installedPackIds.contains(pack.info.id) && !pack.installed { + return StickerPaneSearchPack(info: pack.info, topItems: pack.topItems, installed: true) + } else { + return pack + } + } + } + + strongSelf.currentSearchEntries = entries + strongSelf.currentPacks = packItems + if let selectedPack = strongSelf.selectedPack, let updatedPack = packItems.first(where: { $0.info.id == selectedPack.info.id }), selectedPack.installed != updatedPack.installed { + strongSelf.selectedPack = StickerPaneSearchPack(info: selectedPack.info, topItems: selectedPack.topItems, installed: updatedPack.installed) + } + strongSelf.currentSearchIsFinal = final + strongSelf.searchIsActive = true + + if strongSelf.selectedPack == nil { + strongSelf.enqueueEntries(entries, interaction: interaction) + strongSelf.updateNotFound() + } + if previousPacks != packItems { + strongSelf.requestLayout(transition: .immediate) } } else { let _ = strongSelf.currentRemotePacks.swap(nil) strongSelf.stickerSearchContext = nil + strongSelf.currentSearchStickerCount = 0 strongSelf.currentStickerCount = 0 + strongSelf.currentSearchEntries = [] + strongSelf.currentPacks = [] + strongSelf.currentSearchIsFinal = false + strongSelf.searchIsActive = false strongSelf.updateActivity?(false) strongSelf.gridNode.isHidden = true strongSelf.notFoundNode.isHidden = true strongSelf.trendingPane.isHidden = false + strongSelf.enqueueEntries([], interaction: interaction) + strongSelf.requestLayout(transition: .immediate) } - - let previousEntries = strongSelf.currentEntries.swap(entries) - let transition = preparedChatMediaInputGridEntryTransition(context: strongSelf.context, theme: strongSelf.theme, strings: strongSelf.strings, from: previousEntries ?? [], to: entries, interaction: interaction, inputNodeInteraction: strongSelf.inputNodeInteraction) - strongSelf.enqueueTransition(transition) } })) } - - private func entries(stickers: [FoundStickerItem], packs: FoundStickerSets) -> [StickerSearchEntry] { + + private func entries(stickers: [FoundStickerItem]) -> [StickerSearchEntry] { var entries: [StickerSearchEntry] = [] var index = 0 var existingStickerIds = Set() @@ -533,60 +731,289 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { existingStickerIds.insert(id) } } - - var isFirstGlobal = true + + return entries + } + + private func packs(from packs: FoundStickerSets) -> [StickerPaneSearchPack] { + var result: [StickerPaneSearchPack] = [] + var existingIds = Set() for (collectionId, info, _, installed) in packs.infos { - if let info = info as? StickerPackCollectionInfo { - var topItems: [StickerPackItem] = [] - for entry in packs.entries { - if let item = entry.item as? StickerPackItem, entry.index.collectionId == collectionId { - topItems.append(item) - } + guard !existingIds.contains(collectionId), let info = info as? StickerPackCollectionInfo else { + continue + } + existingIds.insert(collectionId) + + var topItems: [StickerPackItem] = [] + for entry in packs.entries { + if let item = entry.item as? StickerPackItem, entry.index.collectionId == collectionId { + topItems.append(item) } - entries.append(.global(index: index, info: info, topItems: topItems, installed: installed, topSeparator: !isFirstGlobal)) - isFirstGlobal = false + } + result.append(StickerPaneSearchPack(info: info, topItems: topItems, installed: installed)) + } + + return result + } + + private func entries(packItems: [StickerPackItem]) -> [StickerSearchEntry] { + var entries: [StickerSearchEntry] = [] + var existingStickerIds = Set() + var index = 0 + for item in packItems { + let file = item.file._parse() + if let id = file.id, !existingStickerIds.contains(id) { + entries.append(.sticker(index: index, code: nil, stickerItem: FoundStickerItem(file: file, stringRepresentations: item.getStringRepresentationsOfIndexKeys()), theme: self.theme)) + existingStickerIds.insert(id) index += 1 } } - return entries } + + private var shouldDisplayPackPanel: Bool { + return self.searchIsActive && !self.currentPacks.isEmpty + } + + private var currentPackPanelHeight: CGFloat { + guard self.shouldDisplayPackPanel else { + return 0.0 + } + return packPanelHeight + } + + private var currentVisiblePackPanelHeight: CGFloat { + guard self.shouldDisplayPackPanel else { + return 0.0 + } + return self.isPackPanelExpanded ? packPanelHeight : collapsedPackPanelHeight + } + + private var isInstallPackButtonVisible: Bool { + guard let selectedPack = self.selectedPack else { + return false + } + return !selectedPack.installed && !self.installedPackIds.contains(selectedPack.info.id) + } + private func markPackInstalled(_ id: ItemCollectionId) { + self.installedPackIds.insert(id) + + if let selectedPack = self.selectedPack, selectedPack.info.id == id, !selectedPack.installed { + self.selectedPack = StickerPaneSearchPack(info: selectedPack.info, topItems: selectedPack.topItems, installed: true) + } + + self.currentPacks = self.currentPacks.map { pack in + if pack.info.id == id && !pack.installed { + return StickerPaneSearchPack(info: pack.info, topItems: pack.topItems, installed: true) + } else { + return pack + } + } + } + + private func updatePackPanelExpansionFromScroll() { + guard self.shouldDisplayPackPanel else { + return + } + + let contentOffsetY = self.gridNode.scrollView.contentOffset.y + let shouldExpand: Bool + if self.gridNode.scrollView.contentInset.top < 10.0 { + shouldExpand = true + } else { + shouldExpand = contentOffsetY <= -packPanelHeight + 20.0 + } + if self.isPackPanelExpanded != shouldExpand { + self.isPackPanelExpanded = shouldExpand + self.requestLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) + } + } + + private func resetGridScrollToTop() { + let scrollView = self.gridNode.scrollView + scrollView.setContentOffset(CGPoint(x: 0.0, y: -scrollView.contentInset.top), animated: false) + } + + private func enqueueEntries(_ entries: [StickerSearchEntry], interaction: StickerPaneSearchInteraction, crossfade: Bool = false) { + let previousEntries = self.currentEntries.swap(entries) + let transition = preparedChatMediaInputGridEntryTransition(context: self.context, theme: self.theme, strings: self.strings, from: previousEntries ?? [], to: entries, interaction: interaction, inputNodeInteraction: self.inputNodeInteraction, crossfade: crossfade) + self.enqueueTransition(transition) + } + + private func updateNotFound() { + if self.selectedPack != nil || !self.searchIsActive { + self.notFoundNode.isHidden = true + } else if self.currentSearchIsFinal || !self.currentSearchEntries.isEmpty || !self.currentPacks.isEmpty { + self.notFoundNode.isHidden = !(self.currentSearchEntries.isEmpty && self.currentPacks.isEmpty) + } else { + self.notFoundNode.isHidden = true + } + } + + private func selectPack(_ pack: StickerPaneSearchPack) { + guard let interaction = self.searchInteraction else { + return + } + + self.view.window?.endEditing(true) + self.deactivateSearchBar?() + + self.selectedPackDisposable.set(nil) + self.selectedPack = pack + self.currentStickerCount = 0 + self.notFoundNode.isHidden = true + self.gridNode.isHidden = false + self.trendingPane.isHidden = true + self.selectedPackUpdated?(StickerPaneSearchSelectedPack(info: pack.info)) + + self.enqueueEntries(self.entries(packItems: pack.topItems), interaction: interaction, crossfade: true) + self.isPackPanelExpanded = true + self.requestLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) + self.resetGridScrollToTop() + + let packId = pack.info.id + self.selectedPackDisposable.set((self.context.engine.stickers.loadedStickerPack(reference: .id(id: pack.info.id.id, accessHash: pack.info.accessHash), forceActualized: false) + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self, let interaction = self.searchInteraction, self.selectedPack?.info.id == packId else { + return + } + switch result { + case let .result(_, items, _): + self.enqueueEntries(self.entries(packItems: items), interaction: interaction) + case .fetching, .none: + break + } + })) + } + + private func installSelectedStickerPack() { + guard let selectedPack = self.selectedPack, !selectedPack.installed, !self.installedPackIds.contains(selectedPack.info.id) else { + return + } + + let context = self.context + let packId = selectedPack.info.id + let accessHash = selectedPack.info.accessHash + + self.markPackInstalled(packId) + self.requestLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) + + let installSignal = (context.engine.stickers.loadedStickerPack(reference: .id(id: packId.id, accessHash: accessHash), forceActualized: false) + |> mapToSignal { result -> Signal<(StickerPackCollectionInfo, [StickerPackItem]), NoError> in + switch result { + case let .result(info, items, installed): + let info = info._parse() + if installed { + return .single((info, items)) + } else { + return preloadedStickerPackThumbnail(account: context.account, info: StickerPackCollectionInfo.Accessor(info), items: items) + |> filter { $0 } + |> ignoreValues + |> then( + context.engine.stickers.addStickerPackInteractively(info: info, items: items) + |> ignoreValues + ) + |> mapToSignal { _ -> Signal<(StickerPackCollectionInfo, [StickerPackItem]), NoError> in + } + |> then(.single((info, items))) + } + case .fetching: + break + case .none: + break + } + return .complete() + } + |> deliverOnMainQueue) + + self.installDisposable.set(installSignal.start(next: { [weak self] info, items in + guard let self else { + return + } + + var animateInAsReplacement = false + if let navigationController = self.interaction.getNavigationController() { + for controller in navigationController.overlayControllers { + if let controller = controller as? UndoOverlayController { + controller.dismissWithCommitActionAndReplacementAnimation() + animateInAsReplacement = true + } + } + } + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: self.theme) + self.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: self.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in + return true + })) + })) + } + + func clearSelectedPack(applySearchResults: Bool = true) { + guard self.selectedPack != nil else { + return + } + self.selectedPack = nil + self.selectedPackDisposable.set(nil) + + self.selectedPackUpdated?(nil) + + if applySearchResults, let interaction = self.searchInteraction { + self.currentStickerCount = self.currentSearchStickerCount + self.gridNode.isHidden = !self.searchIsActive + self.trendingPane.isHidden = self.searchIsActive + self.enqueueEntries(self.currentSearchEntries, interaction: interaction, crossfade: true) + self.updateNotFound() + self.requestLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) + } + } + func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { + self.theme = theme + self.strings = strings self.notFoundNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Media/StickersNotFoundIcon"), color: theme.list.freeMonoIconColor) self.notFoundLabel.attributedText = NSAttributedString(string: strings.Stickers_NoStickersFound, font: Font.medium(14.0), textColor: theme.list.freeTextColor) } - + private func enqueueTransition(_ transition: StickerPaneSearchGridTransition) { self.enqueuedTransitions.append(transition) - + if self.validLayout != nil { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } } - + private func dequeueTransition() { if let transition = self.enqueuedTransitions.first { self.enqueuedTransitions.remove(at: 0) + + if transition.crossfade, let snapshotView = self.gridNode.scrollView.snapshotContentTree() { + snapshotView.frame = self.gridNode.frame + self.gridNode.view.superview?.addSubview(snapshotView) + + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in + snapshotView.removeFromSuperview() + }) + + self.gridNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + } let itemTransition: ContainedViewLayoutTransition = .immediate self.gridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: transition.scrollToItem, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: transition.updateFirstIndexInSectionOffset), completion: { _ in }) } } - + func updatePreviewing(animated: Bool) { self.gridNode.forEachItemNode { itemNode in if let itemNode = itemNode as? StickerPaneSearchStickerItemNode { itemNode.updatePreviewing(animated: animated) - } else if let itemNode = itemNode as? StickerPaneSearchGlobalItemNode { - itemNode.updatePreviewing(animated: animated) } } self.trendingPane.updatePreviewing(animated: animated) } - + func itemAt(point: CGPoint) -> (ASDisplayNode, Any)? { if !self.trendingPane.isHidden { if let (itemNode, item) = self.trendingPane.itemAt(point: self.view.convert(point, to: self.trendingPane.view)) { @@ -596,36 +1023,165 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { if let itemNode = self.gridNode.itemNodeAtPoint(self.view.convert(point, to: self.gridNode.view)) { if let itemNode = itemNode as? StickerPaneSearchStickerItemNode, let stickerItem = itemNode.stickerItem { return (itemNode, StickerPreviewPeekItem.found(stickerItem)) - } else if let itemNode = itemNode as? StickerPaneSearchGlobalItemNode { - if let (node, item) = itemNode.itemAt(point: self.view.convert(point, to: itemNode.view)) { - return (node, StickerPreviewPeekItem.pack(item.file._parse())) - } } } } return nil } + + private func updatePackButtonsLayout(size: CGSize, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) { + if self.shouldDisplayPackPanel { + let componentTransition = ComponentTransition(transition) + let panelSize = self.packPanel.update( + transition: componentTransition, + component: AnyComponent(EntityKeyboardTopPanelComponent( + id: AnyHashable("stickerSearchPacks"), + theme: self.theme, + customTintColor: nil, + items: self.currentPacks.map { pack in + return EntityKeyboardTopPanelComponent.Item( + id: AnyHashable(pack.info.id), + isReorderable: false, + content: AnyComponent(StickerSearchPackTopPanelItemComponent( + context: self.context, + theme: self.theme, + info: pack.info, + topItem: pack.topItems.first, + pressed: { [weak self] in + self?.selectPack(pack) + } + )) + ) + }, + containerSideInset: 0.0, + forceActiveItemId: self.selectedPack.flatMap { AnyHashable($0.info.id) }, + displayHighlightInExpanded: true, + automaticallySelectsFirstItem: false, + itemSpacing: 14.0, + activeContentItemIdUpdated: self.packPanelActiveItemUpdated, + reorderItems: { _ in } + )), + environment: { + EntityKeyboardTopContainerPanelEnvironment( + isContentInFocus: true, + height: collapsedPackPanelHeight, + visibilityFractionUpdated: self.packPanelVisibilityFractionUpdated, + isExpandedUpdated: { _, _ in } + ) + }, + containerSize: CGSize(width: size.width, height: self.currentVisiblePackPanelHeight) + ) + + if let view = self.packPanel.view { + if view.superview == nil { + self.view.addSubview(view) + } + componentTransition.setFrame(view: view, frame: CGRect(origin: CGPoint(), size: panelSize)) + } + } else if let view = self.packPanel.view { + view.removeFromSuperview() + } + + let isVisible = self.isInstallPackButtonVisible + + let componentTransition = ComponentTransition(transition) + let edgeEffectHeight: CGFloat = isVisible ? 88.0 + bottomInset : 0.0 + let edgeEffectFrame = CGRect(origin: CGPoint(x: 0.0, y: size.height - edgeEffectHeight), size: CGSize(width: size.width, height: edgeEffectHeight)) + transition.updateFrame(view: self.bottomEdgeEffectView, frame: edgeEffectFrame) + self.bottomEdgeEffectView.update( + content: self.theme.chat.inputMediaPanel.stickersBackgroundColor.withAlphaComponent(1.0), + blur: true, + alpha: 1.0, + rect: edgeEffectFrame, + edge: .bottom, + edgeSize: min(edgeEffectFrame.height, 80.0), + transition: componentTransition + ) + transition.updateAlpha(layer: self.bottomEdgeEffectView.layer, alpha: isVisible ? 1.0 : 0.0) + + if isVisible, let selectedPack = self.selectedPack { + let buttonTitle = self.strings.StickerPack_AddStickerCount(selectedPack.info.count) + let buttonForegroundColor = self.theme.list.itemCheckColors.foregroundColor + let buttonBackgroundColor = self.theme.list.itemCheckColors.fillColor + let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: bottomInset, innerDiameter: 52.0, sideInset: 30.0) + let buttonSize = self.selectedPackAddButton.update( + transition: componentTransition, + component: AnyComponent(ButtonComponent( + background: ButtonComponent.Background( + style: .actualGlass, + color: buttonBackgroundColor, + foreground: buttonForegroundColor, + pressedColor: buttonBackgroundColor.withMultipliedAlpha(0.9) + ), + content: AnyComponentWithIdentity( + id: AnyHashable(buttonTitle), + component: AnyComponent(Text(text: buttonTitle, font: Font.semibold(17.0), color: buttonForegroundColor)) + ), + action: { [weak self] in + self?.installSelectedStickerPack() + } + )), + environment: {}, + containerSize: CGSize(width: max(0.0, size.width - buttonInsets.left - buttonInsets.right), height: 52.0) + ) + + if let buttonView = self.selectedPackAddButton.view { + if buttonView.superview == nil { + self.view.addSubview(buttonView) + } + buttonView.isUserInteractionEnabled = true + buttonView.frame = CGRect(origin: CGPoint(x: floor((size.width - buttonSize.width) / 2.0), y: size.height - bottomInset - buttonInsets.bottom - buttonSize.height), size: buttonSize) + componentTransition.setAlpha(view: buttonView, alpha: 1.0) + } + } else if let buttonView = self.selectedPackAddButton.view { + buttonView.isUserInteractionEnabled = false + componentTransition.setAlpha(view: buttonView, alpha: 0.0) + } + } + func requestLayout(transition: ContainedViewLayoutTransition) { + guard let (size, leftInset, rightInset, bottomInset, inputHeight, deviceMetrics) = self.validLayout else { + return + } + self.updateLayout(size: size, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, inputHeight: inputHeight, deviceMetrics: deviceMetrics, transition: transition) + } + func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics, transition: ContainedViewLayoutTransition) { let firstLayout = self.validLayout == nil - self.validLayout = size - + self.validLayout = (size, leftInset, rightInset, bottomInset, inputHeight, deviceMetrics) + + let edgeEffectHeight: CGFloat = 80.0 + let edgeEffectFrame = CGRect(origin: .zero, size: CGSize(width: size.width, height: edgeEffectHeight)) + transition.updateFrame(view: self.topEdgeEffectView, frame: edgeEffectFrame) + self.topEdgeEffectView.update( + content: self.theme.chat.inputMediaPanel.stickersBackgroundColor.withAlphaComponent(1.0), + blur: true, + alpha: 1.0, + rect: edgeEffectFrame, + edge: .top, + edgeSize: edgeEffectFrame.height, + transition: ComponentTransition(transition) + ) + transition.updateAlpha(layer: self.topEdgeEffectView.layer, alpha: self.shouldDisplayPackPanel ? 1.0 : 0.0) + self.updatePackButtonsLayout(size: size, bottomInset: bottomInset, transition: transition) + if let image = self.notFoundNode.image { - let areaHeight = size.height - inputHeight - + let areaHeight = max(0.0, size.height - inputHeight) + let labelSize = self.notFoundLabel.updateLayout(CGSize(width: size.width, height: CGFloat.greatestFiniteMagnitude)) - + transition.updateFrame(node: self.notFoundNode, frame: CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((areaHeight - image.size.height - labelSize.height) / 2.0)), size: image.size)) transition.updateFrame(node: self.notFoundLabel, frame: CGRect(origin: CGPoint(x: floor((image.size.width - labelSize.width) / 2.0), y: image.size.height + 8.0), size: labelSize)) } - + let contentFrame = CGRect(origin: CGPoint(), size: size) - self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: contentFrame.size, insets: UIEdgeInsets(top: 4.0, left: 0.0, bottom: 4.0 + bottomInset, right: 0.0), preloadSize: 300.0, type: .fixed(itemSize: CGSize(width: 75.0, height: 75.0), fillWidth: nil, lineSpacing: 0.0, itemSpacing: nil)), transition: transition), itemTransition: .immediate, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) + let gridTopInset: CGFloat = 4.0 + self.currentPackPanelHeight + self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: contentFrame.size, insets: UIEdgeInsets(top: gridTopInset, left: 0.0, bottom: 4.0 + bottomInset + 64.0, right: 0.0), preloadSize: 300.0, type: .fixed(itemSize: CGSize(width: 75.0, height: 75.0), fillWidth: nil, lineSpacing: 0.0, itemSpacing: nil)), transition: transition), itemTransition: .immediate, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) transition.updateFrame(node: self.trendingPane, frame: contentFrame) self.trendingPane.updateLayout(size: contentFrame.size, topInset: 0.0, bottomInset: bottomInset, isExpanded: false, isVisible: true, deviceMetrics: deviceMetrics, transition: transition) - + transition.updateFrame(node: self.gridNode, frame: contentFrame) if firstLayout { while !self.enqueuedTransitions.isEmpty { @@ -633,23 +1189,43 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { } } } - + func animateIn(additivePosition: CGFloat, transition: ContainedViewLayoutTransition) { self.gridNode.alpha = 0.0 transition.updateAlpha(node: self.gridNode, alpha: 1.0, completion: { _ in }) + if let view = self.packPanel.view { + view.alpha = 0.0 + ComponentTransition(transition).setAlpha(view: view, alpha: 1.0) + } + self.topEdgeEffectView.alpha = 0.0 + ComponentTransition(transition).setAlpha(view: self.topEdgeEffectView, alpha: self.shouldDisplayPackPanel ? 1.0 : 0.0) + self.bottomEdgeEffectView.alpha = 0.0 + ComponentTransition(transition).setAlpha(view: self.bottomEdgeEffectView, alpha: self.isInstallPackButtonVisible ? 1.0 : 0.0) + if let buttonView = self.selectedPackAddButton.view { + buttonView.alpha = 0.0 + ComponentTransition(transition).setAlpha(view: buttonView, alpha: self.isInstallPackButtonVisible ? 1.0 : 0.0) + } self.trendingPane.alpha = 0.0 transition.updateAlpha(node: self.trendingPane, alpha: 1.0, completion: { _ in }) - + if case let .animated(duration, curve) = transition { self.trendingPane.layer.animatePosition(from: CGPoint(x: 0.0, y: additivePosition), to: CGPoint(), duration: duration, timingFunction: curve.timingFunction, additive: true) } } - + func animateOut(transition: ContainedViewLayoutTransition) { transition.updateAlpha(node: self.gridNode, alpha: 0.0, completion: { _ in }) + if let view = self.packPanel.view { + ComponentTransition(transition).setAlpha(view: view, alpha: 0.0) + } + ComponentTransition(transition).setAlpha(view: self.topEdgeEffectView, alpha: 0.0) + ComponentTransition(transition).setAlpha(view: self.bottomEdgeEffectView, alpha: 0.0) + if let buttonView = self.selectedPackAddButton.view { + ComponentTransition(transition).setAlpha(view: buttonView, alpha: 0.0) + } transition.updateAlpha(node: self.trendingPane, alpha: 0.0, completion: { _ in }) transition.updateAlpha(node: self.notFoundNode, alpha: 0.0, completion: { _ in diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopContainerPanelComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopContainerPanelComponent.swift index 043dd60491..d5b92ee876 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopContainerPanelComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopContainerPanelComponent.swift @@ -12,7 +12,7 @@ public final class EntityKeyboardTopContainerPanelEnvironment: Equatable { let visibilityFractionUpdated: ActionSlot<(CGFloat, ComponentTransition)> let isExpandedUpdated: (Bool, ComponentTransition) -> Void - init( + public init( isContentInFocus: Bool, height: CGFloat, visibilityFractionUpdated: ActionSlot<(CGFloat, ComponentTransition)>, diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift index a28088ad5d..68918afe3c 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift @@ -1218,11 +1218,14 @@ public final class EntityKeyboardTopPanelComponent: Component { let containerSideInset: CGFloat let defaultActiveItemId: AnyHashable? let forceActiveItemId: AnyHashable? + let displayHighlightInExpanded: Bool + let automaticallySelectsFirstItem: Bool + let itemSpacing: CGFloat let activeContentItemIdUpdated: ActionSlot<(AnyHashable, AnyHashable?, ComponentTransition)> let activeContentItemMapping: [AnyHashable: AnyHashable] let reorderItems: ([Item]) -> Void - init( + public init( id: AnyHashable, theme: PresentationTheme, customTintColor: UIColor?, @@ -1230,6 +1233,9 @@ public final class EntityKeyboardTopPanelComponent: Component { containerSideInset: CGFloat, defaultActiveItemId: AnyHashable? = nil, forceActiveItemId: AnyHashable? = nil, + displayHighlightInExpanded: Bool = false, + automaticallySelectsFirstItem: Bool = true, + itemSpacing: CGFloat = 8.0, activeContentItemIdUpdated: ActionSlot<(AnyHashable, AnyHashable?, ComponentTransition)>, activeContentItemMapping: [AnyHashable: AnyHashable] = [:], reorderItems: @escaping ([Item]) -> Void @@ -1241,6 +1247,9 @@ public final class EntityKeyboardTopPanelComponent: Component { self.containerSideInset = containerSideInset self.defaultActiveItemId = defaultActiveItemId self.forceActiveItemId = forceActiveItemId + self.displayHighlightInExpanded = displayHighlightInExpanded + self.automaticallySelectsFirstItem = automaticallySelectsFirstItem + self.itemSpacing = itemSpacing self.activeContentItemIdUpdated = activeContentItemIdUpdated self.activeContentItemMapping = activeContentItemMapping self.reorderItems = reorderItems @@ -1268,6 +1277,15 @@ public final class EntityKeyboardTopPanelComponent: Component { if lhs.forceActiveItemId != rhs.forceActiveItemId { return false } + if lhs.displayHighlightInExpanded != rhs.displayHighlightInExpanded { + return false + } + if lhs.automaticallySelectsFirstItem != rhs.automaticallySelectsFirstItem { + return false + } + if lhs.itemSpacing != rhs.itemSpacing { + return false + } if lhs.activeContentItemIdUpdated !== rhs.activeContentItemIdUpdated { return false } @@ -1393,7 +1411,7 @@ public final class EntityKeyboardTopPanelComponent: Component { let isExpanded: Bool let items: [Item] - init(isExpanded: Bool, containerSideInset: CGFloat, height: CGFloat, items: [ItemDescription]) { + init(isExpanded: Bool, containerSideInset: CGFloat, height: CGFloat, itemSpacing: CGFloat, items: [ItemDescription]) { self.sideInset = containerSideInset + 7.0 self.isExpanded = isExpanded @@ -1401,7 +1419,7 @@ public final class EntityKeyboardTopPanelComponent: Component { self.staticItemSize = self.itemSize self.staticExpandedItemSize = self.isExpanded ? self.staticItemSize : CGSize(width: 134.0, height: 28.0) self.innerItemSize = self.isExpanded ? CGSize(width: 50.0, height: 62.0) : CGSize(width: 24.0, height: 24.0) - self.itemSpacing = 8.0 + self.itemSpacing = itemSpacing var contentSize = CGSize(width: sideInset, height: height) var resultItems: [Item] = [] @@ -2002,6 +2020,9 @@ public final class EntityKeyboardTopPanelComponent: Component { if let forceActiveItemId = component.forceActiveItemId { self.activeContentItemId = forceActiveItemId + } else if component.defaultActiveItemId == nil && !component.automaticallySelectsFirstItem { + self.activeContentItemId = nil + self.activeSubcontentItemId = nil } else if self.activeContentItemId == nil, let defaultActiveItemId = component.defaultActiveItemId { self.activeContentItemId = defaultActiveItemId } @@ -2045,12 +2066,12 @@ public final class EntityKeyboardTopPanelComponent: Component { } self.items = items - if self.activeContentItemId == nil { + if self.activeContentItemId == nil && component.automaticallySelectsFirstItem { self.activeContentItemId = items.first?.id } let previousItemLayout = self.itemLayout - let itemLayout = ItemLayout(isExpanded: isExpanded, containerSideInset: component.containerSideInset, height: availableSize.height, items: self.items.map { item -> ItemLayout.ItemDescription in + let itemLayout = ItemLayout(isExpanded: isExpanded, containerSideInset: component.containerSideInset, height: availableSize.height, itemSpacing: component.itemSpacing, items: self.items.map { item -> ItemLayout.ItemDescription in let isStatic = item.id == AnyHashable("static") return ItemLayout.ItemDescription( isStatic: isStatic, @@ -2063,7 +2084,14 @@ public final class EntityKeyboardTopPanelComponent: Component { var updatedBounds: CGRect? if wasExpanded != isExpanded, let previousItemLayout = previousItemLayout { - if !isExpanded { + let keepInitialScrollOffset = component.displayHighlightInExpanded && self.scrollView.bounds.origin.x <= 1.0 + if keepInitialScrollOffset { + let maxContentOffsetX = max(0.0, itemLayout.contentSize.width - availableSize.width) + updatedBounds = CGRect( + origin: CGPoint(x: min(max(0.0, self.scrollView.bounds.origin.x), maxContentOffsetX), y: 0.0), + size: availableSize + ) + } else if !isExpanded { if let draggingEndOffset = self.draggingEndOffset { if abs(self.scrollView.contentOffset.x - draggingEndOffset) > 16.0 { self.draggingFocusItemIndex = nil @@ -2073,105 +2101,107 @@ public final class EntityKeyboardTopPanelComponent: Component { } } - var visibleBounds = self.scrollView.bounds - visibleBounds.origin.x -= 280.0 - visibleBounds.size.width += 560.0 - - let previousVisibleRange = previousItemLayout.visibleItemRange(for: visibleBounds) - if previousVisibleRange.minIndex <= previousVisibleRange.maxIndex { - var itemIndex = self.draggingFocusItemIndex ?? ((previousVisibleRange.minIndex + previousVisibleRange.maxIndex) / 2) - if !isExpanded { - if self.scrollView.bounds.maxX >= self.scrollView.contentSize.width { - itemIndex = component.items.count - 1 - } - if self.scrollView.bounds.minX <= 0.0 { - itemIndex = 0 - } - } + if !keepInitialScrollOffset { + var visibleBounds = self.scrollView.bounds + visibleBounds.origin.x -= 280.0 + visibleBounds.size.width += 560.0 - var previousItemFrame = previousItemLayout.containerFrame(at: itemIndex) - var updatedItemFrame = itemLayout.containerFrame(at: itemIndex) - - let previousDistanceToItem = (previousItemFrame.minX - self.scrollView.bounds.minX) - let previousDistanceToItemRight = (previousItemFrame.maxX - self.scrollView.bounds.maxX) - var newBounds = CGRect(origin: CGPoint(x: updatedItemFrame.minX - previousDistanceToItem, y: 0.0), size: availableSize) - var useRightAnchor = false - if newBounds.minX > itemLayout.contentSize.width - self.scrollView.bounds.width { - newBounds.origin.x = itemLayout.contentSize.width - self.scrollView.bounds.width - itemIndex = component.items.count - 1 - useRightAnchor = true - } - if itemIndex == component.items.count - 1 { - useRightAnchor = true - } - if newBounds.minX < 0.0 { - newBounds.origin.x = 0.0 - itemIndex = 0 - useRightAnchor = false - } - - if useRightAnchor { - let _ = previousDistanceToItemRight - newBounds.origin.x = itemLayout.contentSize.width - self.scrollView.bounds.width - } - - previousItemFrame = previousItemLayout.containerFrame(at: itemIndex) - updatedItemFrame = itemLayout.containerFrame(at: itemIndex) - - self.draggingFocusItemIndex = itemIndex - - updatedBounds = newBounds - - var updatedVisibleBounds = newBounds - updatedVisibleBounds.origin.x -= 280.0 - updatedVisibleBounds.size.width += 560.0 - let updatedVisibleRange = itemLayout.visibleItemRange(for: updatedVisibleBounds) - - if useRightAnchor { - let baseFrame = CGRect(origin: CGPoint(x: updatedItemFrame.maxX - previousItemFrame.width, y: previousItemFrame.minY), size: previousItemFrame.size) - for index in updatedVisibleRange.minIndex ... updatedVisibleRange.maxIndex { - let indexDifference = index - itemIndex - if let itemView = self.itemViews[self.items[index].id] { - let itemContainerMaxX = baseFrame.maxX + CGFloat(indexDifference) * (previousItemLayout.itemSize.width + previousItemLayout.itemSpacing) - let itemContainerFrame = CGRect(origin: CGPoint(x: itemContainerMaxX - baseFrame.width, y: baseFrame.minY), size: baseFrame.size) - let itemOuterFrame = previousItemLayout.contentFrame(index: index, containerFrame: itemContainerFrame) - - let itemSize = itemView.bounds.size - itemView.frame = CGRect(origin: CGPoint(x: itemOuterFrame.minX + floor((itemOuterFrame.width - itemSize.width) / 2.0), y: itemOuterFrame.minY + floor((itemOuterFrame.height - itemSize.height) / 2.0)), size: itemSize) - - if let activeContentItemId = self.activeContentItemId, activeContentItemId == self.items[index].id { - self.highlightedIconBackgroundView.frame = itemOuterFrame - self.highlightedIconTintBackgroundView.frame = itemOuterFrame - } + let previousVisibleRange = previousItemLayout.visibleItemRange(for: visibleBounds) + if previousVisibleRange.minIndex <= previousVisibleRange.maxIndex { + var itemIndex = self.draggingFocusItemIndex ?? ((previousVisibleRange.minIndex + previousVisibleRange.maxIndex) / 2) + if !isExpanded { + if self.scrollView.bounds.maxX >= self.scrollView.contentSize.width { + itemIndex = component.items.count - 1 + } + if self.scrollView.bounds.minX <= 0.0 { + itemIndex = 0 } } - } else { - let baseFrame = CGRect(origin: CGPoint(x: updatedItemFrame.minX, y: previousItemFrame.minY), size: previousItemFrame.size) - for index in updatedVisibleRange.minIndex ... updatedVisibleRange.maxIndex { - let indexDifference = index - itemIndex - if let itemView = self.itemViews[self.items[index].id] { - var itemContainerOriginX = baseFrame.minX - if indexDifference > 0 { - for i in 0 ..< indexDifference { - itemContainerOriginX += previousItemLayout.itemSpacing - itemContainerOriginX += previousItemLayout.containerFrame(at: itemIndex + i).width - } - } else if indexDifference < 0 { - for i in 0 ..< (-indexDifference) { - itemContainerOriginX -= previousItemLayout.itemSpacing - itemContainerOriginX -= previousItemLayout.containerFrame(at: itemIndex - i - 1).width + + var previousItemFrame = previousItemLayout.containerFrame(at: itemIndex) + var updatedItemFrame = itemLayout.containerFrame(at: itemIndex) + + let previousDistanceToItem = (previousItemFrame.minX - self.scrollView.bounds.minX) + let previousDistanceToItemRight = (previousItemFrame.maxX - self.scrollView.bounds.maxX) + var newBounds = CGRect(origin: CGPoint(x: updatedItemFrame.minX - previousDistanceToItem, y: 0.0), size: availableSize) + var useRightAnchor = false + if newBounds.minX > itemLayout.contentSize.width - self.scrollView.bounds.width { + newBounds.origin.x = itemLayout.contentSize.width - self.scrollView.bounds.width + itemIndex = component.items.count - 1 + useRightAnchor = true + } + if itemIndex == component.items.count - 1 { + useRightAnchor = true + } + if newBounds.minX < 0.0 { + newBounds.origin.x = 0.0 + itemIndex = 0 + useRightAnchor = false + } + + if useRightAnchor { + let _ = previousDistanceToItemRight + newBounds.origin.x = itemLayout.contentSize.width - self.scrollView.bounds.width + } + + previousItemFrame = previousItemLayout.containerFrame(at: itemIndex) + updatedItemFrame = itemLayout.containerFrame(at: itemIndex) + + self.draggingFocusItemIndex = itemIndex + + updatedBounds = newBounds + + var updatedVisibleBounds = newBounds + updatedVisibleBounds.origin.x -= 280.0 + updatedVisibleBounds.size.width += 560.0 + let updatedVisibleRange = itemLayout.visibleItemRange(for: updatedVisibleBounds) + + if useRightAnchor { + let baseFrame = CGRect(origin: CGPoint(x: updatedItemFrame.maxX - previousItemFrame.width, y: previousItemFrame.minY), size: previousItemFrame.size) + for index in updatedVisibleRange.minIndex ... updatedVisibleRange.maxIndex { + let indexDifference = index - itemIndex + if let itemView = self.itemViews[self.items[index].id] { + let itemContainerMaxX = baseFrame.maxX + CGFloat(indexDifference) * (previousItemLayout.itemSize.width + previousItemLayout.itemSpacing) + let itemContainerFrame = CGRect(origin: CGPoint(x: itemContainerMaxX - baseFrame.width, y: baseFrame.minY), size: baseFrame.size) + let itemOuterFrame = previousItemLayout.contentFrame(index: index, containerFrame: itemContainerFrame) + + let itemSize = itemView.bounds.size + itemView.frame = CGRect(origin: CGPoint(x: itemOuterFrame.minX + floor((itemOuterFrame.width - itemSize.width) / 2.0), y: itemOuterFrame.minY + floor((itemOuterFrame.height - itemSize.height) / 2.0)), size: itemSize) + + if let activeContentItemId = self.activeContentItemId, activeContentItemId == self.items[index].id { + self.highlightedIconBackgroundView.frame = itemOuterFrame + self.highlightedIconTintBackgroundView.frame = itemOuterFrame } } - - let previousContainerFrame = previousItemLayout.containerFrame(at: index) - let itemContainerFrame = CGRect(origin: CGPoint(x: itemContainerOriginX, y: previousContainerFrame.minY), size: previousContainerFrame.size) - let itemOuterFrame = previousItemLayout.contentFrame(index: index, containerFrame: itemContainerFrame) - - let itemSize = itemView.bounds.size - itemView.frame = CGRect(origin: CGPoint(x: itemOuterFrame.minX + floor((itemOuterFrame.width - itemSize.width) / 2.0), y: itemOuterFrame.minY + floor((itemOuterFrame.height - itemSize.height) / 2.0)), size: itemSize) - - if let activeContentItemId = self.activeContentItemId, activeContentItemId == self.items[index].id { - self.highlightedIconBackgroundView.frame = itemOuterFrame + } + } else { + let baseFrame = CGRect(origin: CGPoint(x: updatedItemFrame.minX, y: previousItemFrame.minY), size: previousItemFrame.size) + for index in updatedVisibleRange.minIndex ... updatedVisibleRange.maxIndex { + let indexDifference = index - itemIndex + if let itemView = self.itemViews[self.items[index].id] { + var itemContainerOriginX = baseFrame.minX + if indexDifference > 0 { + for i in 0 ..< indexDifference { + itemContainerOriginX += previousItemLayout.itemSpacing + itemContainerOriginX += previousItemLayout.containerFrame(at: itemIndex + i).width + } + } else if indexDifference < 0 { + for i in 0 ..< (-indexDifference) { + itemContainerOriginX -= previousItemLayout.itemSpacing + itemContainerOriginX -= previousItemLayout.containerFrame(at: itemIndex - i - 1).width + } + } + + let previousContainerFrame = previousItemLayout.containerFrame(at: index) + let itemContainerFrame = CGRect(origin: CGPoint(x: itemContainerOriginX, y: previousContainerFrame.minY), size: previousContainerFrame.size) + let itemOuterFrame = previousItemLayout.contentFrame(index: index, containerFrame: itemContainerFrame) + + let itemSize = itemView.bounds.size + itemView.frame = CGRect(origin: CGPoint(x: itemOuterFrame.minX + floor((itemOuterFrame.width - itemSize.width) / 2.0), y: itemOuterFrame.minY + floor((itemOuterFrame.height - itemSize.height) / 2.0)), size: itemSize) + + if let activeContentItemId = self.activeContentItemId, activeContentItemId == self.items[index].id { + self.highlightedIconBackgroundView.frame = itemOuterFrame + } } } } @@ -2197,7 +2227,10 @@ public final class EntityKeyboardTopPanelComponent: Component { if let activeContentItemId = self.activeContentItemId { if let index = self.items.firstIndex(where: { $0.id == activeContentItemId }) { - let itemFrame = itemLayout.containerFrame(at: index) + var itemFrame = itemLayout.containerFrame(at: index) + if isExpanded && component.displayHighlightInExpanded { + itemFrame = CGRect(origin: CGPoint(x: itemFrame.midX - itemFrame.height / 2.0, y: itemFrame.minY), size: CGSize(width: itemFrame.height, height: itemFrame.height)) + } var highlightTransition = transition if self.highlightedIconBackgroundView.isHidden { @@ -2228,8 +2261,9 @@ public final class EntityKeyboardTopPanelComponent: Component { self.highlightedIconBackgroundView.isHidden = true self.highlightedIconTintBackgroundView.isHidden = true } - transition.setAlpha(view: self.highlightedIconBackgroundView, alpha: isExpanded ? 0.0 : 1.0) - transition.setAlpha(view: self.highlightedIconTintBackgroundView, alpha: isExpanded ? 0.0 : 1.0) + let highlightAlpha: CGFloat = isExpanded && !component.displayHighlightInExpanded ? 0.0 : 1.0 + transition.setAlpha(view: self.highlightedIconBackgroundView, alpha: highlightAlpha) + transition.setAlpha(view: self.highlightedIconTintBackgroundView, alpha: highlightAlpha) panelEnvironment.visibilityFractionUpdated.connect { [weak self] (fraction, transition) in guard let strongSelf = self else { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 09f764b071..495b454865 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -6766,6 +6766,19 @@ public final class PeerInfoScreenImpl: ViewController, PeerInfoScreen, KeyShortc }) self.updateTabBarSearchState(ViewController.TabBarSearchState(isActive: false), transition: .immediate) + + if let sourceMessageId { + let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) + |> deliverOnMainQueue).start(next: { peer in + guard case let .user(user) = peer else { + return + } + if case .personal = user.accessHash { + } else { + let _ = context.engine.peers.fetchAndUpdateCachedPeerData(peerId: peerId, sourceMessageId: sourceMessageId).startStandalone() + } + }) + } } required init(coder aDecoder: NSCoder) { diff --git a/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift index 81df672615..e329523617 100644 --- a/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift +++ b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift @@ -72,7 +72,13 @@ extension ChatControllerImpl { } do { - let _ = self.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() + if let reactionPeerId { + if let messageId = messageIds.first { + let _ = self.context.engine.messages.deleteReaction(messageId: messageId, authorId: reactionPeerId).startStandalone() + } + } else { + let _ = self.context.engine.messages.deleteMessagesInteractively(messageIds: Array(messageIds), type: .forEveryone).startStandalone() + } for authorId in result.deleteAllFromPeers { let _ = self.context.engine.messages.deleteAllMessagesWithAuthor(peerId: messagesPeerId, authorId: authorId, namespace: Namespaces.Message.Cloud).startStandalone() From 6df5f109739b05467716dbeaef51b197e1efc106 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Sat, 25 Apr 2026 18:35:36 +0200 Subject: [PATCH 2/2] Fix build --- .../Sources/AdminUserActionsSheet.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift index 87337edfff..fad404b7e4 100644 --- a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift @@ -553,13 +553,13 @@ private final class AdminUserActionsContentComponent: Component { leftIcon: .check(ListActionItemComponent.LeftIcon.Check( isSelected: !selectedPeers.isEmpty, toggle: { - component.toggleDeleteAllOptionPeerSelection(.messages, EnginePeer(component.peers[0].peer)) + component.toggleDeleteAllOptionPeerSelection(.messages, component.peers[0].peer) } )), icon: .none, accessory: nil, action: { _ in - component.toggleDeleteAllOptionPeerSelection(.messages, EnginePeer(component.peers[0].peer)) + component.toggleDeleteAllOptionPeerSelection(.messages, component.peers[0].peer) }, highlighting: .disabled ))) @@ -579,13 +579,13 @@ private final class AdminUserActionsContentComponent: Component { leftIcon: .check(ListActionItemComponent.LeftIcon.Check( isSelected: !additionalSelectedPeers.isEmpty, toggle: { - component.toggleDeleteAllOptionPeerSelection(.reactions, EnginePeer(component.peers[0].peer)) + component.toggleDeleteAllOptionPeerSelection(.reactions, component.peers[0].peer) } )), icon: .none, accessory: nil, action: { _ in - component.toggleDeleteAllOptionPeerSelection(.reactions, EnginePeer(component.peers[0].peer)) + component.toggleDeleteAllOptionPeerSelection(.reactions, component.peers[0].peer) }, highlighting: .disabled )))