From 8051e43e4c7003fd7f970724f3e7f180cb83e285 Mon Sep 17 00:00:00 2001 From: Ali <> Date: Sat, 4 Nov 2023 00:37:02 +0400 Subject: [PATCH] [WIP] Custom channel reactions --- .../Sources/PremiumLimitScreen.swift | 7 +- .../Sources/State/MessageReactions.swift | 16 +- submodules/TelegramUI/BUILD | 1 + .../Sources/EmojiPagerContentComponent.swift | 85 +- .../Sources/EntityKeyboard.swift | 10 +- .../EntityKeyboardTopPanelComponent.swift | 5 +- .../PeerAllowedReactionsScreen/BUILD | 35 + .../Sources/EmojiListInputComponent.swift | 240 +++++ .../Sources/EmojiSelectionComponent.swift | 224 +++++ .../Sources/ListSwitchItemComponent.swift | 116 +++ .../Sources/PeerAllowedReactionsScreen.swift | 856 ++++++++++++++++++ .../Sources/PeerInfo/PeerInfoScreen.swift | 14 +- 12 files changed, 1596 insertions(+), 13 deletions(-) create mode 100644 submodules/TelegramUI/Components/PeerAllowedReactionsScreen/BUILD create mode 100644 submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/EmojiListInputComponent.swift create mode 100644 submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/EmojiSelectionComponent.swift create mode 100644 submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/ListSwitchItemComponent.swift create mode 100644 submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift diff --git a/submodules/PremiumUI/Sources/PremiumLimitScreen.swift b/submodules/PremiumUI/Sources/PremiumLimitScreen.swift index 0ecbf43532..449c67752b 100644 --- a/submodules/PremiumUI/Sources/PremiumLimitScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumLimitScreen.swift @@ -1188,6 +1188,10 @@ private final class LimitSheetContent: CombinedComponent { case .nameColors: titleText = strings.ChannelBoost_EnableColors string = strings.ChannelBoost_EnableColorsText(valueString).string + case .channelReactions: + //TODO:localize + titleText = "Custom Reactions" + string = "Your channel needs \(valueString) to add custom emoji as reactions.\n\nAsk your **Premium** subscribers to boost your channel with this link:" } } else { let storiesString = strings.ChannelBoost_StoriesPerDay(level) @@ -1769,11 +1773,12 @@ public class PremiumLimitScreen: ViewControllerComponentContainer { case storiesWeekly case storiesMonthly - public enum BoostSubject { case stories case nameColors + case channelReactions } + case storiesChannelBoost(peer: EnginePeer, boostSubject: BoostSubject, isCurrent: Bool, level: Int32, currentLevelBoosts: Int32, nextLevelBoosts: Int32?, link: String?, myBoostCount: Int32, canBoostAgain: Bool) } diff --git a/submodules/TelegramCore/Sources/State/MessageReactions.swift b/submodules/TelegramCore/Sources/State/MessageReactions.swift index 6d1e4a7a40..9f87c2e72a 100644 --- a/submodules/TelegramCore/Sources/State/MessageReactions.swift +++ b/submodules/TelegramCore/Sources/State/MessageReactions.swift @@ -597,6 +597,7 @@ public final class EngineMessageReactionListContext { public enum UpdatePeerAllowedReactionsError { case generic + case boostRequired } func _internal_updatePeerAllowedReactions(account: Account, peerId: PeerId, allowedReactions: PeerAllowedReactions) -> Signal { @@ -620,11 +621,20 @@ func _internal_updatePeerAllowedReactions(account: Account, peerId: PeerId, allo } return account.network.request(Api.functions.messages.setChatAvailableReactions(peer: inputPeer, availableReactions: mappedReactions)) - |> mapError { _ -> UpdatePeerAllowedReactionsError in - return .generic + |> map(Optional.init) + |> `catch` { error -> Signal in + if error.errorDescription == "CHAT_NOT_MODIFIED" { + return .single(nil) + } else if error.errorDescription == "BOOSTS_REQUIRED" { + return .fail(.boostRequired) + } else { + return .fail(.generic) + } } |> mapToSignal { result -> Signal in - account.stateManager.addUpdates(result) + if let result = result { + account.stateManager.addUpdates(result) + } return account.postbox.transaction { transaction -> Void in transaction.updatePeerCachedData(peerIds: [peerId], update: { _, current in diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index b3786658be..bf1fae9910 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -409,6 +409,7 @@ swift_library( "//submodules/TelegramUI/Components/Chat/ChatLoadingNode", "//submodules/TelegramUI/Components/Settings/PeerNameColorScreen", "//submodules/TelegramUI/Components/ContextMenuScreen", + "//submodules/TelegramUI/Components/PeerAllowedReactionsScreen", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, "//build-system:ios_sim_arm64": [], diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift index 07ed35b209..c1f26f0c68 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift @@ -164,7 +164,7 @@ private final class WarpView: UIView { } } -public struct EmojiComponentReactionItem { +public struct EmojiComponentReactionItem: Equatable { public var reaction: MessageReaction.Reaction public var file: TelegramMediaFile @@ -2767,6 +2767,32 @@ public final class EmojiPagerContentComponent: Component { ) } + public func withSelectedItems(_ selectedItems: Set) -> EmojiPagerContentComponent { + return EmojiPagerContentComponent( + id: self.id, + context: self.context, + avatarPeer: self.avatarPeer, + animationCache: self.animationCache, + animationRenderer: self.animationRenderer, + inputInteractionHolder: self.inputInteractionHolder, + panelItemGroups: panelItemGroups, + contentItemGroups: contentItemGroups, + itemLayoutType: self.itemLayoutType, + itemContentUniqueId: itemContentUniqueId, + searchState: searchState, + warpContentsOnEdges: self.warpContentsOnEdges, + hideBackground: self.hideBackground, + displaySearchWithPlaceholder: self.displaySearchWithPlaceholder, + searchCategories: self.searchCategories, + searchInitiallyHidden: self.searchInitiallyHidden, + searchAlwaysActive: self.searchAlwaysActive, + searchIsPlaceholderOnly: self.searchIsPlaceholderOnly, + emptySearchResults: emptySearchResults, + enableLongPress: self.enableLongPress, + selectedItems: selectedItems + ) + } + public static func ==(lhs: EmojiPagerContentComponent, rhs: EmojiPagerContentComponent) -> Bool { if lhs === rhs { return true @@ -7124,6 +7150,7 @@ public final class EmojiPagerContentComponent: Component { case profilePhoto case groupPhoto case backgroundIcon + case reactionList } public static func emojiInputData( @@ -7195,14 +7222,14 @@ public final class EmojiPagerContentComponent: Component { } let availableReactions: Signal - if [.reaction, .quickReaction].contains(subject) { + if [.reaction, .quickReaction, .reactionList].contains(subject) { availableReactions = context.engine.stickers.availableReactions() } else { availableReactions = .single(nil) } let searchCategories: Signal - if [.emoji, .reaction].contains(subject) { + if [.emoji, .reaction, .reactionList].contains(subject) { searchCategories = context.engine.stickers.emojiSearchCategories(kind: .emoji) } else if case .status = subject { searchCategories = context.engine.stickers.emojiSearchCategories(kind: .status) @@ -7587,6 +7614,51 @@ public final class EmojiPagerContentComponent: Component { } } } + } else if subject == .reactionList { + var existingIds = Set() + + if let availableReactions = availableReactions { + for reactionItem in availableReactions.reactions { + if !reactionItem.isEnabled { + continue + } + if existingIds.contains(reactionItem.value) { + continue + } + existingIds.insert(reactionItem.value) + + let icon: EmojiPagerContentComponent.Item.Icon + if !hasPremium, case .custom = reactionItem.value { + icon = .locked + } else { + icon = .none + } + + var tintMode: Item.TintMode = .none + if reactionItem.selectAnimation.isCustomTemplateEmoji { + tintMode = .primary + } + + let animationFile = reactionItem.selectAnimation + let animationData = EntityKeyboardAnimationData(file: animationFile, isReaction: true) + let resultItem = EmojiPagerContentComponent.Item( + animationData: animationData, + content: .animation(animationData), + itemFile: animationFile, + subgroupId: nil, + icon: icon, + tintMode: tintMode + ) + + let groupId = "liked" + if let groupIndex = itemGroupIndexById[groupId] { + itemGroups[groupIndex].items.append(resultItem) + } else { + itemGroupIndexById[groupId] = itemGroups.count + itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: nil, subtitle: nil, isPremiumLocked: false, isFeatured: false, collapsedLineCount: nil, isClearable: false, headerItem: nil, items: [resultItem])) + } + } + } } else if [.reaction, .quickReaction].contains(subject) { var existingIds = Set() @@ -8159,7 +8231,12 @@ public final class EmojiPagerContentComponent: Component { ) } - itemGroups.append(ItemGroup(supergroupId: supergroupId, id: groupId, title: featuredEmojiPack.info.title, subtitle: nil, isPremiumLocked: isPremiumLocked, isFeatured: true, collapsedLineCount: 3, isClearable: false, headerItem: headerItem, items: [resultItem])) + var isFeatured = true + if case .reactionList = subject { + isFeatured = false + } + + itemGroups.append(ItemGroup(supergroupId: supergroupId, id: groupId, title: featuredEmojiPack.info.title, subtitle: nil, isPremiumLocked: isPremiumLocked, isFeatured: isFeatured, collapsedLineCount: 3, isClearable: false, headerItem: headerItem, items: [resultItem])) } } } diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift index 07f6e6ff27..0b247cf527 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift @@ -331,12 +331,13 @@ public final class EntityKeyboardComponent: Component { let iconMapping: [String: EntityKeyboardIconTopPanelComponent.Icon] = [ "saved": .saved, "recent": .recent, - "premium": .premium + "premium": .premium, + "liked": .liked ] let titleMapping: [String: String] = [ "saved": component.strings.Stickers_Favorites, "recent": component.strings.Stickers_Recent, - "premium": component.strings.EmojiInput_PanelTitlePremium + "premium": component.strings.EmojiInput_PanelTitlePremium, ] if let icon = iconMapping[id], let title = titleMapping[id] { topMaskItems.append(EntityKeyboardTopPanelComponent.Item( @@ -468,6 +469,7 @@ public final class EntityKeyboardComponent: Component { let iconMapping: [String: EntityKeyboardIconTopPanelComponent.Icon] = [ "saved": .saved, "recent": .recent, + "liked": .liked, "premium": .premium ] let titleMapping: [String: String] = [ @@ -568,12 +570,14 @@ public final class EntityKeyboardComponent: Component { for itemGroup in emojiContent.panelItemGroups { if !itemGroup.items.isEmpty { if let id = itemGroup.groupId.base as? String { - if id == "recent" { + if id == "recent" || id == "liked" { let iconMapping: [String: EntityKeyboardIconTopPanelComponent.Icon] = [ "recent": .recent, + "liked": .liked, ] let titleMapping: [String: String] = [ "recent": component.strings.Stickers_Recent, + "liked": "", ] if let icon = iconMapping[id], let title = titleMapping[id] { topEmojiItems.append(EntityKeyboardTopPanelComponent.Item( diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift index 8f2186a215..da8f46a1df 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift @@ -276,6 +276,7 @@ final class EntityKeyboardIconTopPanelComponent: Component { case recent case saved case premium + case liked } let icon: Icon @@ -360,6 +361,8 @@ final class EntityKeyboardIconTopPanelComponent: Component { image = UIImage(bundleImageName: "Chat/Input/Media/PanelRecentIcon") case .saved: image = UIImage(bundleImageName: "Chat/Input/Media/PanelSavedIcon") + case .liked: + image = UIImage(bundleImageName: "Chat/Input/Media/PanelTrendingIcon") case .premium: image = generateImage(CGSize(width: 44.0, height: 44.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) @@ -2078,7 +2081,7 @@ public final class EntityKeyboardTopPanelComponent: Component { } let isRound: Bool - if let string = activeContentItemId.base as? String, (string == "featuredTop" || string == "recent" || string == "static" || string == "trending") { + if let string = activeContentItemId.base as? String, (string == "featuredTop" || string == "recent" || string == "static" || string == "trending" || string == "liked") { isRound = true } else { isRound = false diff --git a/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/BUILD b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/BUILD new file mode 100644 index 0000000000..8720732421 --- /dev/null +++ b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/BUILD @@ -0,0 +1,35 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "PeerAllowedReactionsScreen", + module_name = "PeerAllowedReactionsScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display:Display", + "//submodules/TelegramPresentationData:TelegramPresentationData", + "//submodules/ComponentFlow", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/AppBundle", + "//submodules/Components/ViewControllerComponent", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/TelegramCore", + "//submodules/Postbox", + "//submodules/AccountContext", + "//submodules/TelegramUI/Components/EntityKeyboard", + "//submodules/TelegramUI/Components/SwitchComponent", + "//submodules/Components/MultilineTextComponent", + "//submodules/Markdown", + "//submodules/TelegramUI/Components/ButtonComponent", + "//submodules/Components/PagerComponent", + "//submodules/PremiumUI", + "//submodules/UndoUI", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/EmojiListInputComponent.swift b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/EmojiListInputComponent.swift new file mode 100644 index 0000000000..0644e96f84 --- /dev/null +++ b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/EmojiListInputComponent.swift @@ -0,0 +1,240 @@ +import Foundation +import UIKit +import Display +import TelegramPresentationData +import ComponentFlow +import ComponentDisplayAdapters +import SwitchComponent +import EntityKeyboard +import AccountContext + +final class EmojiListInputComponent: Component { + let context: AccountContext + let theme: PresentationTheme + let placeholder: String + let reactionItems: [EmojiComponentReactionItem] + let isInputActive: Bool + let activateInput: () -> Void + + init( + context: AccountContext, + theme: PresentationTheme, + placeholder: String, + reactionItems: [EmojiComponentReactionItem], + isInputActive: Bool, + activateInput: @escaping () -> Void + ) { + self.context = context + self.theme = theme + self.placeholder = placeholder + self.reactionItems = reactionItems + self.isInputActive = isInputActive + self.activateInput = activateInput + } + + static func ==(lhs: EmojiListInputComponent, rhs: EmojiListInputComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.placeholder != rhs.placeholder { + return false + } + if lhs.reactionItems != rhs.reactionItems { + return false + } + if lhs.isInputActive != rhs.isInputActive { + return false + } + return true + } + + final class View: UIView { + private var component: EmojiListInputComponent? + private weak var state: EmptyComponentState? + + private var itemLayers: [Int64: EmojiPagerContentComponent.View.ItemLayer] = [:] + private let trailingPlaceholder = ComponentView() + private let caretIndicator: UIImageView + + override init(frame: CGRect) { + self.caretIndicator = UIImageView() + self.caretIndicator.image = generateImage(CGSize(width: 2.0, height: 4.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.white.cgColor) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: size.width * 0.5).cgPath) + context.fillPath() + })?.stretchableImage(withLeftCapWidth: 1, topCapHeight: 2).withRenderingMode(.alwaysTemplate) + + super.init(frame: frame) + + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + let point = recognizer.location(in: self) + + var tapOnItem = false + for (_, itemLayer) in self.itemLayers { + if itemLayer.frame.insetBy(dx: -6.0, dy: -6.0).contains(point) { + tapOnItem = true + break + } + } + + if !tapOnItem { + self.component?.activateInput() + } + } + } + + func update(component: EmojiListInputComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + let verticalInset: CGFloat = 12.0 + let placeholderSpacing: CGFloat = 6.0 + + let minItemSize: CGFloat = 24.0 + let itemSpacingFactor: CGFloat = 0.15 + let minSideInset: CGFloat = 12.0 + + self.backgroundColor = component.theme.list.itemBlocksBackgroundColor + self.layer.cornerRadius = 12.0 + + let maxItemsWidth = availableSize.width - minSideInset * 2.0 + let itemsPerRow = Int(floor((maxItemsWidth + minItemSize * itemSpacingFactor) / (minItemSize + minItemSize * itemSpacingFactor))) + let itemSizePlusSpacing = maxItemsWidth / CGFloat(itemsPerRow) + let itemSize = floor(itemSizePlusSpacing * (1.0 - itemSpacingFactor)) + let itemSpacing = floor(itemSizePlusSpacing * itemSpacingFactor) + let sideInset = floor((availableSize.width - (itemSize * CGFloat(itemsPerRow) + itemSpacing * CGFloat(itemsPerRow - 1))) * 0.5) + + let rowCount = (component.reactionItems.count + (itemsPerRow - 1)) / itemsPerRow + + self.component = component + self.state = state + + let trailingPlaceholderSize = self.trailingPlaceholder.update( + transition: .immediate, + component: AnyComponent(Text(text: component.placeholder, font: Font.regular(17.0), color: component.theme.list.itemPlaceholderTextColor)), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) + ) + + var lastRowItemCount = component.reactionItems.count % itemsPerRow + if lastRowItemCount == 0 { + lastRowItemCount = itemsPerRow + } + let trailingLineWidth = sideInset + CGFloat(lastRowItemCount) * (itemSize + itemSpacing) + placeholderSpacing + + var contentHeight: CGFloat = verticalInset * 2.0 + CGFloat(rowCount) * itemSize + CGFloat(max(0, rowCount - 1)) * itemSpacing + let trailingPlaceholderFrame: CGRect + if availableSize.width - sideInset - trailingLineWidth < trailingPlaceholderSize.width { + contentHeight += itemSize + itemSpacing + trailingPlaceholderFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalInset + CGFloat(rowCount) * (itemSize + itemSpacing) + floor((itemSize - trailingPlaceholderSize.height) * 0.5)), size: trailingPlaceholderSize) + } else { + trailingPlaceholderFrame = CGRect(origin: CGPoint(x: trailingLineWidth, y: verticalInset + CGFloat(rowCount - 1) * (itemSize + itemSpacing) + floor((itemSize - trailingPlaceholderSize.height) * 0.5)), size: trailingPlaceholderSize) + } + + if let trailingPlaceholderView = self.trailingPlaceholder.view { + if trailingPlaceholderView.superview == nil { + trailingPlaceholderView.layer.anchorPoint = CGPoint() + self.addSubview(trailingPlaceholderView) + self.addSubview(self.caretIndicator) + } + transition.setPosition(view: trailingPlaceholderView, position: trailingPlaceholderFrame.origin) + trailingPlaceholderView.bounds = CGRect(origin: CGPoint(), size: trailingPlaceholderFrame.size) + + self.caretIndicator.tintColor = component.theme.list.itemAccentColor + transition.setFrame(view: self.caretIndicator, frame: CGRect(origin: CGPoint(x: trailingPlaceholderFrame.minX, y: trailingPlaceholderFrame.minY + floorToScreenPixels((trailingPlaceholderFrame.height - 22.0) * 0.5)), size: CGSize(width: 2.0, height: 22.0))) + self.caretIndicator.isHidden = !component.isInputActive + } + + var validIds: [Int64] = [] + for i in 0 ..< component.reactionItems.count { + let item = component.reactionItems[i] + let itemKey = item.file.fileId.id + validIds.append(itemKey) + + let itemFrame = CGRect(origin: CGPoint(x: sideInset + CGFloat(i % itemsPerRow) * (itemSize + itemSpacing), y: verticalInset + CGFloat(i / itemsPerRow) * (itemSize + itemSpacing)), size: CGSize(width: itemSize, height: itemSize)) + + var itemTransition = transition + var animateIn = false + let itemLayer: EmojiPagerContentComponent.View.ItemLayer + if let current = self.itemLayers[itemKey] { + itemLayer = current + } else { + itemTransition = .immediate + animateIn = true + + let animationData = EntityKeyboardAnimationData( + file: item.file + ) + itemLayer = EmojiPagerContentComponent.View.ItemLayer( + item: EmojiPagerContentComponent.Item( + animationData: animationData, + content: .animation(animationData), + itemFile: item.file, + subgroupId: nil, + icon: .none, + tintMode: .none + ), + context: component.context, + attemptSynchronousLoad: false, + content: EmojiPagerContentComponent.ItemContent.animation(animationData), + cache: component.context.animationCache, + renderer: component.context.animationRenderer, + placeholderColor: component.theme.list.mediaPlaceholderColor, + blurredBadgeColor: .clear, + accentIconColor: component.theme.list.itemAccentColor, + pointSize: CGSize(width: 32.0, height: 32.0), + onUpdateDisplayPlaceholder: { _, _ in + } + ) + self.itemLayers[itemKey] = itemLayer + self.layer.addSublayer(itemLayer) + } + itemLayer.isVisibleForAnimations = true + + itemTransition.setFrame(layer: itemLayer, frame: itemFrame) + if animateIn, !transition.animation.isImmediate { + itemLayer.animateScale(from: 0.001, to: 1.0, duration: 0.2) + itemLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + + var removedIds: [Int64] = [] + for (key, itemLayer) in self.itemLayers { + if !validIds.contains(key) { + removedIds.append(key) + + if !transition.animation.isImmediate { + itemLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemLayer] _ in + itemLayer?.removeFromSuperlayer() + }) + itemLayer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false) + } else { + itemLayer.removeFromSuperlayer() + } + } + } + for key in removedIds { + self.itemLayers.removeValue(forKey: key) + } + + return CGSize(width: availableSize.width, height: contentHeight) + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/EmojiSelectionComponent.swift b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/EmojiSelectionComponent.swift new file mode 100644 index 0000000000..bcff498a52 --- /dev/null +++ b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/EmojiSelectionComponent.swift @@ -0,0 +1,224 @@ +import Foundation +import UIKit +import Display +import TelegramPresentationData +import ComponentFlow +import ComponentDisplayAdapters +import EntityKeyboard +import AccountContext +import PagerComponent + +public final class EmojiSelectionComponent: Component { + public typealias EnvironmentType = Empty + + public let theme: PresentationTheme + public let strings: PresentationStrings + public let sideInset: CGFloat + public let bottomInset: CGFloat + public let deviceMetrics: DeviceMetrics + public let emojiContent: EmojiPagerContentComponent + public let backgroundIconColor: UIColor? + public let backgroundColor: UIColor + public let separatorColor: UIColor + + public init( + theme: PresentationTheme, + strings: PresentationStrings, + sideInset: CGFloat, + bottomInset: CGFloat, + deviceMetrics: DeviceMetrics, + emojiContent: EmojiPagerContentComponent, + backgroundIconColor: UIColor?, + backgroundColor: UIColor, + separatorColor: UIColor + ) { + self.theme = theme + self.strings = strings + self.sideInset = sideInset + self.bottomInset = bottomInset + self.deviceMetrics = deviceMetrics + self.emojiContent = emojiContent + self.backgroundIconColor = backgroundIconColor + self.backgroundColor = backgroundColor + self.separatorColor = separatorColor + } + + public static func ==(lhs: EmojiSelectionComponent, rhs: EmojiSelectionComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings != rhs.strings { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.bottomInset != rhs.bottomInset { + return false + } + if lhs.deviceMetrics != rhs.deviceMetrics { + return false + } + if lhs.emojiContent != rhs.emojiContent { + return false + } + if lhs.backgroundIconColor != rhs.backgroundIconColor { + return false + } + if lhs.backgroundColor != rhs.backgroundColor { + return false + } + if lhs.separatorColor != rhs.separatorColor { + return false + } + return true + } + + public final class View: UIView { + private let keyboardView: ComponentView + private let keyboardClippingView: UIView + private let panelHostView: PagerExternalTopPanelContainer + private let panelBackgroundView: BlurredBackgroundView + private let panelSeparatorView: UIView + private let shadowView: UIImageView + private let cornersView: UIImageView + + private var component: EmojiSelectionComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.keyboardView = ComponentView() + self.keyboardClippingView = UIView() + self.panelHostView = PagerExternalTopPanelContainer() + self.panelBackgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) + self.panelSeparatorView = UIView() + self.shadowView = UIImageView() + self.cornersView = UIImageView() + + super.init(frame: frame) + + self.addSubview(self.keyboardClippingView) + self.addSubview(self.panelBackgroundView) + self.addSubview(self.panelSeparatorView) + self.addSubview(self.panelHostView) + self.addSubview(self.cornersView) + self.addSubview(self.shadowView) + + self.shadowView.image = generateImage(CGSize(width: 16.0, height: 16.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setShadow(offset: CGSize(), blur: 40.0, color: UIColor(white: 0.0, alpha: 0.05).cgColor) + context.setFillColor(UIColor.black.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: 8.0), size: size)) + context.setBlendMode(.copy) + context.setFillColor(UIColor.clear.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: 8.0), size: size).insetBy(dx: -0.5, dy: -0.5)) + })?.stretchableImage(withLeftCapWidth: 8, topCapHeight: 16) + + self.cornersView.image = generateImage(CGSize(width: 16.0 + 1.0, height: 16.0), rotatedContext: { size, context in + context.setFillColor(UIColor.white.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + context.setBlendMode(.copy) + context.setFillColor(UIColor.clear.cgColor) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(x: 0.0, y: 8.0), size: size), cornerRadius: 8.0).cgPath) + context.fillPath() + context.clear(CGRect(origin: CGPoint(x: 8.0, y: 0.0), size: CGSize(width: 1.0, height: size.height))) + })?.withRenderingMode(.alwaysTemplate).stretchableImage(withLeftCapWidth: 8, topCapHeight: 16) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + func update(component: EmojiSelectionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.backgroundColor = component.backgroundColor + let panelBackgroundColor = component.backgroundColor.withMultipliedAlpha(0.85) + self.panelBackgroundView.updateColor(color: panelBackgroundColor, transition: .immediate) + self.panelSeparatorView.backgroundColor = component.separatorColor + + self.component = component + self.state = state + + self.cornersView.tintColor = component.theme.list.blocksBackgroundColor + transition.setFrame(view: self.cornersView, frame: CGRect(origin: CGPoint(x: 0.0, y: -8.0), size: CGSize(width: availableSize.width, height: 16.0))) + + transition.setFrame(view: self.shadowView, frame: CGRect(origin: CGPoint(x: 0.0, y: -8.0), size: CGSize(width: availableSize.width, height: 16.0))) + + let topPanelHeight: CGFloat = 42.0 + + let keyboardSize = self.keyboardView.update( + transition: transition.withUserData(EmojiPagerContentComponent.SynchronousLoadBehavior(isDisabled: true)), + component: AnyComponent(EntityKeyboardComponent( + theme: component.theme, + strings: component.strings, + isContentInFocus: false, + containerInsets: UIEdgeInsets(top: topPanelHeight - 34.0, left: component.sideInset, bottom: component.bottomInset, right: component.sideInset), + topPanelInsets: UIEdgeInsets(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0), + emojiContent: component.emojiContent, + stickerContent: nil, + maskContent: nil, + gifContent: nil, + hasRecentGifs: false, + availableGifSearchEmojies: [], + defaultToEmojiTab: true, + externalTopPanelContainer: self.panelHostView, + externalBottomPanelContainer: nil, + displayTopPanelBackground: .blur, + topPanelExtensionUpdated: { _, _ in }, + topPanelScrollingOffset: { _, _ in }, + hideInputUpdated: { _, _, _ in }, + hideTopPanelUpdated: { _, _ in }, + switchToTextInput: {}, + switchToGifSubject: { _ in }, + reorderItems: { _, _ in }, + makeSearchContainerNode: { _ in return nil }, + contentIdUpdated: { _ in }, + deviceMetrics: component.deviceMetrics, + hiddenInputHeight: 0.0, + inputHeight: 0.0, + displayBottomPanel: false, + isExpanded: true, + clipContentToTopPanel: false, + useExternalSearchContainer: false, + customTintColor: component.backgroundIconColor + )), + environment: {}, + containerSize: availableSize + ) + if let keyboardComponentView = self.keyboardView.view { + if keyboardComponentView.superview == nil { + self.keyboardClippingView.addSubview(keyboardComponentView) + } + + if panelBackgroundColor.alpha < 0.01 { + self.keyboardClippingView.clipsToBounds = true + } else { + self.keyboardClippingView.clipsToBounds = false + } + + transition.setFrame(view: self.keyboardClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight), size: CGSize(width: availableSize.width, height: availableSize.height - topPanelHeight))) + + transition.setFrame(view: keyboardComponentView, frame: CGRect(origin: CGPoint(x: 0.0, y: -topPanelHeight), size: keyboardSize)) + transition.setFrame(view: self.panelHostView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight - 34.0), size: CGSize(width: keyboardSize.width, height: 0.0))) + + transition.setFrame(view: self.panelBackgroundView, frame: CGRect(origin: CGPoint(), size: CGSize(width: keyboardSize.width, height: topPanelHeight))) + self.panelBackgroundView.update(size: self.panelBackgroundView.bounds.size, transition: transition.containedViewLayoutTransition) + + transition.setFrame(view: self.panelSeparatorView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight), size: CGSize(width: keyboardSize.width, height: UIScreenPixel))) + transition.setAlpha(view: self.panelSeparatorView, alpha: 1.0) + } + + return availableSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/ListSwitchItemComponent.swift b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/ListSwitchItemComponent.swift new file mode 100644 index 0000000000..5bc2a48642 --- /dev/null +++ b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/ListSwitchItemComponent.swift @@ -0,0 +1,116 @@ +import Foundation +import UIKit +import Display +import TelegramPresentationData +import ComponentFlow +import ComponentDisplayAdapters +import SwitchComponent + +final class ListSwitchItemComponent: Component { + let theme: PresentationTheme + let title: String + let value: Bool + let valueUpdated: (Bool) -> Void + + init( + theme: PresentationTheme, + title: String, + value: Bool, + valueUpdated: @escaping (Bool) -> Void + ) { + self.theme = theme + self.title = title + self.value = value + self.valueUpdated = valueUpdated + } + + static func ==(lhs: ListSwitchItemComponent, rhs: ListSwitchItemComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.title != rhs.title { + return false + } + if lhs.value != rhs.value { + return false + } + return true + } + + final class View: UIView { + private let title = ComponentView() + private let switchView = ComponentView() + + private var component: ListSwitchItemComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + func update(component: ListSwitchItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.component = component + self.state = state + + self.backgroundColor = component.theme.list.itemBlocksBackgroundColor + self.layer.cornerRadius = 12.0 + + let size = CGSize(width: availableSize.width, height: 44.0) + let rightInset: CGFloat = 16.0 + let leftInset: CGFloat = 16.0 + let spacing: CGFloat = 8.0 + + let switchSize = self.switchView.update( + transition: transition, + component: AnyComponent(SwitchComponent( + value: component.value, + valueUpdated: { [weak self] value in + guard let self else { + return + } + self.component?.valueUpdated(value) + } + )), + environment: {}, + containerSize: size + ) + let switchFrame = CGRect(origin: CGPoint(x: size.width - rightInset - switchSize.width, y: floor((size.height - switchSize.height) * 0.5)), size: switchSize) + if let switchComponentView = self.switchView.view { + if switchComponentView.superview == nil { + self.addSubview(switchComponentView) + } + transition.setFrame(view: switchComponentView, frame: switchFrame) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(Text(text: component.title, font: Font.regular(17.0), color: component.theme.list.itemPrimaryTextColor)), + environment: {}, + containerSize: CGSize(width: max(1.0, switchFrame.minX - spacing - leftInset), height: .greatestFiniteMagnitude) + ) + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.layer.anchorPoint = CGPoint() + self.addSubview(titleView) + } + transition.setPosition(view: titleView, position: titleFrame.origin) + titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) + } + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift new file mode 100644 index 0000000000..37e82af6a8 --- /dev/null +++ b/submodules/TelegramUI/Components/PeerAllowedReactionsScreen/Sources/PeerAllowedReactionsScreen.swift @@ -0,0 +1,856 @@ +import Foundation +import UIKit +import Display +import TelegramPresentationData +import ComponentFlow +import ComponentDisplayAdapters +import AppBundle +import ViewControllerComponent +import AccountContext +import TelegramCore +import Postbox +import SwiftSignalKit +import EntityKeyboard +import MultilineTextComponent +import Markdown +import ButtonComponent +import PremiumUI +import UndoUI + +final class PeerAllowedReactionsScreenComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let peerId: EnginePeer.Id + let initialContent: PeerAllowedReactionsScreen.Content + + init( + context: AccountContext, + peerId: EnginePeer.Id, + initialContent: PeerAllowedReactionsScreen.Content + ) { + self.context = context + self.peerId = peerId + self.initialContent = initialContent + } + + static func ==(lhs: PeerAllowedReactionsScreenComponent, rhs: PeerAllowedReactionsScreenComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.peerId != rhs.peerId { + return false + } + + return true + } + + final class View: UIView, UIScrollViewDelegate { + private let scrollView: UIScrollView + private let switchItem = ComponentView() + private let switchInfoText = ComponentView() + private var reactionsTitleText: ComponentView? + private var reactionsInfoText: ComponentView? + private var reactionInput: ComponentView? + private let actionButton = ComponentView() + + private var reactionSelectionControl: ComponentView? + + private var isUpdating: Bool = false + + private var component: PeerAllowedReactionsScreenComponent? + private(set) weak var state: EmptyComponentState? + private var environment: EnvironmentType? + + private var isEnabled: Bool = false + private var availableReactions: AvailableReactions? + private var enabledReactions: [EmojiComponentReactionItem]? + + private var emojiContent: EmojiPagerContentComponent? + private var emojiContentDisposable: Disposable? + + private var displayInput: Bool = false + + private var isApplyingSettings: Bool = false + private var applyDisposable: Disposable? + + override init(frame: CGRect) { + self.scrollView = UIScrollView() + self.scrollView.showsVerticalScrollIndicator = true + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.scrollsToTop = false + self.scrollView.delaysContentTouches = false + self.scrollView.canCancelContentTouches = true + self.scrollView.contentInsetAdjustmentBehavior = .never + self.scrollView.alwaysBounceVertical = true + + super.init(frame: frame) + + self.scrollView.delegate = self + self.addSubview(self.scrollView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.emojiContentDisposable?.dispose() + self.applyDisposable?.dispose() + } + + func scrollToTop() { + self.scrollView.setContentOffset(CGPoint(), animated: true) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + self.updateScrolling(transition: .immediate) + } + + private func updateScrolling(transition: Transition) { + let navigationAlphaDistance: CGFloat = 16.0 + let navigationAlpha: CGFloat = max(0.0, min(1.0, self.scrollView.contentOffset.y / navigationAlphaDistance)) + if let controller = self.environment?.controller(), let navigationBar = controller.navigationBar { + transition.setAlpha(layer: navigationBar.backgroundNode.layer, alpha: navigationAlpha) + transition.setAlpha(layer: navigationBar.stripeNode.layer, alpha: navigationAlpha) + } + } + + private func applySettings() { + guard let component = self.component else { + return + } + if self.isApplyingSettings { + return + } + guard let enabledReactions = self.enabledReactions else { + return + } + guard let availableReactions = self.availableReactions else { + return + } + + self.isApplyingSettings = true + self.state?.updated(transition: .immediate) + + self.applyDisposable?.dispose() + + let allowedReactions: PeerAllowedReactions + if self.isEnabled { + if Set(availableReactions.reactions.map(\.value)) == Set(enabledReactions.map(\.reaction)) { + allowedReactions = .all + } else { + allowedReactions = .limited(enabledReactions.map(\.reaction)) + } + } else { + allowedReactions = .empty + } + self.applyDisposable = (component.context.engine.peers.updatePeerAllowedReactions(peerId: component.peerId, allowedReactions: allowedReactions) + |> deliverOnMainQueue).start(error: { [weak self] error in + guard let self, let component = self.component else { + return + } + self.isApplyingSettings = false + self.state?.updated(transition: .immediate) + + switch error { + case .boostRequired: + let _ = combineLatest( + queue: Queue.mainQueue(), + component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.peerId)), + component.context.engine.peers.getChannelBoostStatus(peerId: component.peerId) + ).startStandalone(next: { [weak self] peer, status in + guard let self, let component = self.component, let peer, let status else { + return + } + + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 }) + + let link = status.url + let controller = PremiumLimitScreen(context: component.context, subject: .storiesChannelBoost(peer: peer, boostSubject: .channelReactions, isCurrent: true, level: Int32(status.level), currentLevelBoosts: Int32(status.currentLevelBoosts), nextLevelBoosts: status.nextLevelBoosts.flatMap(Int32.init), link: link, myBoostCount: 0, canBoostAgain: false), count: Int32(status.boosts), action: { [weak self] in + guard let self, let component = self.component else { + return true + } + + UIPasteboard.general.string = link + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + self.environment?.controller()?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.ChannelBoost_BoostLinkCopied), elevatedLayout: false, position: .bottom, animateInAsReplacement: false, action: { _ in return false }), in: .current) + return true + }, openStats: nil, openGift: premiumConfiguration.giveawayGiftsPurchaseAvailable ? { [weak self] in + guard let self, let component = self.component else { + return + } + let controller = createGiveawayController(context: component.context, peerId: component.peerId, subject: .generic) + self.environment?.controller()?.push(controller) + } : nil) + self.environment?.controller()?.push(controller) + + HapticFeedback().impact(.light) + }) + case .generic: + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + //TODO:localize + self.environment?.controller()?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: "An error occurred", actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + } + }, completed: { [weak self] in + guard let self else { + return + } + self.environment?.controller()?.dismiss() + }) + } + + func update(component: PeerAllowedReactionsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + + let environment = environment[EnvironmentType.self].value + let themeUpdated = self.environment?.theme !== environment.theme + self.environment = environment + + self.component = component + self.state = state + + let topInset: CGFloat = 24.0 + let bottomInset: CGFloat = 8.0 + let sideInset: CGFloat = 16.0 + environment.safeInsets.left + let textSideInset: CGFloat = 16.0 + + let enabledReactions: [EmojiComponentReactionItem] + if let current = self.enabledReactions { + enabledReactions = current + } else { + enabledReactions = component.initialContent.enabledReactions + self.enabledReactions = enabledReactions + self.availableReactions = component.initialContent.availableReactions + self.isEnabled = component.initialContent.isEnabled + } + + if self.emojiContentDisposable == nil { + let emojiContent = EmojiPagerContentComponent.emojiInputData( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + isStandalone: false, + subject: .reactionList, + hasTrending: false, + topReactionItems: [], + areUnicodeEmojiEnabled: false, + areCustomEmojiEnabled: true, + chatPeerId: nil, + selectedItems: Set(), + backgroundIconColor: nil, + hasSearch: false, + forceHasPremium: true + ) + self.emojiContentDisposable = (emojiContent + |> deliverOnMainQueue).start(next: { [weak self] emojiContent in + guard let self else { + return + } + self.emojiContent = emojiContent + + emojiContent.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction( + performItemAction: { [weak self] _, item, _, _, _, _ in + guard let self, var enabledReactions = self.enabledReactions else { + return + } + if self.isApplyingSettings { + return + } + guard let itemFile = item.itemFile else { + return + } + + if let index = enabledReactions.firstIndex(where: { $0.file.fileId.id == itemFile.fileId.id }) { + enabledReactions.remove(at: index) + } else { + let reaction: MessageReaction.Reaction + if let availableReactions = self.availableReactions, let reactionItem = availableReactions.reactions.first(where: { $0.selectAnimation.fileId.id == itemFile.fileId.id }) { + reaction = reactionItem.value + } else { + reaction = .custom(itemFile.fileId.id) + } + enabledReactions.append(EmojiComponentReactionItem(reaction: reaction, file: itemFile)) + } + self.enabledReactions = enabledReactions + if !self.isUpdating { + self.state?.updated(transition: .spring(duration: 0.4)) + } + }, + deleteBackwards: { + }, + openStickerSettings: { + }, + openFeatured: { + }, + openSearch: { + }, + addGroupAction: { _, _, _ in + }, + clearGroup: { _ in + }, + pushController: { c in + }, + presentController: { c in + }, + presentGlobalOverlayController: { c in + }, + navigationController: { + return nil + }, + requestUpdate: { _ in + }, + updateSearchQuery: { _ in + }, + updateScrollingToItemGroup: { + }, + onScroll: {}, + chatPeerId: nil, + peekBehavior: nil, + customLayout: nil, + externalBackground: nil, + externalExpansionView: nil, + customContentView: nil, + useOpaqueTheme: true, + hideBackground: false, + stateContext: nil, + addImage: nil + ) + + if !self.isUpdating { + self.state?.updated(transition: .immediate) + } + }) + } + + if themeUpdated { + self.backgroundColor = environment.theme.list.blocksBackgroundColor + } + + var contentHeight: CGFloat = 0.0 + contentHeight += environment.navigationHeight + contentHeight += topInset + + let switchSize = self.switchItem.update( + transition: transition, + component: AnyComponent(ListSwitchItemComponent( + theme: environment.theme, + title: environment.strings.PeerInfo_AllowedReactions_AllowAllText, + value: true, + valueUpdated: { [weak self] value in + guard let self else { + return + } + if self.isEnabled != value { + self.isEnabled = value + + if self.isEnabled { + if var enabledReactions = self.enabledReactions, enabledReactions.isEmpty { + if let availableReactions = self.availableReactions { + for reactionItem in availableReactions.reactions { + enabledReactions.append(EmojiComponentReactionItem(reaction: reactionItem.value, file: reactionItem.selectAnimation)) + } + } + self.enabledReactions = enabledReactions + } + } else { + self.displayInput = false + } + + self.state?.updated(transition: .easeInOut(duration: 0.25)) + } + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude) + ) + let switchFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: switchSize) + if let switchView = self.switchItem.view { + if switchView.superview == nil { + self.scrollView.addSubview(switchView) + } + transition.setFrame(view: switchView, frame: switchFrame) + } + contentHeight += switchSize.height + contentHeight += 7.0 + + //TODO:localize + let switchInfoTextSize = self.switchInfoText.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "You can add emoji from any emoji pack as a reaction.", + font: Font.regular(13.0), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - textSideInset * 2.0, height: .greatestFiniteMagnitude) + ) + let switchInfoTextFrame = CGRect(origin: CGPoint(x: sideInset + textSideInset, y: contentHeight), size: switchInfoTextSize) + if let switchInfoTextView = self.switchInfoText.view { + if switchInfoTextView.superview == nil { + switchInfoTextView.layer.anchorPoint = CGPoint() + self.scrollView.addSubview(switchInfoTextView) + } + transition.setPosition(view: switchInfoTextView, position: switchInfoTextFrame.origin) + switchInfoTextView.bounds = CGRect(origin: CGPoint(), size: switchInfoTextFrame.size) + } + contentHeight += switchInfoTextSize.height + contentHeight += 37.0 + + if self.isEnabled { + var animateIn = false + + let reactionsTitleText: ComponentView + if let current = self.reactionsTitleText { + reactionsTitleText = current + } else { + reactionsTitleText = ComponentView() + self.reactionsTitleText = reactionsTitleText + animateIn = true + } + + //TODO:localize + let reactionsTitleTextSize = reactionsTitleText.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "AVAILABLE REACTIONS", + font: Font.regular(13.0), + textColor: environment.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - textSideInset * 2.0, height: .greatestFiniteMagnitude) + ) + let reactionsTitleTextFrame = CGRect(origin: CGPoint(x: sideInset + textSideInset, y: contentHeight), size: reactionsTitleTextSize) + if let reactionsTitleTextView = reactionsTitleText.view { + if reactionsTitleTextView.superview == nil { + reactionsTitleTextView.layer.anchorPoint = CGPoint() + self.scrollView.addSubview(reactionsTitleTextView) + } + + if animateIn { + reactionsTitleTextView.frame = reactionsTitleTextFrame + if !transition.animation.isImmediate { + reactionsTitleTextView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } else { + transition.setPosition(view: reactionsTitleTextView, position: reactionsTitleTextFrame.origin) + reactionsTitleTextView.bounds = CGRect(origin: CGPoint(), size: reactionsTitleTextFrame.size) + } + } + contentHeight += reactionsTitleTextSize.height + contentHeight += 6.0 + + let reactionInput: ComponentView + if let current = self.reactionInput { + reactionInput = current + } else { + reactionInput = ComponentView() + self.reactionInput = reactionInput + } + + //TOOD:localize + let reactionInputSize = reactionInput.update( + transition: animateIn ? .immediate : transition, + component: AnyComponent(EmojiListInputComponent( + context: component.context, + theme: environment.theme, + placeholder: "Add Reactions...", + reactionItems: enabledReactions, + isInputActive: self.displayInput, + activateInput: { [weak self] in + guard let self else { + return + } + if self.emojiContent != nil && !self.displayInput { + self.displayInput = true + self.state?.updated(transition: .spring(duration: 0.5)) + } + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude) + ) + let reactionInputFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: reactionInputSize) + if let reactionInputView = reactionInput.view { + if reactionInputView.superview == nil { + self.scrollView.addSubview(reactionInputView) + } + if animateIn { + reactionInputView.frame = reactionInputFrame + if !transition.animation.isImmediate { + reactionInputView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } else { + transition.setFrame(view: reactionInputView, frame: reactionInputFrame) + } + } + contentHeight += reactionInputSize.height + contentHeight += 7.0 + + let reactionsInfoText: ComponentView + if let current = self.reactionsInfoText { + reactionsInfoText = current + } else { + reactionsInfoText = ComponentView() + self.reactionsInfoText = reactionsInfoText + } + + //TODO:localize + let body = MarkdownAttributeSet(font: UIFont.systemFont(ofSize: 13.0), textColor: environment.theme.list.freeTextColor) + let link = MarkdownAttributeSet(font: UIFont.systemFont(ofSize: 13.0), textColor: environment.theme.list.itemAccentColor, additionalAttributes: ["URL": true as NSNumber]) + let attributes = MarkdownAttributes(body: body, bold: body, link: link, linkAttribute: { _ in + return nil + }) + let reactionsInfoTextSize = reactionsInfoText.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .markdown(text: "You can also [create your own]() emoji packs and use them.", attributes: attributes), + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - textSideInset * 2.0, height: .greatestFiniteMagnitude) + ) + let reactionsInfoTextFrame = CGRect(origin: CGPoint(x: sideInset + textSideInset, y: contentHeight), size: reactionsInfoTextSize) + if let reactionsInfoTextView = reactionsInfoText.view { + if reactionsInfoTextView.superview == nil { + reactionsInfoTextView.layer.anchorPoint = CGPoint() + self.scrollView.addSubview(reactionsInfoTextView) + } + if animateIn { + reactionsInfoTextView.frame = reactionsInfoTextFrame + if !transition.animation.isImmediate { + reactionsInfoTextView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } else { + transition.setPosition(view: reactionsInfoTextView, position: reactionsInfoTextFrame.origin) + reactionsInfoTextView.bounds = CGRect(origin: CGPoint(), size: reactionsInfoTextFrame.size) + } + } + contentHeight += reactionsInfoTextSize.height + contentHeight += 6.0 + } else { + if let reactionsTitleText = self.reactionsTitleText { + self.reactionsTitleText = nil + if let reactionsTitleTextView = reactionsTitleText.view { + if !transition.animation.isImmediate { + reactionsTitleTextView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak reactionsTitleTextView] _ in + reactionsTitleTextView?.removeFromSuperview() + }) + } else { + reactionsTitleTextView.removeFromSuperview() + } + } + } + + if let reactionInput = self.reactionInput { + self.reactionInput = nil + if let reactionInputView = reactionInput.view { + if !transition.animation.isImmediate { + reactionInputView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak reactionInputView] _ in + reactionInputView?.removeFromSuperview() + }) + } else { + reactionInputView.removeFromSuperview() + } + } + } + + if let reactionsInfoText = self.reactionsInfoText { + self.reactionsInfoText = nil + if let reactionsInfoTextView = reactionsInfoText.view { + if !transition.animation.isImmediate { + reactionsInfoTextView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak reactionsInfoTextView] _ in + reactionsInfoTextView?.removeFromSuperview() + }) + } else { + reactionsInfoTextView.removeFromSuperview() + } + } + } + } + + //TODO:localize + var buttonContents: [AnyComponentWithIdentity] = [] + buttonContents.append(AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent( + Text(text: "Update Reactions", font: Font.semibold(17.0), color: environment.theme.list.itemCheckColors.foregroundColor) + ))) + /*if self.remainingTimer > 0 { + buttonContents.append(AnyComponentWithIdentity(id: AnyHashable(1 as Int), component: AnyComponent( + AnimatedTextComponent(font: Font.with(size: 17.0, weight: .semibold, traits: .monospacedNumbers), color: environment.theme.list.itemCheckColors.foregroundColor.withMultipliedAlpha(0.5), items: [ + AnimatedTextComponent.Item(id: AnyHashable(0 as Int), content: .number(self.remainingTimer, minDigits: 0)) + ]) + ))) + }*/ + + let buttonSize = self.actionButton.update( + transition: transition, + component: AnyComponent(ButtonComponent( + background: ButtonComponent.Background( + color: environment.theme.list.itemCheckColors.fillColor, + foreground: environment.theme.list.itemCheckColors.foregroundColor, + pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.8) + ), + content: AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent( + HStack(buttonContents, spacing: 5.0) + )), + isEnabled: true, + tintWhenDisabled: false, + displaysProgress: self.isApplyingSettings, + action: { [weak self] in + guard let self else { + return + } + self.applySettings() + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 50.0) + ) + contentHeight += buttonSize.height + + var inputHeight: CGFloat = 0.0 + if self.displayInput, let emojiContent = self.emojiContent { + let reactionSelectionControl: ComponentView + var animateIn = false + if let current = self.reactionSelectionControl { + reactionSelectionControl = current + } else { + animateIn = true + reactionSelectionControl = ComponentView() + self.reactionSelectionControl = reactionSelectionControl + } + let reactionSelectionControlSize = reactionSelectionControl.update( + transition: animateIn ? .immediate : transition, + component: AnyComponent(EmojiSelectionComponent( + theme: environment.theme, + strings: environment.strings, + sideInset: environment.safeInsets.left, + bottomInset: environment.safeInsets.bottom, + deviceMetrics: environment.deviceMetrics, + emojiContent: emojiContent.withSelectedItems(Set(enabledReactions.map(\.file.fileId))), + backgroundIconColor: nil, + backgroundColor: environment.theme.list.itemBlocksBackgroundColor, + separatorColor: environment.theme.list.itemBlocksSeparatorColor) + ), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: min(340.0, max(50.0, availableSize.height - 200.0))) + ) + let reactionSelectionControlFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - reactionSelectionControlSize.height), size: reactionSelectionControlSize) + if let reactionSelectionControlView = reactionSelectionControl.view { + if reactionSelectionControlView.superview == nil { + self.addSubview(reactionSelectionControlView) + } + if animateIn { + reactionSelectionControlView.frame = reactionSelectionControlFrame + transition.animatePosition(view: reactionSelectionControlView, from: CGPoint(x: 0.0, y: reactionSelectionControlFrame.height), to: CGPoint(), additive: true) + } else { + transition.setFrame(view: reactionSelectionControlView, frame: reactionSelectionControlFrame) + } + } + inputHeight = reactionSelectionControlSize.height + } else if let reactionSelectionControl = self.reactionSelectionControl { + self.reactionSelectionControl = nil + if let reactionSelectionControlView = reactionSelectionControl.view { + transition.setPosition(view: reactionSelectionControlView, position: CGPoint(x: reactionSelectionControlView.center.x, y: availableSize.height + reactionSelectionControlView.bounds.height * 0.5), completion: { [weak reactionSelectionControlView] _ in + reactionSelectionControlView?.removeFromSuperview() + }) + } + } + + let buttonY: CGFloat + + if self.displayInput { + contentHeight += bottomInset + 8.0 + contentHeight += inputHeight + + buttonY = availableSize.height - bottomInset - 8.0 - inputHeight - buttonSize.height + } else { + contentHeight += bottomInset + contentHeight += environment.safeInsets.bottom + + buttonY = availableSize.height - bottomInset - environment.safeInsets.bottom - buttonSize.height + } + + let buttonFrame = CGRect(origin: CGPoint(x: sideInset, y: buttonY), size: buttonSize) + if let buttonView = self.actionButton.view { + if buttonView.superview == nil { + self.addSubview(buttonView) + } + transition.setFrame(view: buttonView, frame: buttonFrame) + } + + let contentSize = CGSize(width: availableSize.width, height: contentHeight) + if self.scrollView.frame != CGRect(origin: CGPoint(), size: availableSize) { + self.scrollView.frame = CGRect(origin: CGPoint(), size: availableSize) + } + if self.scrollView.contentSize != contentSize { + self.scrollView.contentSize = contentSize + } + let scrollInsets = UIEdgeInsets(top: environment.navigationHeight, left: 0.0, bottom: environment.safeInsets.bottom, right: 0.0) + if self.scrollView.scrollIndicatorInsets != scrollInsets { + self.scrollView.scrollIndicatorInsets = scrollInsets + } + + self.updateScrolling(transition: transition) + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} + +public class PeerAllowedReactionsScreen: ViewControllerComponentContainer { + public final class Content: Equatable { + public let isEnabled: Bool + public let enabledReactions: [EmojiComponentReactionItem] + public let availableReactions: AvailableReactions? + + init( + isEnabled: Bool, + enabledReactions: [EmojiComponentReactionItem], + availableReactions: AvailableReactions? + ) { + self.isEnabled = isEnabled + self.enabledReactions = enabledReactions + self.availableReactions = availableReactions + } + + public static func ==(lhs: Content, rhs: Content) -> Bool { + if lhs === rhs { + return true + } + if lhs.isEnabled != rhs.isEnabled { + return false + } + if lhs.enabledReactions != rhs.enabledReactions { + return false + } + if lhs.availableReactions != rhs.availableReactions { + return false + } + return true + } + } + + private let context: AccountContext + private var isDismissed: Bool = false + + public init( + context: AccountContext, + peerId: EnginePeer.Id, + initialContent: Content + ) { + self.context = context + + super.init(context: context, component: PeerAllowedReactionsScreenComponent( + context: context, + peerId: peerId, + initialContent: initialContent + ), navigationBarAppearance: .default, theme: .default) + + self.scrollToTop = { [weak self] in + guard let self, let componentView = self.node.hostView.componentView as? PeerAllowedReactionsScreenComponent.View else { + return + } + componentView.scrollToTop() + } + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + self.title = presentationData.strings.PeerInfo_AllowedReactions_Title + self.navigationItem.setLeftBarButton(UIBarButtonItem(title: presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed)), animated: false) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + @objc private func cancelPressed() { + self.dismiss() + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + } + + public static func content(context: AccountContext, peerId: EnginePeer.Id) -> Signal { + return combineLatest( + context.engine.stickers.availableReactions(), + context.account.postbox.combinedView(keys: [.cachedPeerData(peerId: peerId)]) + ) + |> mapToSignal { availableReactions, combinedView -> Signal in + guard let cachedDataView = combinedView.views[.cachedPeerData(peerId: peerId)] as? CachedPeerDataView, let cachedData = cachedDataView.cachedPeerData as? CachedChannelData else { + return .complete() + } + + var reactions: [MessageReaction.Reaction] = [] + var isEnabled = false + + if let allowedReactions = cachedData.allowedReactions.knownValue { + switch allowedReactions { + case .all: + isEnabled = true + if let availableReactions { + reactions = availableReactions.reactions.map(\.value) + } + case let .limited(list): + isEnabled = true + reactions.append(contentsOf: list) + case .empty: + isEnabled = false + } + } + + var missingReactionFiles: [Int64] = [] + for reaction in reactions { + if let availableReactions, let _ = availableReactions.reactions.first(where: { $0.value == reaction }) { + } else { + if case let .custom(fileId) = reaction { + if !missingReactionFiles.contains(fileId) { + missingReactionFiles.append(fileId) + } + } + } + } + + return context.engine.stickers.resolveInlineStickers(fileIds: missingReactionFiles) + |> map { files -> Content in + var result: [EmojiComponentReactionItem] = [] + + for reaction in reactions { + if let availableReactions, let item = availableReactions.reactions.first(where: { $0.value == reaction }) { + result.append(EmojiComponentReactionItem(reaction: reaction, file: item.selectAnimation)) + } else { + if case let .custom(fileId) = reaction { + if let file = files[fileId] { + result.append(EmojiComponentReactionItem(reaction: reaction, file: file)) + } + } + } + } + + return Content(isEnabled: isEnabled, enabledReactions: result, availableReactions: availableReactions) + } + } + |> distinctUntilChanged + } +} diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index b43f14cf92..a8fc3f7c23 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -95,6 +95,7 @@ import WebUI import ShareWithPeersScreen import ItemListPeerItem import PeerNameColorScreen +import PeerAllowedReactionsScreen enum PeerInfoAvatarEditingMode { case generic @@ -7122,7 +7123,18 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro guard let data = self.data, let peer = data.peer else { return } - self.controller?.push(peerAllowedReactionListController(context: self.context, updatedPresentationData: self.controller?.updatedPresentationData, peerId: peer.id)) + if let channel = peer as? TelegramChannel, case .broadcast = channel.info { + let subscription = Promise() + subscription.set(PeerAllowedReactionsScreen.content(context: self.context, peerId: peer.id)) + let _ = (subscription.get() |> take(1) |> deliverOnMainQueue).start(next: { [weak self] content in + guard let self else { + return + } + self.controller?.push(PeerAllowedReactionsScreen(context: self.context, peerId: peer.id, initialContent: content)) + }) + } else { + self.controller?.push(peerAllowedReactionListController(context: self.context, updatedPresentationData: self.controller?.updatedPresentationData, peerId: peer.id)) + } } private func toggleForumTopics(isEnabled: Bool) {