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 import BundleIconComponent import AnimatedTextComponent import TextFormat import AudioToolbox import PremiumLockButtonSubtitleComponent import ListSectionComponent import ListItemSliderSelectorComponent 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 var reactionCountSection: ComponentView? private var paidReactionsSection: 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 boostStatus: ChannelBoostStatus? private var boostStatusDisposable: Disposable? private var isEnabled: Bool = false private var availableReactions: AvailableReactions? private var enabledReactions: [EmojiComponentReactionItem]? private var allowedReactionCount: Int = 11 private var appliedReactionSettings: PeerReactionSettings? private var areStarsReactionsEnabled: Bool = true private var emojiContent: EmojiPagerContentComponent? private var emojiContentDisposable: Disposable? private var caretPosition: Int? private var displayInput: Bool = false private var recenterOnCaret: Bool = false private var isApplyingSettings: Bool = false private var applyDisposable: Disposable? private var resolveStickersBotDisposable: Disposable? private weak var currentUndoController: UndoOverlayController? private var cachedChevronImage: (UIImage, PresentationTheme)? 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() self.boostStatusDisposable?.dispose() self.resolveStickersBotDisposable?.dispose() } func scrollToTop() { self.scrollView.setContentOffset(CGPoint(), animated: true) } func attemptNavigation(complete: @escaping () -> Void) -> Bool { guard let component = self.component else { return true } if self.isApplyingSettings { return true } guard var enabledReactions = self.enabledReactions else { return true } if !self.isEnabled { enabledReactions.removeAll() } enabledReactions.removeAll(where: { $0.reaction == .stars }) guard let availableReactions = self.availableReactions else { return true } let allowedReactions: PeerAllowedReactions if self.isEnabled { if Set(availableReactions.reactions.filter({ $0.isEnabled }).map(\.value)) == Set(enabledReactions.map(\.reaction)) { allowedReactions = .all } else { if enabledReactions.isEmpty { allowedReactions = .empty } else { allowedReactions = .limited(enabledReactions.map(\.reaction)) } } } else { allowedReactions = .empty } let reactionSettings = PeerReactionSettings(allowedReactions: allowedReactions, maxReactionCount: self.allowedReactionCount >= 11 ? nil : Int32(self.allowedReactionCount), starsAllowed: self.isEnabled && self.areStarsReactionsEnabled) if self.appliedReactionSettings != reactionSettings { if case .empty = allowedReactions { self.applySettings(standalone: true) } else { let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } self.environment?.controller()?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: presentationData.strings.ChannelReactions_UnsavedChangesAlertTitle, text: presentationData.strings.ChannelReactions_UnsavedChangesAlertText, actions: [ TextAlertAction(type: .genericAction, title: presentationData.strings.ChannelReactions_UnsavedChangesAlertDiscard, action: { [weak self] in guard let self else { return } self.environment?.controller()?.dismiss() }), TextAlertAction(type: .defaultAction, title: presentationData.strings.ChannelReactions_UnsavedChangesAlertApply, action: { [weak self] in guard let self else { return } self.applySettings(standalone: false) }) ]), in: .window(.root)) return false } } return true } func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateScrolling(transition: .immediate) } private func updateScrolling(transition: ComponentTransition) { 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(standalone: Bool) { guard let component = self.component else { return } if self.isApplyingSettings { return } guard var enabledReactions = self.enabledReactions else { return } enabledReactions.removeAll(where: { $0.reaction == .stars }) if !self.isEnabled { enabledReactions.removeAll() } guard let availableReactions = self.availableReactions else { return } let customReactions = enabledReactions.filter({ item in switch item.reaction { case .custom: return true case .builtin: return false case .stars: return false } }) if let boostStatus = self.boostStatus, !customReactions.isEmpty, customReactions.count > boostStatus.level { self.displayPremiumScreen(reactionCount: customReactions.count) return } self.isApplyingSettings = true self.state?.updated(transition: .immediate) self.applyDisposable?.dispose() let allowedReactions: PeerAllowedReactions if self.isEnabled { if Set(availableReactions.reactions.filter({ $0.isEnabled }).map(\.value)) == Set(enabledReactions.map(\.reaction)) { allowedReactions = .all } else if enabledReactions.isEmpty { allowedReactions = .empty } else { allowedReactions = .limited(enabledReactions.map(\.reaction)) } } else { allowedReactions = .empty } let reactionSettings = PeerReactionSettings(allowedReactions: allowedReactions, maxReactionCount: self.allowedReactionCount == 11 ? nil : Int32(self.allowedReactionCount), starsAllowed: self.isEnabled && self.areStarsReactionsEnabled) let applyDisposable = (component.context.engine.peers.updatePeerReactionSettings(peerId: component.peerId, reactionSettings: reactionSettings) |> deliverOnMainQueue).start(error: { [weak self] error in guard let self, let component = self.component else { return } self.isApplyingSettings = false self.state?.updated(transition: .immediate) if !standalone { switch error { case .boostRequired: self.displayPremiumScreen(reactionCount: customReactions.count) case .generic: let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } self.environment?.controller()?.present(standardTextAlertController(theme: AlertControllerTheme(presentationData: presentationData), title: nil, text: presentationData.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) } } }, completed: { [weak self] in guard let self else { return } self.appliedReactionSettings = reactionSettings if !standalone { self.environment?.controller()?.dismiss() } }) if standalone { let _ = applyDisposable } else { self.applyDisposable = applyDisposable } } private func displayPremiumScreen(reactionCount: Int) { guard let component = self.component else { return } let _ = (component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.peerId)) |> deliverOnMainQueue).startStandalone(next: { [weak self] peer in guard let self, let component = self.component, let peer, let status = self.boostStatus 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(reactionCount: reactionCount), 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: { [weak self] in guard let self else { return } self.openBoostStats() }, 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) }) } private func openBoostStats() { guard let component = self.component, let boostStatus = self.boostStatus else { return } let statsController = component.context.sharedContext.makeChannelStatsController(context: component.context, updatedPresentationData: nil, peerId: component.peerId, boosts: true, boostStatus: boostStatus) self.environment?.controller()?.push(statsController) } func update(component: PeerAllowedReactionsScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> 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 { if let value = component.initialContent.reactionSettings?.starsAllowed { self.areStarsReactionsEnabled = value } else { self.areStarsReactionsEnabled = component.initialContent.isStarReactionAvailable } var enabledReactionsValue = component.initialContent.enabledReactions if self.areStarsReactionsEnabled { if let item = component.initialContent.availableReactions?.reactions.first(where: { $0.value == .stars }) { enabledReactionsValue.insert(EmojiComponentReactionItem(reaction: item.value, file: item.selectAnimation), at: 0) } } enabledReactions = enabledReactionsValue self.enabledReactions = enabledReactions self.availableReactions = component.initialContent.availableReactions self.isEnabled = component.initialContent.isEnabled self.appliedReactionSettings = component.initialContent.reactionSettings.flatMap { reactionSettings in return PeerReactionSettings( allowedReactions: reactionSettings.allowedReactions, maxReactionCount: reactionSettings.maxReactionCount == 11 ? nil : reactionSettings.maxReactionCount, starsAllowed: reactionSettings.starsAllowed ) } self.allowedReactionCount = (component.initialContent.reactionSettings?.maxReactionCount).flatMap(Int.init) ?? 11 } var caretPosition = self.caretPosition ?? enabledReactions.count caretPosition = max(0, min(enabledReactions.count, caretPosition)) self.caretPosition = caretPosition 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 } AudioServicesPlaySystemSound(0x450) if let index = enabledReactions.firstIndex(where: { $0.file.fileId.id == itemFile.fileId.id }) { enabledReactions.remove(at: index) if let caretPosition = self.caretPosition, caretPosition > index { self.caretPosition = max(0, caretPosition - 1) } } else { if enabledReactions.count >= 100 { let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } var animateAsReplacement = false if let currentUndoController = self.currentUndoController { currentUndoController.dismiss() animateAsReplacement = true } let undoController = UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.ChannelReactions_ToastMaxReactionsReached, timeout: nil, customUndoText: nil), elevatedLayout: false, position: .bottom, animateInAsReplacement: animateAsReplacement, action: { _ in return false }) self.currentUndoController = undoController self.environment?.controller()?.present(undoController, in: .current) return } let reaction: MessageReaction.Reaction if let availableReactions = self.availableReactions, let reactionItem = availableReactions.reactions.filter({ $0.isEnabled }).first(where: { $0.selectAnimation.fileId.id == itemFile.fileId.id }) { reaction = reactionItem.value } else { reaction = .custom(itemFile.fileId.id) if let boostStatus = self.boostStatus { let enabledCustomReactions = enabledReactions.filter({ item in switch item.reaction { case .custom: return true case .builtin: return false case .stars: return false } }) let nextCustomReactionCount = enabledCustomReactions.count + 1 if nextCustomReactionCount > boostStatus.level { let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } var animateAsReplacement = false if let currentUndoController = self.currentUndoController { currentUndoController.dismiss() animateAsReplacement = true } let text = presentationData.strings.ChannelReactions_ToastLevelBoostRequiredTemplate(presentationData.strings.ChannelReactions_ToastLevelBoostRequiredTemplateLevel(Int32(nextCustomReactionCount)), presentationData.strings.ChannelReactions_ToastLevelBoostRequiredTemplateEmojiCount(Int32(nextCustomReactionCount))).string let undoController = UndoOverlayController(presentationData: presentationData, content: .customEmoji(context: component.context, file: itemFile, loop: false, title: nil, text: text, undoText: nil, customAction: nil), elevatedLayout: false, position: .bottom, animateInAsReplacement: animateAsReplacement, action: { _ in return false }) self.currentUndoController = undoController self.environment?.controller()?.present(undoController, in: .current) } } } let item = EmojiComponentReactionItem(reaction: reaction, file: itemFile) if let caretPosition = self.caretPosition, caretPosition < enabledReactions.count { enabledReactions.insert(item, at: caretPosition) self.caretPosition = caretPosition + 1 } else { enabledReactions.append(item) self.caretPosition = enabledReactions.count } self.recenterOnCaret = true } self.enabledReactions = enabledReactions if !self.isUpdating { self.state?.updated(transition: .spring(duration: 0.25)) } }, deleteBackwards: { }, openStickerSettings: { }, openFeatured: { }, openSearch: { }, addGroupAction: { _, _, _ in }, clearGroup: { _ in }, editAction: { _ 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 self.boostStatusDisposable == nil { self.boostStatusDisposable = (component.context.engine.peers.getChannelBoostStatus(peerId: component.peerId) |> deliverOnMainQueue).start(next: { [weak self] boostStatus in guard let self else { return } self.boostStatus = boostStatus 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: self.isEnabled, 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.filter({ $0.isEnabled }) { enabledReactions.append(EmojiComponentReactionItem(reaction: reactionItem.value, file: reactionItem.selectAnimation)) } } if self.areStarsReactionsEnabled { if let item = component.initialContent.availableReactions?.reactions.first(where: { $0.value == .stars }) { enabledReactions.insert(EmojiComponentReactionItem(reaction: item.value, file: item.selectAnimation), at: 0) } } self.enabledReactions = enabledReactions self.caretPosition = enabledReactions.count } } else { self.displayInput = false } self.state?.updated(transition: .immediate) } } )), 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 let switchInfoTextSize = self.switchInfoText.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: environment.strings.ChannelReactions_GeneralInfoLabel, 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 } let reactionsTitleTextSize = reactionsTitleText.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: environment.strings.ChannelReactions_ReactionsSectionTitle, 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 } let reactionInputSize = reactionInput.update( transition: animateIn ? .immediate : transition, component: AnyComponent(EmojiListInputComponent( context: component.context, theme: environment.theme, placeholder: environment.strings.ChannelReactions_InputPlaceholder, reactionItems: enabledReactions, isInputActive: self.displayInput, caretPosition: caretPosition, activateInput: { [weak self] in guard let self else { return } if self.emojiContent != nil && !self.displayInput { self.displayInput = true self.recenterOnCaret = true self.state?.updated(transition: .spring(duration: 0.5)) } }, setCaretPosition: { [weak self] value in guard let self else { return } if self.caretPosition != value { self.caretPosition = value self.state?.updated(transition: .immediate) } } )), 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 } 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: [:]) let attributes = MarkdownAttributes(body: body, bold: body, link: link, linkAttribute: { contents in return (TelegramTextAttributes.URL, contents) }) let reactionsInfoTextSize = reactionsInfoText.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .markdown(text: environment.strings.ChannelReactions_ReactionsInfoLabel, attributes: attributes), maximumNumberOfLines: 0, highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] { return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL) } else { return nil } }, tapAction: { [weak self] attributes, _ in guard let self, let component = self.component, attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] != nil else { return } self.resolveStickersBotDisposable?.dispose() self.resolveStickersBotDisposable = (component.context.engine.peers.resolvePeerByName(name: "stickers") |> mapToSignal { result -> Signal in guard case let .result(result) = result else { return .complete() } return .single(result) } |> deliverOnMainQueue).start(next: { [weak self] peer in guard let self, let component = self.component, let peer else { return } guard let navigationController = self.environment?.controller()?.navigationController as? NavigationController else { return } component.context.sharedContext.navigateToChatController(NavigateToChatControllerParams( navigationController: navigationController, context: component.context, chatLocation: .peer(peer), keepStack: .always )) }) } )), 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 contentHeight += 32.0 let reactionCountSection: ComponentView if let current = self.reactionCountSection { reactionCountSection = current } else { reactionCountSection = ComponentView() self.reactionCountSection = reactionCountSection } let reactionCountValueList = (1 ... 11).map { i -> String in return "\(i)" } let sliderTitle: String = environment.strings.PeerInfo_AllowedReactions_MaxCountValue(Int32(self.allowedReactionCount)) let reactionCountSectionSize = reactionCountSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: environment.strings.PeerInfo_AllowedReactions_MaxCountSectionTitle, font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), footer: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: environment.strings.PeerInfo_AllowedReactions_MaxCountSectionFooter, font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), items: [ AnyComponentWithIdentity(id: 0, component: AnyComponent(ListItemSliderSelectorComponent( theme: environment.theme, values: reactionCountValueList.map { item in return item }, markPositions: false, selectedIndex: max(0, min(reactionCountValueList.count - 1, self.allowedReactionCount - 1)), title: sliderTitle, selectedIndexUpdated: { [weak self] index in guard let self else { return } let index = max(1, min(reactionCountValueList.count, index + 1)) self.allowedReactionCount = index self.state?.updated(transition: .immediate) } ))) ], displaySeparators: false )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let reactionCountSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: reactionCountSectionSize) if let reactionCountSectionView = reactionCountSection.view { if reactionCountSectionView.superview == nil { self.scrollView.addSubview(reactionCountSectionView) } if animateIn { reactionCountSectionView.frame = reactionCountSectionFrame if !transition.animation.isImmediate { reactionCountSectionView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } else { transition.setFrame(view: reactionCountSectionView, frame: reactionCountSectionFrame) } } contentHeight += reactionCountSectionSize.height if component.initialContent.isStarReactionAvailable { contentHeight += 32.0 let paidReactionsSection: ComponentView if let current = self.paidReactionsSection { paidReactionsSection = current } else { paidReactionsSection = ComponentView() self.paidReactionsSection = paidReactionsSection } let parsedString = parseMarkdownIntoAttributedString(environment.strings.PeerInfo_AllowedReactions_StarReactionsFooter, attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor), bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: environment.theme.list.freeTextColor), link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemAccentColor), linkAttribute: { url in return ("URL", url) })) let paidReactionsFooterText = NSMutableAttributedString(attributedString: parsedString) if self.cachedChevronImage == nil || self.cachedChevronImage?.1 !== environment.theme { self.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Item List/InlineTextRightArrow"), color: environment.theme.list.itemAccentColor)!, environment.theme) } if let range = paidReactionsFooterText.string.range(of: ">"), let chevronImage = self.cachedChevronImage?.0 { paidReactionsFooterText.addAttribute(.attachment, value: chevronImage, range: NSRange(range, in: paidReactionsFooterText.string)) } let paidReactionsSectionSize = paidReactionsSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: nil, footer: AnyComponent(MultilineTextComponent( text: .plain(paidReactionsFooterText), maximumNumberOfLines: 0, highlightColor: environment.theme.list.itemAccentColor.withAlphaComponent(0.2), highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] { return NSAttributedString.Key(rawValue: "URL") } else { return nil } }, tapAction: { [weak self] attributes, _ in guard let self, let component = self.component else { return } if let url = attributes[NSAttributedString.Key(rawValue: "URL")] as? String { component.context.sharedContext.applicationBindings.openUrl(url) } } )), items: [ AnyComponentWithIdentity(id: 0, component: AnyComponent(ListSwitchItemComponent( theme: environment.theme, title: environment.strings.PeerInfo_AllowedReactions_StarReactions, value: self.areStarsReactionsEnabled, valueUpdated: { [weak self] value in guard let self, let component = self.component else { return } self.areStarsReactionsEnabled = value var enabledReactions = self.enabledReactions ?? [] if self.areStarsReactionsEnabled { if let item = component.initialContent.availableReactions?.reactions.first(where: { $0.value == .stars }) { enabledReactions.insert(EmojiComponentReactionItem(reaction: item.value, file: item.selectAnimation), at: 0) if let caretPosition = self.caretPosition { self.caretPosition = min(enabledReactions.count, caretPosition + 1) } } } else { if let index = enabledReactions.firstIndex(where: { $0.reaction == .stars }) { enabledReactions.remove(at: index) if let caretPosition = self.caretPosition, caretPosition > index { self.caretPosition = max(0, caretPosition - 1) } } } self.enabledReactions = enabledReactions if !self.isUpdating { self.state?.updated(transition: .spring(duration: 0.25)) } } ))) ] )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let paidReactionsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: paidReactionsSectionSize) if let paidReactionsSectionView = paidReactionsSection.view { if paidReactionsSectionView.superview == nil { self.scrollView.addSubview(paidReactionsSectionView) } if animateIn { paidReactionsSectionView.frame = paidReactionsSectionFrame if !transition.animation.isImmediate { paidReactionsSectionView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } else { transition.setFrame(view: paidReactionsSectionView, frame: paidReactionsSectionFrame) } } contentHeight += paidReactionsSectionSize.height contentHeight += 12.0 } else { contentHeight += 12.0 if let paidReactionsSection = self.paidReactionsSection { self.paidReactionsSection = nil if let paidReactionsSectionView = paidReactionsSection.view { if !transition.animation.isImmediate { paidReactionsSectionView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak paidReactionsSectionView] _ in paidReactionsSectionView?.removeFromSuperview() }) } else { paidReactionsSectionView.removeFromSuperview() } } } } } 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() } } } if let reactionCountSection = self.reactionCountSection { self.reactionCountSection = nil if let reactionCountSectionView = reactionCountSection.view { if !transition.animation.isImmediate { reactionCountSectionView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak reactionCountSectionView] _ in reactionCountSectionView?.removeFromSuperview() }) } else { reactionCountSectionView.removeFromSuperview() } } } if let paidReactionsSection = self.paidReactionsSection { self.paidReactionsSection = nil if let paidReactionsSectionView = paidReactionsSection.view { if !transition.animation.isImmediate { paidReactionsSectionView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak paidReactionsSectionView] _ in paidReactionsSectionView?.removeFromSuperview() }) } else { paidReactionsSectionView.removeFromSuperview() } } } } var buttonContents: [AnyComponentWithIdentity] = [] buttonContents.append(AnyComponentWithIdentity(id: AnyHashable(0 as Int), component: AnyComponent( Text(text: environment.strings.ChannelReactions_SaveAction, font: Font.semibold(17.0), color: environment.theme.list.itemCheckColors.foregroundColor) ))) let customReactionCount = self.isEnabled ? enabledReactions.filter({ item in switch item.reaction { case .custom: return true case .builtin: return false case .stars: return false } }).count : 0 if let boostStatus = self.boostStatus, customReactionCount > boostStatus.level { buttonContents.append(AnyComponentWithIdentity(id: AnyHashable(1 as Int), component: AnyComponent(PremiumLockButtonSubtitleComponent( count: customReactionCount, theme: environment.theme, strings: environment.strings )))) } 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( VStack(buttonContents, spacing: 3.0) )), isEnabled: true, tintWhenDisabled: false, displaysProgress: self.isApplyingSettings, action: { [weak self] in guard let self else { return } self.applySettings(standalone: false) } )), 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))), stickerContent: nil, backgroundIconColor: nil, backgroundColor: environment.theme.list.itemBlocksBackgroundColor, separatorColor: environment.theme.list.itemBlocksSeparatorColor, backspace: enabledReactions.isEmpty ? nil : { [weak self] in guard let self, var enabledReactions = self.enabledReactions, !enabledReactions.isEmpty else { return } if let caretPosition = self.caretPosition, caretPosition < enabledReactions.count { if caretPosition > 0 { enabledReactions.remove(at: caretPosition - 1) self.caretPosition = caretPosition - 1 self.recenterOnCaret = true } } else { enabledReactions.removeLast() self.caretPosition = enabledReactions.count self.recenterOnCaret = true } self.enabledReactions = enabledReactions if !enabledReactions.contains(where: { $0.reaction == .stars }) { self.areStarsReactionsEnabled = false } if !self.isUpdating { self.state?.updated(transition: .spring(duration: 0.25)) } } )), environment: {}, containerSize: CGSize(width: availableSize.width, height: availableSize.height) ) 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) transition.setAlpha(view: buttonView, alpha: self.isEnabled ? 1.0 : 0.0) } 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 } if self.recenterOnCaret { self.recenterOnCaret = false if let reactionInputView = self.reactionInput?.view as? EmojiListInputComponent.View, let localCaretRect = reactionInputView.caretRect() { let caretRect = reactionInputView.convert(localCaretRect, to: self.scrollView) var scrollViewBounds = self.scrollView.bounds let minButtonDistance: CGFloat = 16.0 if -scrollViewBounds.minY + caretRect.maxY > buttonFrame.minY - minButtonDistance { scrollViewBounds.origin.y = -(buttonFrame.minY - minButtonDistance - caretRect.maxY) if scrollViewBounds.origin.y < 0.0 { scrollViewBounds.origin.y = 0.0 } } if self.scrollView.bounds != scrollViewBounds { transition.setBounds(view: self.scrollView, bounds: scrollViewBounds) } } } self.updateScrolling(transition: transition) return availableSize } } func makeView() -> View { return View() } 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) } } public class PeerAllowedReactionsScreen: ViewControllerComponentContainer { public final class Content: Equatable { public let isEnabled: Bool public let enabledReactions: [EmojiComponentReactionItem] public let availableReactions: AvailableReactions? public let reactionSettings: PeerReactionSettings? public let isStarReactionAvailable: Bool init( isEnabled: Bool, enabledReactions: [EmojiComponentReactionItem], availableReactions: AvailableReactions?, reactionSettings: PeerReactionSettings?, isStarReactionAvailable: Bool ) { self.isEnabled = isEnabled self.enabledReactions = enabledReactions self.availableReactions = availableReactions self.reactionSettings = reactionSettings self.isStarReactionAvailable = isStarReactionAvailable } 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 } if lhs.reactionSettings != rhs.reactionSettings { return false } if lhs.isStarReactionAvailable != rhs.isStarReactionAvailable { 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.title = context.sharedContext.currentPresentationData.with({ $0 }).strings.ChannelReactions_Reactions self.scrollToTop = { [weak self] in guard let self, let componentView = self.node.hostView.componentView as? PeerAllowedReactionsScreenComponent.View else { return } componentView.scrollToTop() } self.attemptNavigation = { [weak self] complete in guard let self, let componentView = self.node.hostView.componentView as? PeerAllowedReactionsScreenComponent.View else { return true } return componentView.attemptNavigation(complete: complete) } } 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 let reactionSettings = cachedData.reactionSettings.knownValue if let reactionSettings { switch reactionSettings.allowedReactions { case .all: isEnabled = true if let availableReactions { reactions = availableReactions.reactions.filter({ $0.isEnabled }).map(\.value) } case let .limited(list): isEnabled = true reactions.append(contentsOf: list) case .empty: isEnabled = false } if let starsAllowed = reactionSettings.starsAllowed, starsAllowed { isEnabled = true } } var missingReactionFiles: [Int64] = [] for reaction in reactions { if let availableReactions, let _ = availableReactions.reactions.filter({ $0.isEnabled }).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.filter({ $0.isEnabled }).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, reactionSettings: reactionSettings, isStarReactionAvailable: cachedData.flags.contains(.paidMediaAllowed)) } } |> distinctUntilChanged } }