diff --git a/submodules/ComposePollUI/Sources/ComposePollScreen.swift b/submodules/ComposePollUI/Sources/ComposePollScreen.swift index 4fe507f2f1..c96f0dd597 100644 --- a/submodules/ComposePollUI/Sources/ComposePollScreen.swift +++ b/submodules/ComposePollUI/Sources/ComposePollScreen.swift @@ -477,7 +477,7 @@ final class ComposePollScreenComponent: Component { defer { self.isUpdating = false } - + var alphaTransition = transition if !transition.animation.isImmediate { alphaTransition = alphaTransition.withAnimation(.curve(duration: 0.25, curve: .easeInOut)) @@ -850,7 +850,7 @@ final class ComposePollScreenComponent: Component { } } - if self.pollOptions.count < 10, let lastOption = self.pollOptions.last { + if self.pollOptions.count < component.initialData.maxPollAnswersCount, let lastOption = self.pollOptions.last { if lastOption.textInputState.text.length != 0 { self.pollOptions.append(PollOption(id: self.nextPollOptionId)) self.nextPollOptionId += 1 @@ -921,7 +921,7 @@ final class ComposePollScreenComponent: Component { contentHeight += 7.0 - let pollOptionsLimitReached = self.pollOptions.count >= 10 + let pollOptionsLimitReached = self.pollOptions.count >= component.initialData.maxPollAnswersCount var animatePollOptionsFooterIn = false var pollOptionsFooterTransition = transition if self.currentPollOptionsLimitReached != pollOptionsLimitReached { @@ -944,7 +944,7 @@ final class ComposePollScreenComponent: Component { maximumNumberOfLines: 0 )) } else { - let remainingCount = 10 - self.pollOptions.count + let remainingCount = component.initialData.maxPollAnswersCount - self.pollOptions.count let rawString = environment.strings.CreatePoll_OptionCountFooterFormat(Int32(remainingCount)) var pollOptionsFooterItems: [AnimatedTextComponent.Item] = [] @@ -1476,13 +1476,16 @@ public class ComposePollScreen: ViewControllerComponentContainer, AttachmentCont public final class InitialData { fileprivate let maxPollTextLength: Int fileprivate let maxPollOptionLength: Int + fileprivate let maxPollAnswersCount: Int fileprivate init( maxPollTextLength: Int, - maxPollOptionLength: Int + maxPollOptionLength: Int, + maxPollAnwsersCount: Int ) { self.maxPollTextLength = maxPollTextLength self.maxPollOptionLength = maxPollOptionLength + self.maxPollAnswersCount = maxPollAnwsersCount } } @@ -1577,9 +1580,14 @@ public class ComposePollScreen: ViewControllerComponentContainer, AttachmentCont } public static func initialData(context: AccountContext) -> InitialData { + var maxPollAnwsersCount: Int = 10 + if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["poll_answers_max"] as? Double { + maxPollAnwsersCount = Int(value) + } return InitialData( maxPollTextLength: Int(200), - maxPollOptionLength: 100 + maxPollOptionLength: 100, + maxPollAnwsersCount: maxPollAnwsersCount ) } diff --git a/submodules/ContactListUI/Sources/ContactListNode.swift b/submodules/ContactListUI/Sources/ContactListNode.swift index fe89cebbd1..4be8b9e67c 100644 --- a/submodules/ContactListUI/Sources/ContactListNode.swift +++ b/submodules/ContactListUI/Sources/ContactListNode.swift @@ -426,7 +426,9 @@ private func contactListNodeEntries(accountPeer: EnginePeer?, peers: [ContactLis let text = strings.Contacts_PermissionsText switch authorizationStatus { case .limited: - entries.append(.permissionLimited(theme, strings)) + if displaySortOptions { + entries.append(.permissionLimited(theme, strings)) + } case .denied: entries.append(.permissionInfo(theme, title, text, suppressed)) entries.append(.permissionEnable(theme, strings.Permissions_ContactsAllowInSettings_v0)) diff --git a/submodules/TelegramPresentationData/Sources/PresentationData.swift b/submodules/TelegramPresentationData/Sources/PresentationData.swift index 8ad523ddd1..1c35635c1b 100644 --- a/submodules/TelegramPresentationData/Sources/PresentationData.swift +++ b/submodules/TelegramPresentationData/Sources/PresentationData.swift @@ -116,6 +116,10 @@ public final class PresentationData: Equatable { return PresentationData(strings: self.strings, theme: self.theme, autoNightModeTriggered: self.autoNightModeTriggered, chatWallpaper: chatWallpaper, chatFontSize: self.chatFontSize, chatBubbleCorners: self.chatBubbleCorners, listsFontSize: self.listsFontSize, dateTimeFormat: self.dateTimeFormat, nameDisplayOrder: self.nameDisplayOrder, nameSortOrder: self.nameSortOrder, reduceMotion: self.reduceMotion, largeEmoji: self.largeEmoji) } + public func withUpdate(listsFontSize: PresentationFontSize) -> PresentationData { + return PresentationData(strings: self.strings, theme: self.theme, autoNightModeTriggered: self.autoNightModeTriggered, chatWallpaper: self.chatWallpaper, chatFontSize: self.chatFontSize, chatBubbleCorners: self.chatBubbleCorners, listsFontSize: listsFontSize, dateTimeFormat: self.dateTimeFormat, nameDisplayOrder: self.nameDisplayOrder, nameSortOrder: self.nameSortOrder, reduceMotion: self.reduceMotion, largeEmoji: self.largeEmoji) + } + public static func ==(lhs: PresentationData, rhs: PresentationData) -> Bool { return lhs.strings === rhs.strings && lhs.theme === rhs.theme && lhs.autoNightModeTriggered == rhs.autoNightModeTriggered && lhs.chatWallpaper == rhs.chatWallpaper && lhs.chatFontSize == rhs.chatFontSize && lhs.chatBubbleCorners == rhs.chatBubbleCorners && lhs.listsFontSize == rhs.listsFontSize && lhs.dateTimeFormat == rhs.dateTimeFormat && lhs.reduceMotion == rhs.reduceMotion && lhs.largeEmoji == rhs.largeEmoji } diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FilterSelectorComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FilterSelectorComponent.swift index 8d4bc8fff3..f282b15046 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FilterSelectorComponent.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/FilterSelectorComponent.swift @@ -2,17 +2,19 @@ import Foundation import UIKit import Display import ComponentFlow +import SwiftSignalKit import PlainButtonComponent -import MultilineTextWithEntitiesComponent +import MultilineTextComponent import BundleIconComponent import TextFormat import AccountContext +import LottieComponent public final class FilterSelectorComponent: Component { public struct Colors: Equatable { public var foreground: UIColor public var background: UIColor - + public init( foreground: UIColor, background: UIColor @@ -24,39 +26,45 @@ public final class FilterSelectorComponent: Component { public struct Item: Equatable { public var id: AnyHashable + public var index: Int public var iconName: String? public var title: String public var action: (UIView) -> Void - + public init( id: AnyHashable, + index: Int = 0, iconName: String? = nil, title: String, action: @escaping (UIView) -> Void ) { self.id = id + self.index = index self.iconName = iconName self.title = title self.action = action } public static func ==(lhs: Item, rhs: Item) -> Bool { - return lhs.id == rhs.id && lhs.iconName == rhs.iconName && lhs.title == rhs.title + return lhs.id == rhs.id && lhs.index == rhs.index && lhs.iconName == rhs.iconName && lhs.title == rhs.title } } - + public let context: AccountContext? public let colors: Colors public let items: [Item] + public let selectedItemId: AnyHashable? public init( context: AccountContext? = nil, colors: Colors, - items: [Item] + items: [Item], + selectedItemId: AnyHashable? ) { self.context = context self.colors = colors self.items = items + self.selectedItemId = selectedItemId } public static func ==(lhs: FilterSelectorComponent, rhs: FilterSelectorComponent) -> Bool { @@ -69,6 +77,9 @@ public final class FilterSelectorComponent: Component { if lhs.items != rhs.items { return false } + if lhs.selectedItemId != rhs.selectedItemId { + return false + } return true } @@ -123,14 +134,14 @@ public final class FilterSelectorComponent: Component { self.state = state let baseHeight: CGFloat = 28.0 - + var spacing: CGFloat = 6.0 let itemFont = Font.semibold(14.0) let allowScroll = true - + var innerContentWidth: CGFloat = 0.0 - + var validIds: [AnyHashable] = [] var index = 0 var itemViews: [AnyHashable: (VisibleItem, CGSize, ComponentTransition)] = [:] @@ -150,15 +161,17 @@ public final class FilterSelectorComponent: Component { validIds.append(itemId) let itemSize = itemView.title.update( - transition: .immediate, + transition: transition, component: AnyComponent(PlainButtonComponent( content: AnyComponent(ItemComponent( context: component.context, + index: item.index, iconName: item.iconName, text: item.title, font: itemFont, color: component.colors.foreground, - backgroundColor: component.colors.background + backgroundColor: component.colors.background, + isSelected: itemId == component.selectedItemId )), effectAlignment: .center, minSize: nil, @@ -217,7 +230,7 @@ public final class FilterSelectorComponent: Component { self.contentSize = CGSize(width: contentWidth, height: baseHeight) self.disablesInteractiveTransitionGestureRecognizer = contentWidth > availableSize.width - + return CGSize(width: min(contentWidth, availableSize.width), height: baseHeight) } } @@ -233,43 +246,52 @@ public final class FilterSelectorComponent: Component { extension CGRect { func interpolate(with other: CGRect, fraction: CGFloat) -> CGRect { - return CGRect( + return CGRect( x: self.origin.x * (1.0 - fraction) + (other.origin.x) * fraction, y: self.origin.y * (1.0 - fraction) + (other.origin.y) * fraction, width: self.size.width * (1.0 - fraction) + (other.size.width) * fraction, height: self.size.height * (1.0 - fraction) + (other.size.height) * fraction - ) - } + ) + } } -private final class ItemComponent: CombinedComponent { +private final class ItemComponent: Component { let context: AccountContext? + let index: Int let iconName: String? let text: String let font: UIFont let color: UIColor let backgroundColor: UIColor + let isSelected: Bool init( context: AccountContext?, + index: Int, iconName: String?, text: String, font: UIFont, color: UIColor, - backgroundColor: UIColor + backgroundColor: UIColor, + isSelected: Bool ) { self.context = context + self.index = index self.iconName = iconName self.text = text self.font = font self.color = color self.backgroundColor = backgroundColor + self.isSelected = isSelected } - + static func ==(lhs: ItemComponent, rhs: ItemComponent) -> Bool { if lhs.context !== rhs.context { return false } + if lhs.index != rhs.index { + return false + } if lhs.iconName != rhs.iconName { return false } @@ -285,44 +307,97 @@ private final class ItemComponent: CombinedComponent { if lhs.backgroundColor != rhs.backgroundColor { return false } + if lhs.isSelected != rhs.isSelected { + return false + } return true } - static var body: Body { - let background = Child(RoundedRectangle.self) - let title = Child(MultilineTextWithEntitiesComponent.self) - let icon = Child(BundleIconComponent.self) + public final class View: UIView { + private var component: ItemComponent? + private weak var state: EmptyComponentState? - return { context in - let component = context.component + private let background = ComponentView() + private let title = ComponentView() + private let icon = ComponentView() + + private var isSelected = false + private var iconName: String? + + private let playOnce = ActionSlot() + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: ItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + let previousComponent = self.component + self.component = component + self.state = state - let attributedTitle = NSMutableAttributedString(string: component.text, font: component.font, textColor: component.color) - let range = (attributedTitle.string as NSString).range(of: "⭐️") - if range.location != NSNotFound { - attributedTitle.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: range) + var animateTitleInDirection: CGFloat? + if let previousComponent, previousComponent.text != component.text, !transition.animation.isImmediate, let titleView = self.title.view, let snapshotView = titleView.snapshotView(afterScreenUpdates: false) { + snapshotView.frame = titleView.frame + self.addSubview(snapshotView) + + var direction: CGFloat = 1.0 + if previousComponent.index < component.index { + direction = -1.0 + } + + snapshotView.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 6.0 * direction), duration: 0.2, removeOnCompletion: false, additive: true) + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in + snapshotView.removeFromSuperview() + }) + + animateTitleInDirection = direction } - let title = title.update( - component: MultilineTextWithEntitiesComponent( - context: component.context, - animationCache: component.context?.animationCache, - animationRenderer: component.context?.animationRenderer, - placeholderColor: .white, + let attributedTitle = NSAttributedString(string: component.text, font: component.font, textColor: component.color) + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( text: .plain(attributedTitle) - ), - availableSize: context.availableSize, - transition: .immediate + )), + environment: {}, + containerSize: availableSize ) - let icon = icon.update( - component: BundleIconComponent( - name: component.iconName ?? "Item List/ExpandableSelectorArrows", - tintColor: component.color, - maxSize: component.iconName != nil ? CGSize(width: 22.0, height: 22.0) : nil - ), - availableSize: CGSize(width: 100, height: 100), - transition: .immediate + let animationName = component.iconName ?? (component.isSelected ? "GiftFilterMenuOpen" : "GiftFilterMenuClose") + let animationSize = component.iconName != nil ? CGSize(width: 22.0, height: 22.0) : CGSize(width: 10.0, height: 22.0) + + let iconSize = self.icon.update( + transition: transition, + component: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent(name: animationName), + color: component.color, + playOnce: self.playOnce + )), + environment: {}, + containerSize: CGSize(width: 22.0, height: 22.0) ) + + var playAnimation = false + if self.isSelected != component.isSelected || self.iconName != component.iconName { + if let iconName = component.iconName { + if component.isSelected { + playAnimation = true + } else if self.iconName != iconName { + playAnimation = true + } + self.iconName = iconName + } else { + playAnimation = true + } + self.isSelected = component.isSelected + } + if playAnimation { + self.playOnce.invoke(Void()) + } let padding: CGFloat = 12.0 var leftPadding = padding @@ -330,35 +405,68 @@ private final class ItemComponent: CombinedComponent { leftPadding -= 4.0 } let spacing: CGFloat = 4.0 - let totalWidth = title.size.width + icon.size.width + spacing + let totalWidth = titleSize.width + animationSize.width + spacing let size = CGSize(width: totalWidth + leftPadding + padding, height: 28.0) - let background = background.update( - component: RoundedRectangle( + + let backgroundSize = self.background.update( + transition: transition, + component: AnyComponent(RoundedRectangle( color: component.backgroundColor, cornerRadius: 14.0 - ), - availableSize: size, - transition: .immediate + )), + environment: {}, + containerSize: size ) - context.add(background - .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) - ) - if let _ = component.iconName { - context.add(title - .position(CGPoint(x: size.width - padding - title.size.width / 2.0, y: size.height / 2.0)) - ) - context.add(icon - .position(CGPoint(x: leftPadding + icon.size.width / 2.0, y: size.height / 2.0)) - ) - } else { - context.add(title - .position(CGPoint(x: padding + title.size.width / 2.0, y: size.height / 2.0)) - ) - context.add(icon - .position(CGPoint(x: size.width - padding - icon.size.width / 2.0, y: size.height / 2.0)) - ) + + if let backgroundView = self.background.view { + if backgroundView.superview == nil { + self.addSubview(backgroundView) + } + transition.setPosition(view: backgroundView, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + transition.setBounds(view: backgroundView, bounds: CGRect(origin: CGPoint(), size: backgroundSize)) } + + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + let titlePosition: CGPoint + if let _ = component.iconName { + titlePosition = CGPoint(x: size.width - padding - titleSize.width / 2.0, y: size.height / 2.0) + } else { + titlePosition = CGPoint(x: padding + titleSize.width / 2.0, y: size.height / 2.0) + } + if let animateTitleInDirection { + titleView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + titleView.center = CGPoint(x: titlePosition.x, y: titlePosition.y - 6.0 * animateTitleInDirection) + } + transition.setPosition(view: titleView, position: titlePosition) + titleView.bounds = CGRect(origin: CGPoint(), size: titleSize) + } + + if let iconView = self.icon.view { + if iconView.superview == nil { + self.addSubview(iconView) + } + let iconPosition: CGPoint + if let _ = component.iconName { + iconPosition = CGPoint(x: leftPadding + iconSize.width / 2.0, y: size.height / 2.0) + } else { + iconPosition = CGPoint(x: size.width - padding - animationSize.width / 2.0, y: size.height / 2.0) + } + transition.setPosition(view: iconView, position: iconPosition) + transition.setBounds(view: iconView, bounds: CGRect(origin: CGPoint(), size: iconSize)) + } + return size } } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public 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) + } } diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift index 28a5e9206a..04f19bba81 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftAttributeListContextItem.swift @@ -201,70 +201,27 @@ private final class GiftAttributeListContextItemNode: ASDisplayNode, ContextMenu private let actionSelected: (ContextMenuActionResult) -> Void private let scrollNode: ASScrollNode - private let actionNodes: [ContextControllerActionsListActionItemNode] - private let separatorNodes: [ASDisplayNode] + private var actionNodes: [AnyHashable: ContextControllerActionsListActionItemNode] = [:] + private var separatorNodes: [AnyHashable: ASDisplayNode] = [:] private var searchDisposable: Disposable? private var searchQuery = "" + private var itemHeights: [AnyHashable: CGFloat] = [:] + private var totalContentHeight: CGFloat = 0 + private var itemFrames: [AnyHashable: CGRect] = [:] + init(presentationData: PresentationData, item: GiftAttributeListContextItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) { self.item = item - self.presentationData = presentationData + self.presentationData = presentationData.withUpdate(listsFontSize: .regular) self.getController = getController self.actionSelected = actionSelected self.scrollNode = ASScrollNode() - - var actionNodes: [ContextControllerActionsListActionItemNode] = [] - var separatorNodes: [ASDisplayNode] = [] - - let selectedAttributes = Set(item.selectedAttributes) - - let selectAllAction = ContextMenuActionItem(text: presentationData.strings.Gift_Store_SelectAll, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) - }, iconPosition: .left, action: { _, f in - getController()?.dismiss(result: .dismissWithoutContent, completion: nil) - - item.selectAll() - }) - - let selectAllActionNode = ContextControllerActionsListActionItemNode(context: item.context, getController: getController, requestDismiss: actionSelected, requestUpdateAction: { _, _ in }, item: selectAllAction) - actionNodes.append(selectAllActionNode) - - let separatorNode = ASDisplayNode() - separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor - separatorNodes.append(separatorNode) - - for attribute in item.attributes { - guard let action = actionForAttribute(attribute: attribute, presentationData: presentationData, selectedAttributes: selectedAttributes, searchQuery: self.searchQuery, item: item, getController: getController) else { - continue - } - let actionNode = ContextControllerActionsListActionItemNode(context: item.context, getController: getController, requestDismiss: actionSelected, requestUpdateAction: { _, _ in }, item: action) - actionNodes.append(actionNode) - if actionNodes.count != item.attributes.count { - let separatorNode = ASDisplayNode() - separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor - separatorNodes.append(separatorNode) - } - } - - let nopAction: ((ContextControllerProtocol?, @escaping (ContextMenuActionResult) -> Void) -> Void)? = nil - let emptyResultsAction = ContextMenuActionItem(text: presentationData.strings.Gift_Store_NoResults, textFont: .small, icon: { _ in return nil }, action: nopAction) - let emptyResultsActionNode = ContextControllerActionsListActionItemNode(context: item.context, getController: getController, requestDismiss: actionSelected, requestUpdateAction: { _, _ in }, item: emptyResultsAction) - actionNodes.append(emptyResultsActionNode) - - self.actionNodes = actionNodes - self.separatorNodes = separatorNodes - + super.init() self.addSubnode(self.scrollNode) - for separatorNode in self.separatorNodes { - self.scrollNode.addSubnode(separatorNode) - } - for actionNode in self.actionNodes { - self.scrollNode.addSubnode(actionNode) - } self.searchDisposable = (item.searchQuery |> deliverOnMainQueue).start(next: { [weak self] searchQuery in @@ -272,15 +229,7 @@ private final class GiftAttributeListContextItemNode: ASDisplayNode, ContextMenu return } self.searchQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) - - var i = 1 - for attribute in item.attributes { - guard let action = actionForAttribute(attribute: attribute, presentationData: presentationData, selectedAttributes: selectedAttributes, searchQuery: self.searchQuery, item: item, getController: getController) else { - continue - } - self.actionNodes[i].setItem(item: action) - i += 1 - } + self.invalidateLayout() self.getController()?.requestLayout(transition: .immediate) }) } @@ -297,96 +246,246 @@ private final class GiftAttributeListContextItemNode: ASDisplayNode, ContextMenu self.scrollNode.view.showsHorizontalScrollIndicator = false self.scrollNode.view.scrollIndicatorInsets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 5.0, right: 0.0) } - - func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) { - let minActionsWidth: CGFloat = 250.0 - let maxActionsWidth: CGFloat = 300.0 - let constrainedWidth = min(constrainedWidth, maxActionsWidth) - var maxWidth: CGFloat = 0.0 - var contentHeight: CGFloat = 0.0 - var heightsAndCompletions: [(Int, CGFloat, (CGSize, ContainedViewLayoutTransition) -> Void)] = [] - - + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if let maxWidth = self.maxWidth { + self.updateScrolling(maxWidth: maxWidth) + } + } + + enum ItemType { + case selectAll + case attribute(StarGift.UniqueGift.Attribute) + case noResults + case separator + } + + private func getVisibleItems(in scrollView: UIScrollView, constrainedWidth: CGFloat) -> [(itemId: AnyHashable, itemType: ItemType, frame: CGRect)] { let effectiveAttributes: [StarGift.UniqueGift.Attribute] if self.searchQuery.isEmpty { effectiveAttributes = self.item.attributes } else { effectiveAttributes = filteredAttributes(attributes: self.item.attributes, query: self.searchQuery) } - let visibleAttributes = Set(effectiveAttributes.map { attribute -> AnyHashable in - switch attribute { - case let .model(_, file, _): - return file.fileId.id - case let .pattern(_, file, _): - return file.fileId.id - case let .backdrop(_, id, _, _, _, _, _): - return id - default: - fatalError() - } - }) - for i in 0 ..< self.actionNodes.count { - let itemNode = self.actionNodes[i] - if !self.searchQuery.isEmpty && i == 0 { - itemNode.isHidden = true - continue - } + var items: [(itemId: AnyHashable, itemType: ItemType, frame: CGRect)] = [] + var yOffset: CGFloat = 0 + + let defaultHeight: CGFloat = 42.0 + if self.searchQuery.isEmpty { + let selectAllId = AnyHashable("selectAll") + let height = self.itemHeights[selectAllId] ?? defaultHeight + let frame = CGRect(x: 0, y: yOffset, width: constrainedWidth, height: height) + items.append((selectAllId, .selectAll, frame)) + yOffset += height - if i > 0 && i < self.actionNodes.count - 1 { - let attribute = self.item.attributes[i - 1] - let attributeId: AnyHashable - switch attribute { - case let .model(_, file, _): - attributeId = AnyHashable(file.fileId.id) - case let .pattern(_, file, _): - attributeId = AnyHashable(file.fileId.id) - case let .backdrop(_, id, _, _, _, _, _): - attributeId = AnyHashable(id) - default: - fatalError() - } - if !visibleAttributes.contains(attributeId) { - itemNode.isHidden = true - continue - } - } - if i == self.actionNodes.count - 1 { - if !visibleAttributes.isEmpty { - itemNode.isHidden = true - continue - } else { - } - } - itemNode.isHidden = false - - let (minSize, complete) = itemNode.update(presentationData: self.presentationData, constrainedSize: CGSize(width: constrainedWidth, height: constrainedHeight)) - maxWidth = max(maxWidth, minSize.width) - heightsAndCompletions.append((i, minSize.height, complete)) - contentHeight += minSize.height + let separatorId = AnyHashable("separator_selectAll") + let separatorFrame = CGRect(x: 0, y: yOffset, width: constrainedWidth, height: UIScreenPixel) + items.append((separatorId, .separator, separatorFrame)) + yOffset += UIScreenPixel } - maxWidth = max(maxWidth, minActionsWidth) + for (index, attribute) in effectiveAttributes.enumerated() { + let attributeId = self.getAttributeId(from: attribute) + let height = self.itemHeights[attributeId] ?? defaultHeight + let frame = CGRect(x: 0, y: yOffset, width: constrainedWidth, height: height) + items.append((attributeId, .attribute(attribute), frame)) + yOffset += height + + if index < effectiveAttributes.count - 1 { + let separatorId = AnyHashable("separator_\(attributeId)") + let separatorFrame = CGRect(x: 0, y: yOffset, width: constrainedWidth, height: UIScreenPixel) + items.append((separatorId, .separator, separatorFrame)) + yOffset += UIScreenPixel + } + } + + if !self.searchQuery.isEmpty && effectiveAttributes.isEmpty { + let noResultsId = AnyHashable("noResults") + let height = self.itemHeights[noResultsId] ?? defaultHeight + let frame = CGRect(x: 0, y: yOffset, width: constrainedWidth, height: height) + items.append((noResultsId, .noResults, frame)) + yOffset += height + } + + self.totalContentHeight = yOffset + + for (itemId, _, frame) in items { + self.itemFrames[itemId] = frame + } + + let visibleBounds = scrollView.bounds.insetBy(dx: 0.0, dy: -100.0) + return items.filter { visibleBounds.intersects($0.frame) } + } + + private func getAttributeId(from attribute: StarGift.UniqueGift.Attribute) -> AnyHashable { + switch attribute { + case let .model(_, file, _): + return AnyHashable("model_\(file.fileId.id)") + case let .pattern(_, file, _): + return AnyHashable("pattern_\(file.fileId.id)") + case let .backdrop(_, id, _, _, _, _, _): + return AnyHashable("backdrop_\(id)") + default: + return AnyHashable("unknown") + } + } + + private var maxWidth: CGFloat? + private func updateScrolling(maxWidth: CGFloat) { + let scrollView = self.scrollNode.view + + let constrainedWidth = scrollView.bounds.width + let visibleItems = self.getVisibleItems(in: scrollView, constrainedWidth: constrainedWidth) + + var validNodeIds: Set = [] + + for (itemId, itemType, frame) in visibleItems { + validNodeIds.insert(itemId) + + switch itemType { + case .selectAll: + if self.actionNodes[itemId] == nil { + let selectAllAction = ContextMenuActionItem(text: presentationData.strings.Gift_Store_SelectAll, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) + }, iconPosition: .left, action: { _, f in + self.getController()?.dismiss(result: .dismissWithoutContent, completion: nil) + self.item.selectAll() + }) + + let actionNode = ContextControllerActionsListActionItemNode( + context: self.item.context, + getController: self.getController, + requestDismiss: self.actionSelected, + requestUpdateAction: { _, _ in }, + item: selectAllAction + ) + self.actionNodes[itemId] = actionNode + self.scrollNode.addSubnode(actionNode) + } + + case .attribute(let attribute): + if self.actionNodes[itemId] == nil { + let selectedAttributes = Set(self.item.selectedAttributes) + guard let action = actionForAttribute( + attribute: attribute, + presentationData: self.presentationData, + selectedAttributes: selectedAttributes, + searchQuery: self.searchQuery, + item: self.item, + getController: self.getController + ) else { continue } + + let actionNode = ContextControllerActionsListActionItemNode( + context: self.item.context, + getController: self.getController, + requestDismiss: self.actionSelected, + requestUpdateAction: { _, _ in }, + item: action + ) + self.actionNodes[itemId] = actionNode + self.scrollNode.addSubnode(actionNode) + } else { + let selectedAttributes = Set(self.item.selectedAttributes) + if let action = actionForAttribute( + attribute: attribute, + presentationData: self.presentationData, + selectedAttributes: selectedAttributes, + searchQuery: self.searchQuery, + item: self.item, + getController: self.getController + ) { + self.actionNodes[itemId]?.setItem(item: action) + } + } + + case .noResults: + if self.actionNodes[itemId] == nil { + let nopAction: ((ContextControllerProtocol?, @escaping (ContextMenuActionResult) -> Void) -> Void)? = nil + let emptyResultsAction = ContextMenuActionItem( + text: presentationData.strings.Gift_Store_NoResults, + textFont: .small, + icon: { _ in return nil }, + action: nopAction + ) + let actionNode = ContextControllerActionsListActionItemNode( + context: self.item.context, + getController: self.getController, + requestDismiss: self.actionSelected, + requestUpdateAction: { _, _ in }, + item: emptyResultsAction + ) + self.actionNodes[itemId] = actionNode + self.scrollNode.addSubnode(actionNode) + } + case .separator: + if self.separatorNodes[itemId] == nil { + let separatorNode = ASDisplayNode() + separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor + self.separatorNodes[itemId] = separatorNode + self.scrollNode.addSubnode(separatorNode) + } + } + + if let actionNode = self.actionNodes[itemId] { + actionNode.frame = frame + + let (minSize, complete) = actionNode.update(presentationData: self.presentationData, constrainedSize: frame.size) + self.itemHeights[itemId] = minSize.height + complete(CGSize(width: maxWidth, height: minSize.height), .immediate) + } else if let separatorNode = self.separatorNodes[itemId] { + separatorNode.frame = frame + } + } + + var nodesToRemove: [AnyHashable] = [] + for (nodeId, node) in self.actionNodes { + if !validNodeIds.contains(nodeId) { + nodesToRemove.append(nodeId) + node.removeFromSupernode() + } + } + for nodeId in nodesToRemove { + self.actionNodes.removeValue(forKey: nodeId) + } + + var separatorsToRemove: [AnyHashable] = [] + for (separatorId, separatorNode) in self.separatorNodes { + if !validNodeIds.contains(separatorId) { + separatorsToRemove.append(separatorId) + separatorNode.removeFromSupernode() + } + } + for separatorId in separatorsToRemove { + self.separatorNodes.removeValue(forKey: separatorId) + } + } + + private func invalidateLayout() { + self.itemHeights.removeAll() + self.itemFrames.removeAll() + self.totalContentHeight = 0.0 + } + + func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) { + let minActionsWidth: CGFloat = 250.0 + let maxActionsWidth: CGFloat = 300.0 + let constrainedWidth = min(constrainedWidth, maxActionsWidth) + let maxWidth = max(constrainedWidth, minActionsWidth) let maxHeight: CGFloat = min(360.0, constrainedHeight - 108.0) - return (CGSize(width: maxWidth, height: min(maxHeight, contentHeight)), { size, transition in - var verticalOffset: CGFloat = 0.0 - for (i, itemHeight, itemCompletion) in heightsAndCompletions { - let itemNode = self.actionNodes[i] - - let itemSize = CGSize(width: maxWidth, height: itemHeight) - transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: itemSize)) - itemCompletion(itemSize, transition) - verticalOffset += itemHeight - - if i < self.actionNodes.count - 2 { - let separatorNode = self.separatorNodes[i] - separatorNode.frame = CGRect(x: 0, y: verticalOffset, width: size.width, height: UIScreenPixel) - } - } + if self.totalContentHeight == 0 { + let _ = self.getVisibleItems(in: UIScrollView(), constrainedWidth: constrainedWidth) + } + + return (CGSize(width: maxWidth, height: min(maxHeight, self.totalContentHeight)), { size, transition in + self.maxWidth = maxWidth + transition.updateFrame(node: self.scrollNode, frame: CGRect(origin: CGPoint(), size: size)) - self.scrollNode.view.contentSize = CGSize(width: size.width, height: contentHeight) + self.scrollNode.view.contentSize = CGSize(width: size.width, height: self.totalContentHeight) + + self.updateScrolling(maxWidth: maxWidth) }) } @@ -417,7 +516,7 @@ private final class GiftAttributeListContextItemNode: ASDisplayNode, ContextMenu } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - for actionNode in self.actionNodes { + for (_, actionNode) in self.actionNodes { actionNode.updateIsHighlighted(isHighlighted: false) } } diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift index c960a11524..1e243f7ba3 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift @@ -98,6 +98,8 @@ final class GiftStoreScreenComponent: Component { private var initialCount: Int32? private var showLoading = true + private var selectedFilterId: AnyHashable? + private var component: GiftStoreScreenComponent? private(set) weak var state: State? private var environment: EnvironmentType? @@ -502,6 +504,13 @@ final class GiftStoreScreenComponent: Component { }))) let contextController = ContextController(presentationData: presentationData, source: .reference(GiftStoreReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) + contextController.dismissed = { [weak self] in + guard let self else { + return + } + self.selectedFilterId = nil + self.state?.updated() + } controller.presentInGlobalOverlay(contextController) } @@ -603,6 +612,13 @@ final class GiftStoreScreenComponent: Component { items: .single(ContextController.Items(content: .list(items))), gesture: nil ) + contextController.dismissed = { [weak self] in + guard let self else { + return + } + self.selectedFilterId = nil + self.state?.updated() + } controller.presentInGlobalOverlay(contextController) } @@ -704,6 +720,13 @@ final class GiftStoreScreenComponent: Component { items: .single(ContextController.Items(content: .list(items))), gesture: nil ) + contextController.dismissed = { [weak self] in + guard let self else { + return + } + self.selectedFilterId = nil + self.state?.updated() + } controller.presentInGlobalOverlay(contextController) } @@ -805,6 +828,13 @@ final class GiftStoreScreenComponent: Component { items: .single(ContextController.Items(content: .list(items))), gesture: nil ) + contextController.dismissed = { [weak self] in + guard let self else { + return + } + self.selectedFilterId = nil + self.state?.updated() + } controller.presentInGlobalOverlay(contextController) } @@ -996,29 +1026,43 @@ final class GiftStoreScreenComponent: Component { let optionWidth = (availableSize.width - sideInset * 2.0 - optionSpacing * 2.0) / 3.0 var sortingTitle = environment.strings.Gift_Store_Sort_Date - var sortingIcon: String = "Peer Info/SortDate" + var sortingIcon: String = "GiftFilterDate" + var sortingIndex: Int = 0 if let sorting = self.state?.starGiftsState?.sorting { switch sorting { - case .date: - sortingTitle = environment.strings.Gift_Store_Sort_Date - sortingIcon = "Peer Info/SortDate" case .value: sortingTitle = environment.strings.Gift_Store_Sort_Price - sortingIcon = "Peer Info/SortValue" + sortingIcon = "GiftFilterPrice" + sortingIndex = 0 + case .date: + sortingTitle = environment.strings.Gift_Store_Sort_Date + sortingIcon = "GiftFilterDate" + sortingIndex = 1 case .number: sortingTitle = environment.strings.Gift_Store_Sort_Number - sortingIcon = "Peer Info/SortNumber" + sortingIcon = "GiftFilterNumber" + sortingIndex = 2 } } + enum FilterItemId: Int32 { + case sort + case model + case backdrop + case symbol + } + var filterItems: [FilterSelectorComponent.Item] = [] filterItems.append(FilterSelectorComponent.Item( - id: AnyHashable(0), + id: AnyHashable(FilterItemId.sort), + index: sortingIndex, iconName: sortingIcon, title: sortingTitle, action: { [weak self] view in if let self { + self.selectedFilterId = AnyHashable(FilterItemId.sort) self.openSortContextMenu(sourceView: view) + self.state?.updated() } } )) @@ -1035,10 +1079,10 @@ final class GiftStoreScreenComponent: Component { switch attribute { case .model: modelCount += 1 - case .pattern: - symbolCount += 1 case .backdrop: backdropCount += 1 + case .pattern: + symbolCount += 1 } } @@ -1054,29 +1098,35 @@ final class GiftStoreScreenComponent: Component { } filterItems.append(FilterSelectorComponent.Item( - id: AnyHashable(1), + id: AnyHashable(FilterItemId.model), title: modelTitle, action: { [weak self] view in if let self { + self.selectedFilterId = AnyHashable(FilterItemId.model) self.openModelContextMenu(sourceView: view) + self.state?.updated() } } )) filterItems.append(FilterSelectorComponent.Item( - id: AnyHashable(2), + id: AnyHashable(FilterItemId.backdrop), title: backdropTitle, action: { [weak self] view in if let self { + self.selectedFilterId = AnyHashable(FilterItemId.backdrop) self.openBackdropContextMenu(sourceView: view) + self.state?.updated() } } )) filterItems.append(FilterSelectorComponent.Item( - id: AnyHashable(3), + id: AnyHashable(FilterItemId.symbol), title: symbolTitle, action: { [weak self] view in if let self { + self.selectedFilterId = AnyHashable(FilterItemId.symbol) self.openSymbolContextMenu(sourceView: view) + self.state?.updated() } } )) @@ -1092,7 +1142,8 @@ final class GiftStoreScreenComponent: Component { foreground: theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.65), background: theme.list.itemSecondaryTextColor.mixedWith(theme.list.blocksBackgroundColor, alpha: 0.85) ), - items: filterItems + items: filterItems, + selectedItemId: self.selectedFilterId )), environment: {}, containerSize: CGSize(width: availableSize.width - 10.0 * 2.0, height: 50.0) @@ -1193,8 +1244,17 @@ final class GiftStoreScreenComponent: Component { guard let self else { return } + let previousFilterAttributes = self.starGiftsState?.filterAttributes + let previousSorting = self.starGiftsState?.sorting self.starGiftsState = state - self.updated() + + var transition: ComponentTransition = .immediate + if let previousFilterAttributes, previousFilterAttributes != state.filterAttributes { + transition = .easeInOut(duration: 0.25) + } else if let previousSorting, previousSorting != state.sorting { + transition = .easeInOut(duration: 0.25) + } + self.updated(transition: transition) }) } diff --git a/submodules/TelegramUI/Resources/Animations/GiftFilterDate.tgs b/submodules/TelegramUI/Resources/Animations/GiftFilterDate.tgs new file mode 100644 index 0000000000..40806c9510 Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/GiftFilterDate.tgs differ diff --git a/submodules/TelegramUI/Resources/Animations/GiftFilterMenuClose.tgs b/submodules/TelegramUI/Resources/Animations/GiftFilterMenuClose.tgs new file mode 100644 index 0000000000..3616863b50 Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/GiftFilterMenuClose.tgs differ diff --git a/submodules/TelegramUI/Resources/Animations/GiftFilterMenuOpen.tgs b/submodules/TelegramUI/Resources/Animations/GiftFilterMenuOpen.tgs new file mode 100644 index 0000000000..144e8c46fd Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/GiftFilterMenuOpen.tgs differ diff --git a/submodules/TelegramUI/Resources/Animations/GiftFilterNumber.tgs b/submodules/TelegramUI/Resources/Animations/GiftFilterNumber.tgs new file mode 100644 index 0000000000..79c323fac3 Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/GiftFilterNumber.tgs differ diff --git a/submodules/TelegramUI/Resources/Animations/GiftFilterPrice.tgs b/submodules/TelegramUI/Resources/Animations/GiftFilterPrice.tgs new file mode 100644 index 0000000000..a49ad037a8 Binary files /dev/null and b/submodules/TelegramUI/Resources/Animations/GiftFilterPrice.tgs differ diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift index dc6721eaef..a7115e2b14 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift @@ -61,8 +61,10 @@ extension ChatControllerImpl { var canSendPolls = true if let peer = self.presentationInterfaceState.renderedPeer?.peer { - if let peer = peer as? TelegramUser, peer.botInfo == nil { - canSendPolls = false + if let peer = peer as? TelegramUser { + if peer.botInfo == nil && peer.id != self.context.account.peerId { + canSendPolls = false + } } else if peer is TelegramSecretChat { canSendPolls = false } else if let channel = peer as? TelegramChannel { diff --git a/submodules/WebUI/Sources/WebAppMessagePreviewScreen.swift b/submodules/WebUI/Sources/WebAppMessagePreviewScreen.swift index a65ddc15f8..b41f39b0db 100644 --- a/submodules/WebUI/Sources/WebAppMessagePreviewScreen.swift +++ b/submodules/WebUI/Sources/WebAppMessagePreviewScreen.swift @@ -528,9 +528,22 @@ public final class WebAppMessagePreviewScreen: ViewControllerComponentContainer } fileprivate func proceed() { - let requestPeerType = self.preparedMessage.peerTypes.requestPeerTypes + let peerTypes = self.preparedMessage.peerTypes + var types: [ReplyMarkupButtonRequestPeerType] = [] + if peerTypes.contains(.users) { + types.append(.user(.init(isBot: false, isPremium: nil))) + } + if peerTypes.contains(.bots) { + types.append(.user(.init(isBot: true, isPremium: nil))) + } + if peerTypes.contains(.channels) { + types.append(.channel(.init(isCreator: false, hasUsername: nil, userAdminRights: TelegramChatAdminRights(rights: [.canPostMessages]), botAdminRights: nil))) + } + if peerTypes.contains(.groups) { + types.append(.group(.init(isCreator: false, hasUsername: nil, isForum: nil, botParticipant: false, userAdminRights: nil, botAdminRights: nil))) + } - let controller = self.context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: self.context, filter: [.excludeRecent, .doNotSearchMessages], requestPeerType: requestPeerType, hasContactSelector: false, multipleSelection: true, selectForumThreads: true, immediatelyActivateMultipleSelection: true)) + let controller = self.context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: self.context, filter: [.excludeRecent, .doNotSearchMessages], requestPeerType: types, hasContactSelector: false, multipleSelection: true, selectForumThreads: true, immediatelyActivateMultipleSelection: true)) controller.multiplePeersSelected = { [weak self, weak controller] peers, _, _, _, _, _ in guard let self else {