2025-06-29 11:13:07 +02:00

1781 lines
87 KiB
Swift

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
import ListComposePollOptionComponent
import Markdown
import PresentationDataUtils
final class ComposeTodoScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let peer: EnginePeer
let initialData: ComposeTodoScreen.InitialData
let completion: (TelegramMediaTodo) -> Void
init(
context: AccountContext,
peer: EnginePeer,
initialData: ComposeTodoScreen.InitialData,
completion: @escaping (TelegramMediaTodo) -> Void
) {
self.context = context
self.peer = peer
self.initialData = initialData
self.completion = completion
}
static func ==(lhs: ComposeTodoScreenComponent, rhs: ComposeTodoScreenComponent) -> Bool {
return true
}
private final class TodoItem {
let id: Int32
let textInputState = TextFieldComponent.ExternalState()
let textFieldTag = NSObject()
var resetText: NSAttributedString?
init(id: Int32) {
self.id = id
}
}
final class View: UIView, UIScrollViewDelegate {
private let scrollView: UIScrollView
private let todoTextSection = ComponentView<Empty>()
private let todoItemsSectionHeader = ComponentView<Empty>()
private let todoItemsSectionFooterContainer = UIView()
private var todoItemsSectionFooter = ComponentView<Empty>()
private var todoItemsSectionContainer: ListSectionContentView
private let todoSettingsSection = ComponentView<Empty>()
private let actionButton = ComponentView<Empty>()
private var isUpdating: Bool = false
private var ignoreScrolling: Bool = false
private var previousHadInputHeight: Bool = false
private var component: ComposeTodoScreenComponent?
private(set) weak var state: EmptyComponentState?
private var environment: EnvironmentType?
private let todoTextInputState = TextFieldComponent.ExternalState()
private let todoTextFieldTag = NSObject()
private var resetTodoText: NSAttributedString?
private var nextTodoItemId: Int32 = 1
private var todoItems: [TodoItem] = []
private var currentTodoItemsLimitReached: Bool = false
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 var inputMediaNodeTargetTag: AnyObject?
private let inputMediaNodeDataPromise = Promise<ChatEntityKeyboardInputNode.InputData>()
private var currentEmojiSuggestionView: ComponentHostView<Empty>?
private var currentEditingTag: AnyObject?
private var reorderRecognizer: ReorderGestureRecognizer?
private var reorderingItem: (id: AnyHashable, snapshotView: UIView, backgroundView: UIView, initialPosition: CGPoint, position: CGPoint)?
var isAppendableByOthers = true
var isCompletableByOthers = true
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.todoItemsSectionContainer = ListSectionContentView(frame: CGRect())
self.todoItemsSectionContainer.automaticallyLayoutExternalContentBackgroundView = false
super.init(frame: frame)
self.scrollView.delegate = self
self.addSubview(self.scrollView)
let reorderRecognizer = ReorderGestureRecognizer(
shouldBegin: { [weak self] point in
guard let self, let (id, item) = self.item(at: point) else {
return (allowed: false, requiresLongPress: false, id: nil, item: nil)
}
return (allowed: true, requiresLongPress: false, id: id, item: item)
},
willBegin: { point in
},
began: { [weak self] item in
guard let self else {
return
}
self.setReorderingItem(item: item)
},
ended: { [weak self] in
guard let self else {
return
}
self.setReorderingItem(item: nil)
},
moved: { [weak self] distance in
guard let self else {
return
}
self.moveReorderingItem(distance: distance)
},
isActiveUpdated: { _ in
}
)
self.reorderRecognizer = reorderRecognizer
self.addGestureRecognizer(reorderRecognizer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.inputMediaNodeDataDisposable?.dispose()
}
func scrollToTop() {
self.scrollView.setContentOffset(CGPoint(), animated: true)
}
private func item(at point: CGPoint) -> (AnyHashable, ComponentView<Empty>)? {
if self.scrollView.isDragging || self.scrollView.isDecelerating {
return nil
}
let localPoint = self.todoItemsSectionContainer.convert(point, from: self)
for (id, itemView) in self.todoItemsSectionContainer.itemViews {
if let view = itemView.contents.view as? ListComposePollOptionComponent.View, !view.isRevealed && !view.currentText.isEmpty {
let viewFrame = view.convert(view.bounds, to: self.todoItemsSectionContainer)
let iconFrame = CGRect(origin: CGPoint(x: viewFrame.maxX - 40.0, y: viewFrame.minY), size: CGSize(width: viewFrame.height, height: viewFrame.height))
if iconFrame.contains(localPoint) {
return (id, itemView.contents)
}
}
}
return nil
}
func setReorderingItem(item: AnyHashable?) {
guard let environment = self.environment else {
return
}
var mappedItem: (AnyHashable, ComponentView<Empty>)?
for (id, itemView) in self.todoItemsSectionContainer.itemViews {
if id == item {
mappedItem = (id, itemView.contents)
break
}
}
if self.reorderingItem?.id != mappedItem?.0 {
if let (id, visibleItem) = mappedItem, let view = visibleItem.view, !view.isHidden, let viewSuperview = view.superview, let snapshotView = view.snapshotView(afterScreenUpdates: false) {
let mappedCenter = viewSuperview.convert(view.center, to: self.scrollView)
let wrapperView = UIView()
wrapperView.alpha = 0.8
wrapperView.frame = CGRect(origin: mappedCenter.offsetBy(dx: -snapshotView.bounds.width / 2.0, dy: -snapshotView.bounds.height / 2.0), size: snapshotView.bounds.size)
let theme = environment.theme.withModalBlocksBackground()
let backgroundView = UIImageView(image: generateReorderingBackgroundImage(backgroundColor: theme.list.itemBlocksBackgroundColor))
backgroundView.frame = wrapperView.bounds.insetBy(dx: -10.0, dy: -10.0)
snapshotView.frame = snapshotView.bounds
wrapperView.addSubview(backgroundView)
wrapperView.addSubview(snapshotView)
backgroundView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
wrapperView.transform = CGAffineTransformMakeScale(1.04, 1.04)
wrapperView.layer.animateScale(from: 1.0, to: 1.04, duration: 0.2)
self.scrollView.addSubview(wrapperView)
self.reorderingItem = (id, wrapperView, backgroundView, mappedCenter, mappedCenter)
self.state?.updated()
} else {
if let reorderingItem = self.reorderingItem {
self.reorderingItem = nil
for (itemId, itemView) in self.todoItemsSectionContainer.itemViews {
if itemId == reorderingItem.id, let view = itemView.contents.view {
let viewFrame = view.convert(view.bounds, to: self)
let transition = ComponentTransition.spring(duration: 0.3)
transition.setPosition(view: reorderingItem.snapshotView, position: viewFrame.center)
transition.setAlpha(view: reorderingItem.backgroundView, alpha: 0.0, completion: { _ in
reorderingItem.snapshotView.removeFromSuperview()
self.state?.updated()
})
transition.setScale(view: reorderingItem.snapshotView, scale: 1.0)
break
}
}
}
}
}
}
func moveReorderingItem(distance: CGPoint) {
if let (id, snapshotView, backgroundView, initialPosition, _) = self.reorderingItem {
let targetPosition = CGPoint(x: initialPosition.x + distance.x, y: initialPosition.y + distance.y)
self.reorderingItem = (id, snapshotView, backgroundView, initialPosition, targetPosition)
snapshotView.center = targetPosition
for (itemId, itemView) in self.todoItemsSectionContainer.itemViews {
if itemId == id {
continue
}
if let view = itemView.contents.view {
let viewFrame = view.convert(view.bounds, to: self)
if viewFrame.contains(targetPosition) {
if let targetIndex = self.todoItems.firstIndex(where: { AnyHashable($0.id) == itemId }), let reorderingItem = self.todoItems.first(where: { AnyHashable($0.id) == id }) {
self.reorderIfPossible(item: reorderingItem, toIndex: targetIndex)
}
break
}
}
}
}
}
private func reorderIfPossible(item: TodoItem, toIndex: Int) {
guard let component = self.component else {
return
}
let targetItem = self.todoItems[toIndex]
guard targetItem.textInputState.hasText else {
return
}
var canEdit = true
if let _ = component.initialData.existingTodo, !component.initialData.canEdit {
canEdit = false
}
if !canEdit, let existingTodo = component.initialData.existingTodo, existingTodo.items.contains(where: { $0.id == targetItem.id }) {
return
}
if let fromIndex = self.todoItems.firstIndex(where: { $0.id == item.id }) {
self.todoItems[toIndex] = item
self.todoItems[fromIndex] = targetItem
HapticFeedback().tap()
self.state?.updated(transition: .spring(duration: 0.4))
}
}
func validatedInput() -> TelegramMediaTodo? {
if self.todoTextInputState.text.string.trimmingCharacters(in: .whitespacesAndNewlines).count == 0 {
return nil
}
var mappedItems: [TelegramMediaTodo.Item] = []
for todoItem in self.todoItems {
if todoItem.textInputState.text.string.trimmingCharacters(in: .whitespacesAndNewlines).count == 0 {
continue
}
var entities: [MessageTextEntity] = []
for entity in generateChatInputTextEntities(todoItem.textInputState.text) {
switch entity.type {
case .CustomEmoji:
entities.append(entity)
default:
break
}
}
mappedItems.append(
TelegramMediaTodo.Item(
text: todoItem.textInputState.text.string,
entities: entities,
id: todoItem.id
)
)
}
if mappedItems.count < 1 {
return nil
}
var textEntities: [MessageTextEntity] = []
for entity in generateChatInputTextEntities(self.todoTextInputState.text) {
switch entity.type {
case .CustomEmoji:
textEntities.append(entity)
default:
break
}
}
var flags: TelegramMediaTodo.Flags = []
if self.isCompletableByOthers {
flags.insert(.othersCanComplete)
if self.isAppendableByOthers {
flags.insert(.othersCanAppend)
}
}
return TelegramMediaTodo(
flags: flags,
text: self.todoTextInputState.text.string,
textEntities: textEntities,
items: mappedItems
)
}
func attemptNavigation(complete: @escaping () -> Void) -> Bool {
guard let component = self.component else {
return true
}
let _ = component
return true
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(transition: .immediate)
}
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
self.endEditing(true)
}
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)
}
}
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: ComposeTodoScreenComponent,
availableSize: CGSize,
bottomInset: CGFloat,
inputHeight: CGFloat,
effectiveInputHeight: CGFloat,
metrics: LayoutMetrics,
deviceMetrics: DeviceMetrics,
transition: ComponentTransition
) -> 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 {
if let updatedTag = self.collectTextInputStates().first(where: { $1.isEditing })?.view.currentTag {
self.inputMediaNodeTargetTag = updatedTag
}
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,
useOpaqueTheme: true,
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)
ComponentTransition.immediate.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame)
ComponentTransition.immediate.setFrame(layer: self.inputMediaNodeBackground, frame: inputNodeBackgroundFrame)
}
if animateIn {
var targetFrame = inputNodeFrame
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 {
self.inputMediaNodeTargetTag = nil
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? ComposeTodoScreen {
let isTabBarVisible = self.inputMediaNode == nil
DispatchQueue.main.async { [weak controller] in
controller?.updateTabBarVisibility(isTabBarVisible, 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.todoTextSection.findTaggedView(tag: self.todoTextFieldTag) as? ListComposePollOptionComponent.View {
textInputStates.append((textInputView, self.todoTextInputState))
}
for todoItem in self.todoItems {
if let textInputView = findTaggedComponentViewImpl(view: self.todoItemsSectionContainer, tag: todoItem.textFieldTag) as? ListComposePollOptionComponent.View {
textInputStates.append((textInputView, todoItem.textInputState))
}
}
return textInputStates
}
func update(component: ComposeTodoScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
var alphaTransition = transition
if !transition.animation.isImmediate {
alphaTransition = alphaTransition.withAnimation(.curve(duration: 0.25, curve: .easeInOut))
}
let environment = environment[EnvironmentType.self].value
let themeUpdated = self.environment?.theme !== environment.theme
self.environment = environment
let theme = environment.theme.withModalBlocksBackground()
let isFirstTime = self.component == nil
if self.component == nil {
if let existingTodo = component.initialData.existingTodo {
self.resetTodoText = chatInputStateStringWithAppliedEntities(existingTodo.text, entities: existingTodo.textEntities)
for item in existingTodo.items {
let todoItem = ComposeTodoScreenComponent.TodoItem(
id: item.id
)
todoItem.resetText = chatInputStateStringWithAppliedEntities(item.text, entities: item.entities)
self.todoItems.append(todoItem)
}
self.nextTodoItemId = (existingTodo.items.max(by: { $0.id < $1.id })?.id ?? 0) + 1
self.isAppendableByOthers = existingTodo.flags.contains(.othersCanAppend)
self.isCompletableByOthers = existingTodo.flags.contains(.othersCanComplete)
} else {
self.todoItems.append(ComposeTodoScreenComponent.TodoItem(
id: self.nextTodoItemId
))
self.nextTodoItemId += 1
self.todoItems.append(ComposeTodoScreenComponent.TodoItem(
id: self.nextTodoItemId
))
self.nextTodoItemId += 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: .spring(duration: 0.4))
},
dismissTextInput: {
},
insertText: { [weak self] text in
guard let self else {
return
}
var found = false
for (textInputView, externalState) in self.collectTextInputStates() {
if externalState.isEditing {
textInputView.insertText(text: text)
found = true
break
}
}
if !found, let inputMediaNodeTargetTag = self.inputMediaNodeTargetTag {
for (textInputView, _) in self.collectTextInputStates() {
if textInputView.currentTag === inputMediaNodeTargetTag {
textInputView.insertText(text: text)
found = true
break
}
}
}
},
backwardsDeleteText: { [weak self] in
guard let self else {
return
}
var found = false
for (textInputView, externalState) in self.collectTextInputStates() {
if externalState.isEditing {
textInputView.backwardsDeleteText()
found = true
break
}
}
if !found, let inputMediaNodeTargetTag = self.inputMediaNodeTargetTag {
for (textInputView, _) in self.collectTextInputStates() {
if textInputView.currentTag === inputMediaNodeTargetTag {
textInputView.backwardsDeleteText()
found = true
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] () -> NavigationController? in
guard let self else {
return nil
}
guard let controller = self.environment?.controller() as? ComposeTodoScreen else {
return nil
}
if let navigationController = controller.navigationController as? NavigationController {
return navigationController
}
if let parentController = controller.parentController() {
return parentController.navigationController as? NavigationController
}
return nil
},
requestLayout: { [weak self] transition in
guard let self else {
return
}
if !self.isUpdating {
self.state?.updated(transition: ComponentTransition(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 = theme.list.blocksBackgroundColor
}
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
var contentHeight: CGFloat = 0.0
contentHeight += environment.navigationHeight
contentHeight += topInset
var canEdit = true
if let _ = component.initialData.existingTodo, !component.initialData.canEdit {
canEdit = false
}
var todoTextSectionItems: [AnyComponentWithIdentity<Empty>] = []
todoTextSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent(
externalState: self.todoTextInputState,
context: component.context,
theme: theme,
strings: environment.strings,
isEnabled: canEdit,
resetText: self.resetTodoText.flatMap { resetText in
return ListComposePollOptionComponent.ResetText(value: resetText)
},
assumeIsEditing: self.inputMediaNodeTargetTag === self.todoTextFieldTag,
characterLimit: component.initialData.maxTodoTextLength,
emptyLineHandling: .allowed,
returnKeyAction: { [weak self] in
guard let self else {
return
}
if !self.todoItems.isEmpty {
if let todoItemView = self.todoItemsSectionContainer.itemViews[self.todoItems[0].id] {
if let todoItemComponentView = todoItemView.contents.view as? ListComposePollOptionComponent.View {
todoItemComponentView.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.todoTextFieldTag
))))
self.resetTodoText = nil
let todoTextSectionSize = self.todoTextSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: theme,
header: nil,
footer: nil,
items: todoTextSectionItems
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0)
)
let todoTextSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: todoTextSectionSize)
if let todoTextSectionView = self.todoTextSection.view as? ListSectionComponent.View {
if todoTextSectionView.superview == nil {
self.scrollView.addSubview(todoTextSectionView)
self.todoTextSection.parentState = state
}
transition.setFrame(view: todoTextSectionView, frame: todoTextSectionFrame)
if let itemView = todoTextSectionView.itemView(id: 0) as? ListComposePollOptionComponent.View {
itemView.updateCustomPlaceholder(value: environment.strings.CreateTodo_TitlePlaceholder, size: itemView.bounds.size, transition: .immediate)
}
}
contentHeight += todoTextSectionSize.height
contentHeight += sectionSpacing
var todoItemsSectionItems: [AnyComponentWithIdentity<Empty>] = []
var todoItemsSectionReadyItems: [ListSectionContentView.ReadyItem] = []
let processTodoItemItem: (Int) -> Void = { i in
let todoItem = self.todoItems[i]
let optionId = todoItem.id
var isEnabled = true
if !canEdit, let existingTodo = component.initialData.existingTodo, existingTodo.items.contains(where: { $0.id == todoItem.id }) {
isEnabled = false
}
var canDelete = isEnabled
if i == self.todoItems.count - 1 {
canDelete = false
}
todoItemsSectionItems.append(AnyComponentWithIdentity(id: todoItem.id, component: AnyComponent(ListComposePollOptionComponent(
externalState: todoItem.textInputState,
context: component.context,
theme: theme,
strings: environment.strings,
isEnabled: isEnabled,
resetText: todoItem.resetText.flatMap { resetText in
return ListComposePollOptionComponent.ResetText(value: resetText)
},
assumeIsEditing: self.inputMediaNodeTargetTag === todoItem.textFieldTag,
characterLimit: component.initialData.maxTodoItemLength,
canReorder: isEnabled,
emptyLineHandling: .notAllowed,
returnKeyAction: { [weak self] in
guard let self else {
return
}
if let index = self.todoItems.firstIndex(where: { $0.id == optionId }) {
if index == self.todoItems.count - 1 {
self.endEditing(true)
} else {
if let todoItemView = self.todoItemsSectionContainer.itemViews[self.todoItems[index + 1].id] {
if let todoItemComponentView = todoItemView.contents.view as? ListComposePollOptionComponent.View {
todoItemComponentView.activateInput()
}
}
}
}
},
backspaceKeyAction: { [weak self] in
guard let self else {
return
}
if let index = self.todoItems.firstIndex(where: { $0.id == optionId }) {
if index == 0 {
if let textInputView = self.todoTextSection.findTaggedView(tag: self.todoTextFieldTag) as? ListComposePollOptionComponent.View {
textInputView.activateInput()
}
} else {
if let todoItemView = self.todoItemsSectionContainer.itemViews[self.todoItems[index - 1].id] {
if let todoItemComponentView = todoItemView.contents.view as? ListComposePollOptionComponent.View {
todoItemComponentView.activateInput()
}
}
}
}
},
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))
},
deleteAction: canDelete ? { [weak self] in
guard let self else {
return
}
self.todoItems.removeAll(where: { $0.id == optionId })
self.state?.updated(transition: .spring(duration: 0.4))
} : nil,
paste: { [weak self] data in
guard let self else {
return
}
if case let .text(text) = data {
let lines = text.string.components(separatedBy: "\n")
if !lines.isEmpty {
self.endEditing(true)
var i = 0
for line in lines {
if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
continue
}
let line = String(line.prefix(component.initialData.maxTodoItemLength))
if i < self.todoItems.count {
self.todoItems[i].resetText = NSAttributedString(string: line)
} else {
if self.todoItems.count < component.initialData.maxTodoItemsCount {
let todoItem = ComposeTodoScreenComponent.TodoItem(
id: self.nextTodoItemId
)
todoItem.resetText = NSAttributedString(string: line)
self.todoItems.append(todoItem)
self.nextTodoItemId += 1
}
}
i += 1
}
self.state?.updated()
}
}
},
tag: todoItem.textFieldTag
))))
let item = todoItemsSectionItems[i]
let itemId = item.id
let itemView: ListSectionContentView.ItemView
var itemTransition = transition
if let current = self.todoItemsSectionContainer.itemViews[itemId] {
itemView = current
} else {
itemTransition = itemTransition.withAnimation(.none)
itemView = ListSectionContentView.ItemView()
self.todoItemsSectionContainer.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)
)
todoItemsSectionReadyItems.append(ListSectionContentView.ReadyItem(
id: itemId,
itemView: itemView,
size: itemSize,
transition: itemTransition
))
var isReordering = false
if let reorderingItem = self.reorderingItem, itemId == reorderingItem.id {
isReordering = true
}
itemView.contents.view?.isHidden = isReordering
}
for i in 0 ..< self.todoItems.count {
processTodoItemItem(i)
}
if self.todoItems.count > 2 {
let lastOption = self.todoItems[self.todoItems.count - 1]
let secondToLastOption = self.todoItems[self.todoItems.count - 2]
if !lastOption.textInputState.isEditing && lastOption.textInputState.text.length == 0 && secondToLastOption.textInputState.text.length == 0 {
self.todoItems.removeLast()
todoItemsSectionItems.removeLast()
todoItemsSectionReadyItems.removeLast()
}
}
if self.todoItems.count < component.initialData.maxTodoItemsCount, let lastOption = self.todoItems.last {
if lastOption.textInputState.text.length != 0 {
self.todoItems.append(TodoItem(id: self.nextTodoItemId))
self.nextTodoItemId += 1
processTodoItemItem(self.todoItems.count - 1)
}
}
var focusedIndex: Int?
if isFirstTime, let focusedId = component.initialData.focusedId {
focusedIndex = self.todoItems.firstIndex(where: { $0.id == focusedId })
}
for i in 0 ..< todoItemsSectionReadyItems.count {
var activate = false
let placeholder: String
if i == todoItemsSectionReadyItems.count - 1 {
placeholder = environment.strings.CreateTodo_AddTaskPlaceholder
if isFirstTime, component.initialData.append {
activate = true
}
} else {
placeholder = environment.strings.CreateTodo_TaskPlaceholder
}
if let focusedIndex, i == focusedIndex {
activate = true
}
if let itemView = todoItemsSectionReadyItems[i].itemView.contents.view as? ListComposePollOptionComponent.View {
itemView.updateCustomPlaceholder(value: placeholder, size: todoItemsSectionReadyItems[i].size, transition: todoItemsSectionReadyItems[i].transition)
if activate {
itemView.activateInput()
}
}
}
let todoItemsSectionUpdateResult = self.todoItemsSectionContainer.update(
configuration: ListSectionContentView.Configuration(
theme: theme,
displaySeparators: true,
extendsItemHighlightToSection: false,
background: .all
),
width: availableSize.width - sideInset * 2.0,
leftInset: 0.0,
readyItems: todoItemsSectionReadyItems,
transition: transition
)
let sectionHeaderSideInset: CGFloat = 16.0
let todoItemsSectionHeaderSize = self.todoItemsSectionHeader.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: environment.strings.CreateTodo_TodoTitle,
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
textColor: theme.list.freeTextColor
)),
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - sectionHeaderSideInset * 2.0, height: 1000.0)
)
let todoItemsSectionHeaderFrame = CGRect(origin: CGPoint(x: sideInset + sectionHeaderSideInset, y: contentHeight), size: todoItemsSectionHeaderSize)
if let todoItemsSectionHeaderView = self.todoItemsSectionHeader.view {
if todoItemsSectionHeaderView.superview == nil {
todoItemsSectionHeaderView.layer.anchorPoint = CGPoint()
self.scrollView.addSubview(todoItemsSectionHeaderView)
}
transition.setPosition(view: todoItemsSectionHeaderView, position: todoItemsSectionHeaderFrame.origin)
todoItemsSectionHeaderView.bounds = CGRect(origin: CGPoint(), size: todoItemsSectionHeaderFrame.size)
}
contentHeight += todoItemsSectionHeaderSize.height
contentHeight += 7.0
let todoItemsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: todoItemsSectionUpdateResult.size)
if self.todoItemsSectionContainer.superview == nil {
self.scrollView.addSubview(self.todoItemsSectionContainer.externalContentBackgroundView)
self.scrollView.addSubview(self.todoItemsSectionContainer)
}
transition.setFrame(view: self.todoItemsSectionContainer, frame: todoItemsSectionFrame)
transition.setFrame(view: self.todoItemsSectionContainer.externalContentBackgroundView, frame: todoItemsSectionUpdateResult.backgroundFrame.offsetBy(dx: todoItemsSectionFrame.minX, dy: todoItemsSectionFrame.minY))
contentHeight += todoItemsSectionUpdateResult.size.height
contentHeight += 7.0
let todoItemsLimitReached = self.todoItems.count >= component.initialData.maxTodoItemsCount
var animateTodoItemsFooterIn = false
var todoItemsFooterTransition = transition
if self.currentTodoItemsLimitReached != todoItemsLimitReached {
self.currentTodoItemsLimitReached = todoItemsLimitReached
if let todoItemsSectionFooterView = self.todoItemsSectionFooter.view {
animateTodoItemsFooterIn = true
todoItemsFooterTransition = todoItemsFooterTransition.withAnimation(.none)
alphaTransition.setAlpha(view: todoItemsSectionFooterView, alpha: 0.0, completion: { [weak todoItemsSectionFooterView] _ in
todoItemsSectionFooterView?.removeFromSuperview()
})
self.todoItemsSectionFooter = ComponentView()
}
}
let todoItemsComponent: AnyComponent<Empty>
if todoItemsLimitReached {
todoItemsFooterTransition = todoItemsFooterTransition.withAnimation(.none)
let textFont = Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize)
let boldTextFont = Font.semibold(presentationData.listsFontSize.itemListBaseHeaderFontSize)
let textColor = theme.list.freeTextColor
todoItemsComponent = AnyComponent(MultilineTextComponent(
text: .markdown(
text: environment.strings.CreateTodo_TaskCountLimitReached,
attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: textFont, textColor: textColor),
bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor),
link: MarkdownAttributeSet(font: textFont, textColor: theme.list.itemAccentColor),
linkAttribute: { contents in
return (TelegramTextAttributes.URL, contents)
}
)
),
maximumNumberOfLines: 0,
highlightColor: presentationData.theme.list.itemAccentColor.withAlphaComponent(0.2),
highlightAction: { attributes in
if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] {
return NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)
} else {
return nil
}
},
tapAction: { [weak self] _, _ in
guard let self, let component = self.component else {
return
}
let controller = component.context.sharedContext.makePremiumIntroController(
context: component.context,
source: .chatsPerFolder,
forceDark: false,
dismissed: nil
)
(self.environment?.controller() as? AttachmentContainable)?.parentController()?.push(controller)
}
))
} else {
let remainingCount = component.initialData.maxTodoItemsCount - self.todoItems.count
let rawString = environment.strings.CreateTodo_TaskCountFooterFormat(Int32(remainingCount))
var todoItemsFooterItems: [AnimatedTextComponent.Item] = []
if let range = rawString.range(of: "{count}") {
if range.lowerBound != rawString.startIndex {
todoItemsFooterItems.append(AnimatedTextComponent.Item(
id: 0,
isUnbreakable: true,
content: .text(String(rawString[rawString.startIndex ..< range.lowerBound]))
))
}
todoItemsFooterItems.append(AnimatedTextComponent.Item(
id: 1,
isUnbreakable: true,
content: .number(remainingCount, minDigits: 1)
))
if range.upperBound != rawString.endIndex {
todoItemsFooterItems.append(AnimatedTextComponent.Item(
id: 2,
isUnbreakable: true,
content: .text(String(rawString[range.upperBound ..< rawString.endIndex]))
))
}
}
todoItemsComponent = AnyComponent(AnimatedTextComponent(
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
color: theme.list.freeTextColor,
items: todoItemsFooterItems
))
}
let todoItemsSectionFooterSize = self.todoItemsSectionFooter.update(
transition: todoItemsFooterTransition,
component: todoItemsComponent,
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0 - sectionHeaderSideInset * 2.0, height: 1000.0)
)
let todoItemsSectionFooterFrame = CGRect(origin: CGPoint(x: sideInset + sectionHeaderSideInset, y: contentHeight), size: todoItemsSectionFooterSize)
if self.todoItemsSectionFooterContainer.superview == nil {
self.scrollView.addSubview(self.todoItemsSectionFooterContainer)
}
transition.setFrame(view: self.todoItemsSectionFooterContainer, frame: todoItemsSectionFooterFrame)
if let todoItemsSectionFooterView = self.todoItemsSectionFooter.view {
if todoItemsSectionFooterView.superview == nil {
todoItemsSectionFooterView.layer.anchorPoint = CGPoint()
self.todoItemsSectionFooterContainer.addSubview(todoItemsSectionFooterView)
}
todoItemsFooterTransition.setPosition(view: todoItemsSectionFooterView, position: CGPoint())
todoItemsSectionFooterView.bounds = CGRect(origin: CGPoint(), size: todoItemsSectionFooterFrame.size)
if animateTodoItemsFooterIn && !transition.animation.isImmediate {
alphaTransition.animateAlpha(view: todoItemsSectionFooterView, from: 0.0, to: 1.0)
}
}
contentHeight += todoItemsSectionFooterSize.height
contentHeight += sectionSpacing
var todoSettingsSectionItems: [AnyComponentWithIdentity<Empty>] = []
if canEdit {
todoSettingsSectionItems.append(AnyComponentWithIdentity(id: "completable", component: AnyComponent(ListActionItemComponent(
theme: theme,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: environment.strings.CreateTodo_AllowOthersToComplete,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
))),
], alignment: .left, spacing: 2.0)),
accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isCompletableByOthers, action: { [weak self] _ in
guard let self else {
return
}
self.isCompletableByOthers = !self.isCompletableByOthers
self.state?.updated(transition: .spring(duration: 0.4))
})),
action: nil
))))
if self.isCompletableByOthers {
todoSettingsSectionItems.append(AnyComponentWithIdentity(id: "editable", component: AnyComponent(ListActionItemComponent(
theme: theme,
title: AnyComponent(VStack([
AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(
string: environment.strings.CreateTodo_AllowOthersToAppend,
font: Font.regular(presentationData.listsFontSize.baseDisplaySize),
textColor: theme.list.itemPrimaryTextColor
)),
maximumNumberOfLines: 1
))),
], alignment: .left, spacing: 2.0)),
accessory: .toggle(ListActionItemComponent.Toggle(style: .regular, isOn: self.isAppendableByOthers, action: { [weak self] _ in
guard let self else {
return
}
self.isAppendableByOthers = !self.isAppendableByOthers
self.state?.updated(transition: .spring(duration: 0.4))
})),
action: nil
))))
}
}
if !todoSettingsSectionItems.isEmpty {
let todoSettingsSectionSize = self.todoSettingsSection.update(
transition: transition,
component: AnyComponent(ListSectionComponent(
theme: theme,
header: nil,
footer: nil,
items: todoSettingsSectionItems
)),
environment: {},
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 10000.0)
)
let todoSettingsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: todoSettingsSectionSize)
if let todoSettingsSectionView = self.todoSettingsSection.view {
if todoSettingsSectionView.superview == nil {
self.scrollView.addSubview(todoSettingsSectionView)
self.todoSettingsSection.parentState = state
}
transition.setFrame(view: todoSettingsSectionView, frame: todoSettingsSectionFrame)
}
contentHeight += todoSettingsSectionSize.height
}
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
)
if self.inputMediaNode == nil {
inputHeight = environment.inputHeight
}
let textInputStates = self.collectTextInputStates()
let previousEditingTag = self.currentEditingTag
let isEditing: Bool
if let index = textInputStates.firstIndex(where: { $0.state.isEditing }) {
isEditing = true
self.currentEditingTag = textInputStates[index].view.currentTag
} else {
isEditing = false
self.currentEditingTag = nil
}
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<Empty>
if let current = self.currentEmojiSuggestionView {
currentEmojiSuggestionView = current
} else {
currentEmojiSuggestionView = ComponentHostView<Empty>()
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: theme, backgroundColor: theme.list.itemBlocksBackgroundColor),
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))
}
}
let combinedBottomInset: CGFloat
combinedBottomInset = bottomInset + max(environment.safeInsets.bottom, 8.0 + inputHeight)
contentHeight += combinedBottomInset
var recenterOnTag: AnyObject?
if let hint = transition.userData(TextFieldComponent.AnimationHint.self), let targetView = hint.view {
var matches = false
switch hint.kind {
case .textChanged:
matches = true
case let .textFocusChanged(isFocused):
if isFocused {
matches = true
}
}
if matches {
for (textView, _) in self.collectTextInputStates() {
if targetView.isDescendant(of: textView) {
recenterOnTag = textView.currentTag
break
}
}
}
}
if recenterOnTag == nil && self.previousHadInputHeight != (inputHeight > 0.0) {
for (textView, state) in self.collectTextInputStates() {
if state.isEditing {
recenterOnTag = textView.currentTag
break
}
}
}
self.previousHadInputHeight = (inputHeight > 0.0)
self.ignoreScrolling = true
let previousBounds = self.scrollView.bounds
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.verticalScrollIndicatorInsets != scrollInsets {
self.scrollView.verticalScrollIndicatorInsets = scrollInsets
}
if let recenterOnTag {
if let targetView = self.collectTextInputStates().first(where: { $0.view.currentTag === recenterOnTag })?.view {
let caretRect = targetView.convert(targetView.bounds, to: self.scrollView)
var scrollViewBounds = self.scrollView.bounds
let minButtonDistance: CGFloat = 16.0
if -scrollViewBounds.minY + caretRect.maxY > availableSize.height - combinedBottomInset - minButtonDistance {
scrollViewBounds.origin.y = -(availableSize.height - combinedBottomInset - minButtonDistance - caretRect.maxY)
if scrollViewBounds.origin.y < 0.0 {
scrollViewBounds.origin.y = 0.0
}
}
if self.scrollView.bounds != scrollViewBounds {
self.scrollView.bounds = scrollViewBounds
}
}
}
if !previousBounds.isEmpty, !transition.animation.isImmediate {
let bounds = self.scrollView.bounds
if bounds.maxY != previousBounds.maxY {
let offsetY = previousBounds.maxY - bounds.maxY
transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true)
}
}
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
if isEditing {
if let controller = environment.controller() as? ComposeTodoScreen {
DispatchQueue.main.async { [weak controller] in
controller?.requestAttachmentMenuExpansion()
}
}
}
let isValid = self.validatedInput() != nil
if let controller = environment.controller() as? ComposeTodoScreen, let sendButtonItem = controller.sendButtonItem {
if sendButtonItem.isEnabled != isValid {
sendButtonItem.isEnabled = isValid
}
}
if let currentEditingTag = self.currentEditingTag, previousEditingTag !== currentEditingTag, self.currentInputMode != .keyboard {
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
self.currentInputMode = .keyboard
self.state?.updated(transition: .spring(duration: 0.4))
}
}
for i in 0 ..< self.todoItems.count {
self.todoItems[i].resetText = nil
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
public class ComposeTodoScreen: ViewControllerComponentContainer, AttachmentContainable {
public final class InitialData {
fileprivate let maxTodoTextLength: Int
fileprivate let maxTodoItemLength: Int
fileprivate let maxTodoItemsCount: Int
fileprivate let existingTodo: TelegramMediaTodo?
fileprivate let focusedId: Int32?
fileprivate let append: Bool
fileprivate let canEdit: Bool
fileprivate init(
maxTodoTextLength: Int,
maxTodoItemLength: Int,
maxTodoItemsCount: Int,
existingTodo: TelegramMediaTodo?,
focusedId: Int32?,
append: Bool,
canEdit: Bool
) {
self.maxTodoTextLength = maxTodoTextLength
self.maxTodoItemLength = maxTodoItemLength
self.maxTodoItemsCount = maxTodoItemsCount
self.existingTodo = existingTodo
self.focusedId = focusedId
self.append = append
self.canEdit = canEdit
}
}
private let context: AccountContext
private let completion: (TelegramMediaTodo) -> Void
private var isDismissed: Bool = false
fileprivate private(set) var sendButtonItem: UIBarButtonItem?
public var isMinimized: Bool = false
public var requestAttachmentMenuExpansion: () -> Void = {
}
public var updateNavigationStack: (@escaping ([AttachmentContainable]) -> ([AttachmentContainable], AttachmentMediaPickerContext?)) -> Void = { _ in
}
public var parentController: () -> ViewController? = {
return nil
}
public var updateTabBarAlpha: (CGFloat, ContainedViewLayoutTransition) -> Void = { _, _ in
}
public var updateTabBarVisibility: (Bool, 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? ComposeTodoScreenComponent.View else {
return true
}
return componentView.isPanGestureEnabled()
}
}
public init(
context: AccountContext,
initialData: InitialData,
peer: EnginePeer,
completion: @escaping (TelegramMediaTodo) -> Void
) {
self.context = context
self.completion = completion
super.init(context: context, component: ComposeTodoScreenComponent(
context: context,
peer: peer,
initialData: initialData,
completion: completion
), navigationBarAppearance: .default, theme: .default)
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
if !initialData.canEdit && initialData.existingTodo != nil {
self.title = presentationData.strings.CreateTodo_Title
} else {
self.title = initialData.existingTodo != nil ? presentationData.strings.CreateTodo_EditTitle : presentationData.strings.CreateTodo_Title
}
self.navigationItem.setLeftBarButton(UIBarButtonItem(title: presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed)), animated: false)
let sendButtonItem = UIBarButtonItem(title: initialData.existingTodo != nil ? presentationData.strings.CreateTodo_Save : presentationData.strings.CreateTodo_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? ComposeTodoScreenComponent.View else {
return
}
componentView.scrollToTop()
}
self.attemptNavigation = { [weak self] complete in
guard let self, let componentView = self.node.hostView.componentView as? ComposeTodoScreenComponent.View else {
return true
}
return componentView.attemptNavigation(complete: complete)
}
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
public static func initialData(context: AccountContext, existingTodo: TelegramMediaTodo? = nil, focusedId: Int32? = nil, append: Bool = false, canEdit: Bool = false) -> InitialData {
var maxTodoTextLength: Int = 32
var maxTodoItemLength: Int = 64
var maxTodoItemsCount: Int = 30
if let data = context.currentAppConfiguration.with({ $0 }).data {
if let value = data["todo_title_length_max"] as? Double {
maxTodoTextLength = Int(value)
}
if let value = data["todo_item_length_max"] as? Double {
maxTodoItemLength = Int(value)
}
if let value = data["todo_items_max"] as? Double {
maxTodoItemsCount = Int(value)
}
}
return InitialData(
maxTodoTextLength: maxTodoTextLength,
maxTodoItemLength: maxTodoItemLength,
maxTodoItemsCount: maxTodoItemsCount,
existingTodo: existingTodo,
focusedId: focusedId,
append: append,
canEdit: canEdit
)
}
@objc private func cancelPressed() {
self.dismiss()
}
@objc private func sendPressed() {
guard let componentView = self.node.hostView.componentView as? ComposeTodoScreenComponent.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) {
guard let componentView = self.node.hostView.componentView as? ComposeTodoScreenComponent.View else {
return
}
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
if let input = componentView.validatedInput(), !input.text.isEmpty || !input.items.isEmpty {
let text = presentationData.strings.Attachment_DiscardTodoAlertText
let controller = textAlertController(context: self.context, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Attachment_CancelSelectionAlertNo, action: {
}), TextAlertAction(type: .defaultAction, title: presentationData.strings.Attachment_CancelSelectionAlertYes, action: {
completion()
})])
self.present(controller, in: .window(.root))
} else {
completion()
}
}
public func shouldDismissImmediately() -> Bool {
guard let componentView = self.node.hostView.componentView as? ComposeTodoScreenComponent.View else {
return true
}
if let input = componentView.validatedInput(), !input.text.isEmpty || !input.items.isEmpty {
return false
} else {
return true
}
}
}