import Foundation import UIKit import Display import AccountContext import TelegramCore import Postbox import SwiftSignalKit import TelegramPresentationData import ComponentFlow import ComponentDisplayAdapters import AppBundle import ViewControllerComponent import EntityKeyboard import MultilineTextComponent import UndoUI import BundleIconComponent import AnimatedTextComponent import AudioToolbox import ListSectionComponent import PeerAllowedReactionsScreen import AttachmentUI import ListMultilineTextFieldItemComponent import ListActionItemComponent import ChatEntityKeyboardInputNode import ChatPresentationInterfaceState import EmojiSuggestionsComponent import TextFormat import TextFieldComponent final class ComposePollScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let context: AccountContext let peer: EnginePeer let isQuiz: Bool? let completion: (ComposedPoll) -> Void init( context: AccountContext, peer: EnginePeer, isQuiz: Bool?, completion: @escaping (ComposedPoll) -> Void ) { self.context = context self.peer = peer self.isQuiz = isQuiz self.completion = completion } static func ==(lhs: ComposePollScreenComponent, rhs: ComposePollScreenComponent) -> Bool { return true } private final class PollOption { let id: Int let textInputState = TextFieldComponent.ExternalState() let textFieldTag = NSObject() var resetText: String? init(id: Int) { self.id = id } } final class View: UIView, UIScrollViewDelegate { private let scrollView: UIScrollView private var reactionInput: ComponentView? private let pollTextSection = ComponentView() private let quizAnswerSection = ComponentView() private let pollOptionsSectionHeader = ComponentView() private let pollOptionsSectionFooter = ComponentView() private var pollOptionsSectionContainer: ListSectionContentView private let pollSettingsSection = ComponentView() private let actionButton = ComponentView() private var reactionSelectionControl: ComponentView? private var isUpdating: Bool = false private var component: ComposePollScreenComponent? private(set) weak var state: EmptyComponentState? private var environment: EnvironmentType? private let pollTextInputState = TextFieldComponent.ExternalState() private let pollTextFieldTag = NSObject() private var resetPollText: String? private var quizAnswerTextInputState = ListMultilineTextFieldItemComponent.ExternalState() private var resetQuizAnswerText: String? private var nextPollOptionId: Int = 0 private var pollOptions: [PollOption] = [] private var isAnonymous: Bool = true private var isMultiAnswer: Bool = false private var isQuiz: Bool = false private var selectedQuizOptionId: Int? private var currentInputMode: ListComposePollOptionComponent.InputMode = .keyboard private var inputMediaNodeData: ChatEntityKeyboardInputNode.InputData? private var inputMediaNodeDataDisposable: Disposable? private var inputMediaNodeStateContext = ChatEntityKeyboardInputNode.StateContext() private var inputMediaInteraction: ChatEntityKeyboardInputNode.Interaction? private var inputMediaNode: ChatEntityKeyboardInputNode? private var inputMediaNodeBackground = SimpleLayer() private let inputMediaNodeDataPromise = Promise() private var currentEmojiSuggestionView: ComponentHostView? 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 self.pollOptionsSectionContainer = ListSectionContentView(frame: CGRect()) 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.inputMediaNodeDataDisposable?.dispose() } func scrollToTop() { self.scrollView.setContentOffset(CGPoint(), animated: true) } func validatedInput() -> ComposedPoll? { if self.pollTextInputState.text.length == 0 { return nil } let mappedKind: TelegramMediaPollKind if self.isQuiz { mappedKind = .quiz } else { mappedKind = .poll(multipleAnswers: self.isMultiAnswer) } var mappedOptions: [TelegramMediaPollOption] = [] var selectedQuizOption: Data? for pollOption in self.pollOptions { if pollOption.textInputState.text.length == 0 { continue } let optionData = "\(mappedOptions.count)".data(using: .utf8)! if self.selectedQuizOptionId == pollOption.id { selectedQuizOption = optionData } var entities: [MessageTextEntity] = [] for entity in generateChatInputTextEntities(pollOption.textInputState.text) { switch entity.type { case .CustomEmoji: entities.append(entity) default: break } } mappedOptions.append(TelegramMediaPollOption( text: pollOption.textInputState.text.string, entities: entities, opaqueIdentifier: optionData )) } if mappedOptions.count < 2 { return nil } var mappedCorrectAnswers: [Data]? if self.isQuiz { if let selectedQuizOption { mappedCorrectAnswers = [selectedQuizOption] } else { return nil } } var mappedSolution: String? if self.isQuiz && self.quizAnswerTextInputState.text.length != 0 { mappedSolution = self.quizAnswerTextInputState.text.string } var textEntities: [MessageTextEntity] = [] for entity in generateChatInputTextEntities(self.pollTextInputState.text) { switch entity.type { case .CustomEmoji: textEntities.append(entity) default: break } } return ComposedPoll( publicity: self.isAnonymous ? .anonymous : .public, kind: mappedKind, text: ComposedPoll.Text(string: self.pollTextInputState.text.string, entities: textEntities), options: mappedOptions, correctAnswers: mappedCorrectAnswers, results: TelegramMediaPollResults( voters: nil, totalVoters: nil, recentVoters: [], solution: mappedSolution.flatMap { mappedSolution in return TelegramMediaPollResults.Solution(text: mappedSolution, entities: []) } ), deadlineTimeout: nil ) } func attemptNavigation(complete: @escaping () -> Void) -> Bool { guard let component = self.component else { return true } let _ = component return 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) } } func isPanGestureEnabled() -> Bool { if self.inputMediaNode != nil { return false } for (_, state) in self.collectTextInputStates() { if state.isEditing { return false } } return true } private func updateInputMediaNode( component: ComposePollScreenComponent, availableSize: CGSize, bottomInset: CGFloat, inputHeight: CGFloat, effectiveInputHeight: CGFloat, metrics: LayoutMetrics, deviceMetrics: DeviceMetrics, transition: Transition ) -> CGFloat { let bottomInset: CGFloat = bottomInset + 8.0 let bottomContainerInset: CGFloat = 0.0 let needsInputActivation: Bool = !"".isEmpty var height: CGFloat = 0.0 if case .emoji = self.currentInputMode, let inputData = self.inputMediaNodeData { let inputMediaNode: ChatEntityKeyboardInputNode var inputMediaNodeTransition = transition var animateIn = false if let current = self.inputMediaNode { inputMediaNode = current } else { animateIn = true inputMediaNodeTransition = inputMediaNodeTransition.withAnimation(.none) inputMediaNode = ChatEntityKeyboardInputNode( context: component.context, currentInputData: inputData, updatedInputData: self.inputMediaNodeDataPromise.get(), defaultToEmojiTab: true, opaqueTopPanelBackground: false, interaction: self.inputMediaInteraction, chatPeerId: nil, stateContext: self.inputMediaNodeStateContext ) inputMediaNode.clipsToBounds = true inputMediaNode.externalTopPanelContainerImpl = nil inputMediaNode.useExternalSearchContainer = true if inputMediaNode.view.superview == nil { self.inputMediaNodeBackground.removeAllAnimations() self.layer.addSublayer(self.inputMediaNodeBackground) self.addSubview(inputMediaNode.view) } self.inputMediaNode = inputMediaNode } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } let presentationInterfaceState = ChatPresentationInterfaceState( chatWallpaper: .builtin(WallpaperSettings()), theme: presentationData.theme, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, limitsConfiguration: component.context.currentLimitsConfiguration.with { $0 }, fontSize: presentationData.chatFontSize, bubbleCorners: presentationData.chatBubbleCorners, accountPeerId: component.context.account.peerId, mode: .standard(.default), chatLocation: .peer(id: component.context.account.peerId), subject: nil, peerNearbyData: nil, greetingData: nil, pendingUnpinnedAllMessages: false, activeGroupCallInfo: nil, hasActiveGroupCall: false, importState: nil, threadData: nil, isGeneralThreadClosed: nil, replyMessage: nil, accountPeerColor: nil, businessIntro: nil ) self.inputMediaNodeBackground.backgroundColor = presentationData.theme.rootController.navigationBar.opaqueBackgroundColor.cgColor let heightAndOverflow = inputMediaNode.updateLayout(width: availableSize.width, leftInset: 0.0, rightInset: 0.0, bottomInset: bottomInset, standardInputHeight: deviceMetrics.standardInputHeight(inLandscape: false), inputHeight: inputHeight < 100.0 ? inputHeight - bottomContainerInset : inputHeight, maximumHeight: availableSize.height, inputPanelHeight: 0.0, transition: .immediate, interfaceState: presentationInterfaceState, layoutMetrics: metrics, deviceMetrics: deviceMetrics, isVisible: true, isExpanded: false) let inputNodeHeight = heightAndOverflow.0 let inputNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - inputNodeHeight), size: CGSize(width: availableSize.width, height: inputNodeHeight)) let inputNodeBackgroundFrame = CGRect(origin: CGPoint(x: inputNodeFrame.minX, y: inputNodeFrame.minY - 6.0), size: CGSize(width: inputNodeFrame.width, height: inputNodeFrame.height + 6.0)) if needsInputActivation { let inputNodeFrame = inputNodeFrame.offsetBy(dx: 0.0, dy: inputNodeHeight) Transition.immediate.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) Transition.immediate.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) } if animateIn { var targetFrame = inputMediaNode.frame targetFrame.origin.y = availableSize.height inputMediaNodeTransition.setFrame(layer: inputMediaNode.layer, frame: targetFrame) let inputNodeBackgroundTargetFrame = CGRect(origin: CGPoint(x: targetFrame.minX, y: targetFrame.minY - 6.0), size: CGSize(width: targetFrame.width, height: targetFrame.height + 6.0)) inputMediaNodeTransition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundTargetFrame) transition.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) transition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) } else { inputMediaNodeTransition.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) inputMediaNodeTransition.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame) } height = heightAndOverflow.0 } else if let inputMediaNode = self.inputMediaNode { self.inputMediaNode = nil var targetFrame = inputMediaNode.frame targetFrame.origin.y = availableSize.height transition.setFrame(view: inputMediaNode.view, frame: targetFrame, completion: { [weak inputMediaNode] _ in if let inputMediaNode { Queue.mainQueue().after(0.3) { inputMediaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false, completion: { [weak inputMediaNode] _ in inputMediaNode?.view.removeFromSuperview() }) } } }) transition.setFrame(layer: self.inputMediaNodeBackground, frame: targetFrame, completion: { [weak self] _ in Queue.mainQueue().after(0.3) { guard let self else { return } if self.currentInputMode == .keyboard { self.inputMediaNodeBackground.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false, completion: { [weak self] finished in guard let self else { return } if finished { self.inputMediaNodeBackground.removeFromSuperlayer() } self.inputMediaNodeBackground.removeAllAnimations() }) } } }) } /*if needsInputActivation { needsInputActivation = false Queue.mainQueue().justDispatch { inputPanelView.activateInput() } }*/ /*if let controller = self.environment?.controller() as? ComposePollScreen { controller.updateTabBarAlpha(self.inputMediaNode == nil ? 1.0 : 0.0, transition.containedViewLayoutTransition) }*/ return height } private func collectTextInputStates() -> [(view: ListComposePollOptionComponent.View, state: TextFieldComponent.ExternalState)] { var textInputStates: [(view: ListComposePollOptionComponent.View, state: TextFieldComponent.ExternalState)] = [] if let textInputView = self.pollTextSection.findTaggedView(tag: self.pollTextFieldTag) as? ListComposePollOptionComponent.View { textInputStates.append((textInputView, self.pollTextInputState)) } for pollOption in self.pollOptions { if let textInputView = findTaggedComponentViewImpl(view: self.pollOptionsSectionContainer, tag: pollOption.textFieldTag) as? ListComposePollOptionComponent.View { textInputStates.append((textInputView, pollOption.textInputState)) } } return textInputStates } func update(component: ComposePollScreenComponent, 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 if self.component == nil { self.pollOptions.append(ComposePollScreenComponent.PollOption( id: self.nextPollOptionId )) self.nextPollOptionId += 1 self.pollOptions.append(ComposePollScreenComponent.PollOption( id: self.nextPollOptionId )) self.nextPollOptionId += 1 self.inputMediaNodeDataPromise.set( ChatEntityKeyboardInputNode.inputData( context: component.context, chatPeerId: nil, areCustomEmojiEnabled: true, hasTrending: false, hasSearch: true, hasStickers: false, hasGifs: false, hideBackground: true, sendGif: nil ) ) self.inputMediaNodeDataDisposable = (self.inputMediaNodeDataPromise.get() |> deliverOnMainQueue).start(next: { [weak self] value in guard let self else { return } self.inputMediaNodeData = value }) self.inputMediaInteraction = ChatEntityKeyboardInputNode.Interaction( sendSticker: { _, _, _, _, _, _, _, _, _ in return false }, sendEmoji: { _, _, _ in let _ = self }, sendGif: { _, _, _, _, _ in return false }, sendBotContextResultAsGif: { _, _ , _, _, _, _ in return false }, updateChoosingSticker: { _ in }, switchToTextInput: { [weak self] in guard let self else { return } self.currentInputMode = .keyboard self.state?.updated(transition: .immediate) }, dismissTextInput: { }, insertText: { [weak self] text in guard let self else { return } for (textInputView, externalState) in self.collectTextInputStates() { if externalState.isEditing { textInputView.insertText(text: text) break } } }, backwardsDeleteText: { [weak self] in guard let self else { return } for (textInputView, externalState) in self.collectTextInputStates() { if externalState.isEditing { textInputView.backwardsDeleteText() break } } }, openStickerEditor: { }, presentController: { [weak self] c, a in guard let self else { return } self.environment?.controller()?.present(c, in: .window(.root), with: a) }, presentGlobalOverlayController: { [weak self] c, a in guard let self else { return } self.environment?.controller()?.presentInGlobalOverlay(c, with: a) }, getNavigationController: { [weak self] in guard let self else { return nil } return self.environment?.controller()?.navigationController as? NavigationController }, requestLayout: { [weak self] transition in guard let self else { return } if !self.isUpdating { self.state?.updated(transition: Transition(transition)) } } ) } 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 sectionSpacing: CGFloat = 24.0 if themeUpdated { self.backgroundColor = environment.theme.list.blocksBackgroundColor } let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } var contentHeight: CGFloat = 0.0 contentHeight += environment.navigationHeight contentHeight += topInset var pollTextSectionItems: [AnyComponentWithIdentity] = [] pollTextSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent( externalState: self.pollTextInputState, context: component.context, theme: environment.theme, strings: environment.strings, resetText: self.resetPollText.flatMap { resetText in return ListComposePollOptionComponent.ResetText(value: resetText) }, characterLimit: 256, returnKeyAction: { [weak self] in guard let self else { return } if !self.pollOptions.isEmpty { if let pollOptionView = self.pollOptionsSectionContainer.itemViews[self.pollOptions[0].id] { if let pollOptionComponentView = pollOptionView.contents.view as? ListComposePollOptionComponent.View { pollOptionComponentView.activateInput() } } } }, backspaceKeyAction: nil, selection: nil, inputMode: self.currentInputMode, toggleInputMode: { [weak self] in guard let self else { return } switch self.currentInputMode { case .keyboard: self.currentInputMode = .emoji case .emoji: self.currentInputMode = .keyboard } self.state?.updated(transition: .spring(duration: 0.4)) }, tag: self.pollTextFieldTag )))) self.resetPollText = nil //TODO:localize let pollTextSectionSize = self.pollTextSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "QUESTION", font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), footer: nil, items: pollTextSectionItems )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let pollTextSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: pollTextSectionSize) if let pollTextSectionView = self.pollTextSection.view as? ListSectionComponent.View { if pollTextSectionView.superview == nil { self.scrollView.addSubview(pollTextSectionView) self.pollTextSection.parentState = state } transition.setFrame(view: pollTextSectionView, frame: pollTextSectionFrame) if let itemView = pollTextSectionView.itemView(id: 0) as? ListComposePollOptionComponent.View { itemView.updateCustomPlaceholder(value: "Ask a Question", size: itemView.bounds.size, transition: .immediate) } } contentHeight += pollTextSectionSize.height contentHeight += sectionSpacing var pollOptionsSectionItems: [AnyComponentWithIdentity] = [] var pollOptionsSectionReadyItems: [ListSectionContentView.ReadyItem] = [] let processPollOptionItem: (Int) -> Void = { i in let pollOption = self.pollOptions[i] let optionId = pollOption.id var optionSelection: ListComposePollOptionComponent.Selection? if self.isQuiz { optionSelection = ListComposePollOptionComponent.Selection(isSelected: self.selectedQuizOptionId == optionId, toggle: { [weak self] in guard let self else { return } self.selectedQuizOptionId = optionId self.state?.updated(transition: .spring(duration: 0.35)) }) } pollOptionsSectionItems.append(AnyComponentWithIdentity(id: pollOption.id, component: AnyComponent(ListComposePollOptionComponent( externalState: pollOption.textInputState, context: component.context, theme: environment.theme, strings: environment.strings, resetText: pollOption.resetText.flatMap { resetText in return ListComposePollOptionComponent.ResetText(value: resetText) }, characterLimit: 256, returnKeyAction: { [weak self] in guard let self else { return } if let index = self.pollOptions.firstIndex(where: { $0.id == optionId }) { if index == self.pollOptions.count - 1 { self.endEditing(true) } else { if let pollOptionView = self.pollOptionsSectionContainer.itemViews[self.pollOptions[index + 1].id] { if let pollOptionComponentView = pollOptionView.contents.view as? ListComposePollOptionComponent.View { pollOptionComponentView.activateInput() } } } } }, backspaceKeyAction: { [weak self] in guard let self else { return } if let index = self.pollOptions.firstIndex(where: { $0.id == optionId }) { if index != 0 { if let pollOptionView = self.pollOptionsSectionContainer.itemViews[self.pollOptions[index - 1].id] { if let pollOptionComponentView = pollOptionView.contents.view as? ListComposePollOptionComponent.View { pollOptionComponentView.activateInput() } } } } }, selection: optionSelection, inputMode: self.currentInputMode, toggleInputMode: { [weak self] in guard let self else { return } switch self.currentInputMode { case .keyboard: self.currentInputMode = .emoji case .emoji: self.currentInputMode = .keyboard } self.state?.updated(transition: .spring(duration: 0.4)) }, tag: pollOption.textFieldTag )))) let item = pollOptionsSectionItems[i] let itemId = item.id let itemView: ListSectionContentView.ItemView var itemTransition = transition if let current = self.pollOptionsSectionContainer.itemViews[itemId] { itemView = current } else { itemTransition = itemTransition.withAnimation(.none) itemView = ListSectionContentView.ItemView() self.pollOptionsSectionContainer.itemViews[itemId] = itemView itemView.contents.parentState = state } let itemSize = itemView.contents.update( transition: itemTransition, component: item.component, environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: availableSize.height) ) pollOptionsSectionReadyItems.append(ListSectionContentView.ReadyItem( id: itemId, itemView: itemView, size: itemSize, transition: itemTransition )) } for i in 0 ..< self.pollOptions.count { processPollOptionItem(i) } if self.pollOptions.count > 2 { let lastOption = self.pollOptions[self.pollOptions.count - 1] let secondToLastOption = self.pollOptions[self.pollOptions.count - 2] if !lastOption.textInputState.isEditing && lastOption.textInputState.text.length == 0 && secondToLastOption.textInputState.text.length == 0 { self.pollOptions.removeLast() pollOptionsSectionItems.removeLast() pollOptionsSectionReadyItems.removeLast() } } if self.pollOptions.count < 10, let lastOption = self.pollOptions.last { if lastOption.textInputState.text.length != 0 { self.pollOptions.append(PollOption(id: self.nextPollOptionId)) self.nextPollOptionId += 1 processPollOptionItem(self.pollOptions.count - 1) } } for i in 0 ..< pollOptionsSectionReadyItems.count { let placeholder: String if i == pollOptionsSectionReadyItems.count - 1 { placeholder = "Add an Option" } else { placeholder = "Option" } if let itemView = pollOptionsSectionReadyItems[i].itemView.contents.view as? ListComposePollOptionComponent.View { itemView.updateCustomPlaceholder(value: placeholder, size: pollOptionsSectionReadyItems[i].size, transition: pollOptionsSectionReadyItems[i].transition) } } let pollOptionsSectionUpdateResult = self.pollOptionsSectionContainer.update( configuration: ListSectionContentView.Configuration( theme: environment.theme, displaySeparators: true, extendsItemHighlightToSection: false, background: .all ), width: availableSize.width - sideInset * 2.0, leftInset: 0.0, readyItems: pollOptionsSectionReadyItems, transition: transition ) let sectionHeaderSideInset: CGFloat = 16.0 let pollOptionsSectionHeaderSize = self.pollOptionsSectionHeader.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "POLL OPTIONS", font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - sectionHeaderSideInset * 2.0, height: 1000.0) ) let pollOptionsSectionHeaderFrame = CGRect(origin: CGPoint(x: sideInset + sectionHeaderSideInset, y: contentHeight), size: pollOptionsSectionHeaderSize) if let pollOptionsSectionHeaderView = self.pollOptionsSectionHeader.view { if pollOptionsSectionHeaderView.superview == nil { pollOptionsSectionHeaderView.layer.anchorPoint = CGPoint() self.scrollView.addSubview(pollOptionsSectionHeaderView) } transition.setPosition(view: pollOptionsSectionHeaderView, position: pollOptionsSectionHeaderFrame.origin) pollOptionsSectionHeaderView.bounds = CGRect(origin: CGPoint(), size: pollOptionsSectionHeaderFrame.size) } contentHeight += pollOptionsSectionHeaderSize.height contentHeight += 7.0 let pollOptionsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: pollOptionsSectionUpdateResult.size) if self.pollOptionsSectionContainer.superview == nil { self.scrollView.addSubview(self.pollOptionsSectionContainer.externalContentBackgroundView) self.scrollView.addSubview(self.pollOptionsSectionContainer) } transition.setFrame(view: self.pollOptionsSectionContainer, frame: pollOptionsSectionFrame) transition.setFrame(view: self.pollOptionsSectionContainer.externalContentBackgroundView, frame: pollOptionsSectionUpdateResult.backgroundFrame.offsetBy(dx: pollOptionsSectionFrame.minX, dy: pollOptionsSectionFrame.minY)) contentHeight += pollOptionsSectionUpdateResult.size.height contentHeight += 7.0 var pollOptionsFooterItems: [AnimatedTextComponent.Item] = [] if self.pollOptions.count >= 10, !"".isEmpty { pollOptionsFooterItems.append(AnimatedTextComponent.Item( id: 3, isUnbreakable: true, content: .text("You have added the maximum number of options.") )) } else { pollOptionsFooterItems.append(AnimatedTextComponent.Item( id: 0, isUnbreakable: true, content: .text("You can add ") )) pollOptionsFooterItems.append(AnimatedTextComponent.Item( id: 1, isUnbreakable: true, content: .number(10 - self.pollOptions.count, minDigits: 1) )) pollOptionsFooterItems.append(AnimatedTextComponent.Item( id: 2, isUnbreakable: true, content: .text(" more options.") )) } let pollOptionsSectionFooterSize = self.pollOptionsSectionFooter.update( transition: transition, component: AnyComponent(AnimatedTextComponent( font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), color: environment.theme.list.freeTextColor, items: pollOptionsFooterItems )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - sectionHeaderSideInset * 2.0, height: 1000.0) ) let pollOptionsSectionFooterFrame = CGRect(origin: CGPoint(x: sideInset + sectionHeaderSideInset, y: contentHeight), size: pollOptionsSectionFooterSize) if let pollOptionsSectionFooterView = self.pollOptionsSectionFooter.view { if pollOptionsSectionFooterView.superview == nil { pollOptionsSectionFooterView.layer.anchorPoint = CGPoint() self.scrollView.addSubview(pollOptionsSectionFooterView) } transition.setPosition(view: pollOptionsSectionFooterView, position: pollOptionsSectionFooterFrame.origin) pollOptionsSectionFooterView.bounds = CGRect(origin: CGPoint(), size: pollOptionsSectionFooterFrame.size) } contentHeight += pollOptionsSectionFooterSize.height contentHeight += sectionSpacing var pollSettingsSectionItems: [AnyComponentWithIdentity] = [] pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "anonymous", component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "Anonymous Voting", font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemPrimaryTextColor )), maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isAnonymous, action: { [weak self] _ in guard let self else { return } self.isAnonymous = !self.isAnonymous self.state?.updated(transition: .spring(duration: 0.4)) })), action: nil )))) pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "multiAnswer", component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "Multiple Answers", font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemPrimaryTextColor )), maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isMultiAnswer, action: { [weak self] _ in guard let self else { return } self.isMultiAnswer = !self.isMultiAnswer if self.isMultiAnswer { self.isQuiz = false } self.state?.updated(transition: .spring(duration: 0.4)) })), action: nil )))) pollSettingsSectionItems.append(AnyComponentWithIdentity(id: "quiz", component: AnyComponent(ListActionItemComponent( theme: environment.theme, title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "Quiz Mode", font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemPrimaryTextColor )), maximumNumberOfLines: 1 ))), ], alignment: .left, spacing: 2.0)), accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isQuiz, action: { [weak self] _ in guard let self else { return } self.isQuiz = !self.isQuiz if self.isQuiz { self.isMultiAnswer = false } self.state?.updated(transition: .spring(duration: 0.4)) })), action: nil )))) let pollSettingsSectionSize = self.pollSettingsSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: nil, footer: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "Polls in Quiz Mode have one correct answer. Users can't revoke their answers.", font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), items: pollSettingsSectionItems )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let pollSettingsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: pollSettingsSectionSize) if let pollSettingsSectionView = self.pollSettingsSection.view { if pollSettingsSectionView.superview == nil { self.scrollView.addSubview(pollSettingsSectionView) self.pollSettingsSection.parentState = state } transition.setFrame(view: pollSettingsSectionView, frame: pollSettingsSectionFrame) } contentHeight += pollSettingsSectionSize.height var quizAnswerSectionHeight: CGFloat = 0.0 quizAnswerSectionHeight += sectionSpacing let quizAnswerSectionSize = self.quizAnswerSection.update( transition: transition, component: AnyComponent(ListSectionComponent( theme: environment.theme, header: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "EXPLANATION", font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), footer: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( string: "Users will see this comment after choosing a wrong answer, good for educational purposes.", font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), textColor: environment.theme.list.freeTextColor )), maximumNumberOfLines: 0 )), items: [ AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent( externalState: self.quizAnswerTextInputState, context: component.context, theme: environment.theme, strings: environment.strings, initialText: "", resetText: self.resetQuizAnswerText.flatMap { resetQuizAnswerText in return ListMultilineTextFieldItemComponent.ResetText(value: resetQuizAnswerText) }, placeholder: "Add a Comment (Optional)", autocapitalizationType: .none, autocorrectionType: .no, characterLimit: 256, emptyLineHandling: .oneConsecutive, updated: { _ in }, textUpdateTransition: .spring(duration: 0.4) ))) ] )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0) ) let quizAnswerSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + quizAnswerSectionHeight), size: quizAnswerSectionSize) if let quizAnswerSectionView = self.quizAnswerSection.view { if quizAnswerSectionView.superview == nil { self.scrollView.addSubview(quizAnswerSectionView) self.quizAnswerSection.parentState = state } transition.setFrame(view: quizAnswerSectionView, frame: quizAnswerSectionFrame) transition.setAlpha(view: quizAnswerSectionView, alpha: self.isQuiz ? 1.0 : 0.0) } quizAnswerSectionHeight += pollTextSectionSize.height if self.isQuiz { contentHeight += quizAnswerSectionHeight } var inputHeight: CGFloat = 0.0 inputHeight += self.updateInputMediaNode( component: component, availableSize: availableSize, bottomInset: environment.safeInsets.bottom, inputHeight: 0.0, effectiveInputHeight: environment.deviceMetrics.standardInputHeight(inLandscape: false), metrics: environment.metrics, deviceMetrics: environment.deviceMetrics, transition: transition ) let textInputStates = self.collectTextInputStates() let isEditing = textInputStates.contains(where: { $0.state.isEditing }) if let (_, suggestionTextInputState) = textInputStates.first(where: { $0.state.isEditing && $0.state.currentEmojiSuggestion != nil }), let emojiSuggestion = suggestionTextInputState.currentEmojiSuggestion, emojiSuggestion.disposable == nil { emojiSuggestion.disposable = (EmojiSuggestionsComponent.suggestionData(context: component.context, isSavedMessages: false, query: emojiSuggestion.position.value) |> deliverOnMainQueue).start(next: { [weak self, weak suggestionTextInputState, weak emojiSuggestion] result in guard let self, let suggestionTextInputState, let emojiSuggestion, suggestionTextInputState.currentEmojiSuggestion === emojiSuggestion else { return } emojiSuggestion.value = result self.state?.updated() }) } for (_, suggestionTextInputState) in textInputStates { var hasTrackingView = suggestionTextInputState.hasTrackingView if let currentEmojiSuggestion = suggestionTextInputState.currentEmojiSuggestion, let value = currentEmojiSuggestion.value as? [TelegramMediaFile], value.isEmpty { hasTrackingView = false } if !suggestionTextInputState.isEditing { hasTrackingView = false } if !hasTrackingView { if let currentEmojiSuggestion = suggestionTextInputState.currentEmojiSuggestion { suggestionTextInputState.currentEmojiSuggestion = nil currentEmojiSuggestion.disposable?.dispose() } if let currentEmojiSuggestionView = self.currentEmojiSuggestionView { self.currentEmojiSuggestionView = nil currentEmojiSuggestionView.alpha = 0.0 currentEmojiSuggestionView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak currentEmojiSuggestionView] _ in currentEmojiSuggestionView?.removeFromSuperview() }) } } } if let (suggestionTextInputView, suggestionTextInputState) = textInputStates.first(where: { $0.state.isEditing && $0.state.currentEmojiSuggestion != nil }), let emojiSuggestion = suggestionTextInputState.currentEmojiSuggestion, let value = emojiSuggestion.value as? [TelegramMediaFile] { let currentEmojiSuggestionView: ComponentHostView if let current = self.currentEmojiSuggestionView { currentEmojiSuggestionView = current } else { currentEmojiSuggestionView = ComponentHostView() self.currentEmojiSuggestionView = currentEmojiSuggestionView self.addSubview(currentEmojiSuggestionView) currentEmojiSuggestionView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } let globalPosition: CGPoint if let textView = suggestionTextInputView.textFieldView { globalPosition = textView.convert(emojiSuggestion.localPosition, to: self) } else { globalPosition = .zero } let sideInset: CGFloat = 7.0 let viewSize = currentEmojiSuggestionView.update( transition: .immediate, component: AnyComponent(EmojiSuggestionsComponent( context: component.context, userLocation: .other, theme: EmojiSuggestionsComponent.Theme(theme: environment.theme), animationCache: component.context.animationCache, animationRenderer: component.context.animationRenderer, files: value, action: { [weak self, weak suggestionTextInputView, weak suggestionTextInputState] file in guard let self, let suggestionTextInputView, let suggestionTextInputState, let textView = suggestionTextInputView.textFieldView, let currentEmojiSuggestion = suggestionTextInputState.currentEmojiSuggestion else { return } let _ = self AudioServicesPlaySystemSound(0x450) let inputState = textView.getInputState() let inputText = NSMutableAttributedString(attributedString: inputState.inputText) var text: String? var emojiAttribute: ChatTextInputTextCustomEmojiAttribute? loop: for attribute in file.attributes { switch attribute { case let .CustomEmoji(_, _, displayText, _): text = displayText emojiAttribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file) break loop default: break } } if let emojiAttribute = emojiAttribute, let text = text { let replacementText = NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: emojiAttribute]) let range = currentEmojiSuggestion.position.range let previousText = inputText.attributedSubstring(from: range) inputText.replaceCharacters(in: range, with: replacementText) var replacedUpperBound = range.lowerBound while true { if inputText.attributedSubstring(from: NSRange(location: 0, length: replacedUpperBound)).string.hasSuffix(previousText.string) { let replaceRange = NSRange(location: replacedUpperBound - previousText.length, length: previousText.length) if replaceRange.location < 0 { break } let adjacentString = inputText.attributedSubstring(from: replaceRange) if adjacentString.string != previousText.string || adjacentString.attribute(ChatTextInputAttributes.customEmoji, at: 0, effectiveRange: nil) != nil { break } inputText.replaceCharacters(in: replaceRange, with: NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: emojiAttribute.interactivelySelectedFromPackId, fileId: emojiAttribute.fileId, file: emojiAttribute.file)])) replacedUpperBound = replaceRange.lowerBound } else { break } } let selectionPosition = range.lowerBound + (replacementText.string as NSString).length textView.updateText(inputText, selectionRange: selectionPosition ..< selectionPosition) } } )), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) ) let viewFrame = CGRect(origin: CGPoint(x: min(availableSize.width - sideInset - viewSize.width, max(sideInset, floor(globalPosition.x - viewSize.width / 2.0))), y: globalPosition.y - 4.0 - viewSize.height), size: viewSize) currentEmojiSuggestionView.frame = viewFrame if let componentView = currentEmojiSuggestionView.componentView as? EmojiSuggestionsComponent.View { componentView.adjustBackground(relativePositionX: floor(globalPosition.x + 10.0)) } } if isEditing { contentHeight += bottomInset + 8.0 contentHeight += inputHeight } else { contentHeight += bottomInset contentHeight += environment.safeInsets.bottom } 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) if isEditing { if let controller = environment.controller() as? ComposePollScreen { DispatchQueue.main.async { [weak controller] in controller?.requestAttachmentMenuExpansion() } } } let isValid = self.validatedInput() != nil if let controller = environment.controller() as? ComposePollScreen, let sendButtonItem = controller.sendButtonItem { if sendButtonItem.isEnabled != isValid { sendButtonItem.isEnabled = isValid } } 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 ComposePollScreen: ViewControllerComponentContainer, AttachmentContainable { private let context: AccountContext private let completion: (ComposedPoll) -> Void private var isDismissed: Bool = false fileprivate private(set) var sendButtonItem: UIBarButtonItem? public var requestAttachmentMenuExpansion: () -> Void = { } public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in } public var updateTabBarAlpha: (CGFloat, ContainedViewLayoutTransition) -> Void = { _, _ in } public var cancelPanGesture: () -> Void = { } public var isContainerPanning: () -> Bool = { return false } public var isContainerExpanded: () -> Bool = { return false } public var mediaPickerContext: AttachmentMediaPickerContext? public var isPanGestureEnabled: (() -> Bool)? { return { [weak self] in guard let self, let componentView = self.node.hostView.componentView as? ComposePollScreenComponent.View else { return true } return componentView.isPanGestureEnabled() } } public init( context: AccountContext, peer: EnginePeer, isQuiz: Bool?, completion: @escaping (ComposedPoll) -> Void ) { self.context = context self.completion = completion super.init(context: context, component: ComposePollScreenComponent( context: context, peer: peer, isQuiz: isQuiz, completion: completion ), navigationBarAppearance: .default, theme: .default) //TODO:localize self.title = "New Poll" self.navigationItem.setLeftBarButton(UIBarButtonItem(title: "Cancel", style: .plain, target: self, action: #selector(self.cancelPressed)), animated: false) let sendButtonItem = UIBarButtonItem(title: "Send", style: .done, target: self, action: #selector(self.sendPressed)) self.sendButtonItem = sendButtonItem self.navigationItem.setRightBarButton(sendButtonItem, animated: false) sendButtonItem.isEnabled = false self.scrollToTop = { [weak self] in guard let self, let componentView = self.node.hostView.componentView as? ComposePollScreenComponent.View else { return } componentView.scrollToTop() } self.attemptNavigation = { [weak self] complete in guard let self, let componentView = self.node.hostView.componentView as? ComposePollScreenComponent.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() } @objc private func sendPressed() { guard let componentView = self.node.hostView.componentView as? ComposePollScreenComponent.View else { return } if let input = componentView.validatedInput() { self.completion(input) } self.dismiss() } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) } public func isContainerPanningUpdated(_ panning: Bool) { } public func resetForReuse() { } public func prepareForReuse() { } public func requestDismiss(completion: @escaping () -> Void) { completion() } public func shouldDismissImmediately() -> Bool { return true } }