diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 90d84f161d..544a204872 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -14412,3 +14412,19 @@ Sorry for the inconvenience."; "Stars.SendMessage.AdjustmentAction" = "OK"; "Stars.SendMessage.PriceFree" = "Free"; + +"Notification.TodoTasks_1" = "%@ task"; +"Notification.TodoTasks_any" = "%@ tasks"; + +"Notification.TodoCompleted" = "%1$@ marked \"%2$@\" as done."; +"Notification.TodoIncompleted" = "%1$@ marked \"%2$@\" as undone."; +"Notification.TodoAddedTask" = "%1$@ added a new task \"%2$@\" to \"%3$@\"."; +"Notification.TodoAddedMultipleTasks" = "%1$@ added %2$@ to \"%3$@\"."; + +"Notification.TodoCompletedYou" = "You marked \"%1$@\" as done."; +"Notification.TodoIncompletedYou" = "You marked \"%1$@\" as not done."; +"Notification.TodoAddedTaskYou" = "You added a new task \"%1$@\" to \"%2$@\"."; +"Notification.TodoAddedMultipleTasksYou" = "You added %1$@ to \"%2$@\"."; + +"Stars.Transaction.GiftTransfer" = "Gift Transfer"; +"Stars.Intro.Transaction.GiftTransfer" = "Gift Transfer"; diff --git a/submodules/AccountContext/Sources/Premium.swift b/submodules/AccountContext/Sources/Premium.swift index e1b5e521bb..360282a1fe 100644 --- a/submodules/AccountContext/Sources/Premium.swift +++ b/submodules/AccountContext/Sources/Premium.swift @@ -43,6 +43,7 @@ public enum PremiumIntroSource { case animatedEmoji case messageEffects case paidMessages + case todo case auth(String) } @@ -82,6 +83,7 @@ public enum PremiumDemoSubject { case business case messageEffects case paidMessages + case todo case businessLocation case businessHours diff --git a/submodules/AttachmentUI/Sources/AttachmentController.swift b/submodules/AttachmentUI/Sources/AttachmentController.swift index 9f4a1610df..64072bfc41 100644 --- a/submodules/AttachmentUI/Sources/AttachmentController.swift +++ b/submodules/AttachmentUI/Sources/AttachmentController.swift @@ -21,6 +21,7 @@ public enum AttachmentButtonType: Equatable { case gallery case file case location + case todo case quickReply case contact case poll @@ -36,6 +37,8 @@ public enum AttachmentButtonType: Equatable { return "file" case .location: return "location" + case .todo: + return "todo" case .quickReply: return "quickReply" case .contact: @@ -71,6 +74,12 @@ public enum AttachmentButtonType: Equatable { } else { return false } + case .todo: + if case .todo = rhs { + return true + } else { + return false + } case .quickReply: if case .quickReply = rhs { return true diff --git a/submodules/AttachmentUI/Sources/AttachmentPanel.swift b/submodules/AttachmentUI/Sources/AttachmentPanel.swift index c030d277eb..a731bf4f96 100644 --- a/submodules/AttachmentUI/Sources/AttachmentPanel.swift +++ b/submodules/AttachmentUI/Sources/AttachmentPanel.swift @@ -195,6 +195,9 @@ private final class AttachButtonComponent: CombinedComponent { case .location: name = strings.Attachment_Location imageName = "Chat/Attach Menu/Location" + case .todo: + name = "To Do List" + imageName = "Chat/Attach Menu/Todo" case .contact: name = strings.Attachment_Contact imageName = "Chat/Attach Menu/Contact" @@ -1267,6 +1270,7 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { }, openBoostToUnrestrict: { }, updateRecordingTrimRange: { _, _, _, _ in }, dismissAllTooltips: { + }, editTodoMessage: { _, _ in }, updateHistoryFilter: { _ in }, updateChatLocationThread: { _, _ in }, toggleChatSidebarMode: { @@ -1490,6 +1494,9 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { accessibilityTitle = self.presentationData.strings.Attachment_File case .location: accessibilityTitle = self.presentationData.strings.Attachment_Location + case .todo: + //TODO:localize + accessibilityTitle = "To Do List" case .contact: accessibilityTitle = self.presentationData.strings.Attachment_Contact case .poll: diff --git a/submodules/AvatarNode/Sources/PeerAvatar.swift b/submodules/AvatarNode/Sources/PeerAvatar.swift index e4c3bf1fc4..7d1a9c8009 100644 --- a/submodules/AvatarNode/Sources/PeerAvatar.swift +++ b/submodules/AvatarNode/Sources/PeerAvatar.swift @@ -333,7 +333,8 @@ public func peerAvatarImage(postbox: Postbox, network: Network, peerReference: P if let cutoutRect { context.setBlendMode(.copy) context.setFillColor(UIColor.clear.cgColor) - context.fillEllipse(in: cutoutRect.offsetBy(dx: 0.0, dy: size.height - cutoutRect.maxY - cutoutRect.height)) + //TODO:fix + context.fillEllipse(in: cutoutRect) } }) let unroundedImage: UIImage? diff --git a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift index d7e5cc0247..acf349f8f9 100644 --- a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift +++ b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift @@ -176,6 +176,8 @@ public final class BrowserBookmarksScreen: ViewController { }, playShakeAnimation: { }, displayQuickShare: { _, _ ,_ in }, updateChatLocationThread: { _, _ in + }, requestToggleTodoMessageItem: { _, _, _ in + }, displayTodoToggleUnavailable: { _ in }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) diff --git a/submodules/ChatListUI/BUILD b/submodules/ChatListUI/BUILD index b1491734a3..808dfa9b0d 100644 --- a/submodules/ChatListUI/BUILD +++ b/submodules/ChatListUI/BUILD @@ -108,7 +108,7 @@ swift_library( "//submodules/TelegramUI/Components/PeerManagement/OldChannelsController", "//submodules/TelegramUI/Components/TextFieldComponent", "//submodules/TelegramUI/Components/ChatEntityKeyboardInputNode", - "//submodules/ComposePollUI", + "//submodules/TelegramUI/Components/ListComposePollOptionComponent", "//submodules/ChatPresentationInterfaceState", "//submodules/ShimmerEffect:ShimmerEffect", "//submodules/TelegramUI/Components/LottieComponent", diff --git a/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift b/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift index 5c0493ef89..93d9d7bcdc 100644 --- a/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift +++ b/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift @@ -20,7 +20,7 @@ import AsyncDisplayKit import UndoUI import PeerNameColorItem import EntityKeyboard -import ComposePollUI +import ListComposePollOptionComponent import ChatEntityKeyboardInputNode import ComponentFlow import ChatPresentationInterfaceState diff --git a/submodules/ChatListUI/Sources/ItemListFilterTitleInputItem.swift b/submodules/ChatListUI/Sources/ItemListFilterTitleInputItem.swift index b511eaced3..ea0101a66e 100644 --- a/submodules/ChatListUI/Sources/ItemListFilterTitleInputItem.swift +++ b/submodules/ChatListUI/Sources/ItemListFilterTitleInputItem.swift @@ -8,7 +8,7 @@ import TextNodeWithEntities import AccountContext import ItemListUI import ComponentFlow -import ComposePollUI +import ListComposePollOptionComponent import TextFieldComponent public class ItemListFilterTitleInputItem: ListViewItem, ItemListItem { diff --git a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift index 1baf8ed6a5..ba82ca6fae 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift @@ -423,6 +423,20 @@ public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder: if messageText.isEmpty, case let .Loaded(content) = webpage.content { messageText = content.displayUrl } + case let todo as TelegramMediaTodo: + let pollPrefix = "☑️ " + let entityOffset = (pollPrefix as NSString).length + messageText = "\(pollPrefix)\(todo.text)" + for entity in todo.textEntities { + if case let .CustomEmoji(_, fileId) = entity.type { + if customEmojiRanges == nil { + customEmojiRanges = [] + } + let range = NSRange(location: entityOffset + entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound) + let attribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: message.associatedMedia[EngineMedia.Id(namespace: Namespaces.Media.CloudFile, id: fileId)] as? TelegramMediaFile) + customEmojiRanges?.append((range, attribute)) + } + } default: break } diff --git a/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift b/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift index f0566406fa..de76345dfc 100644 --- a/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift +++ b/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift @@ -177,6 +177,7 @@ public final class ChatPanelInterfaceInteraction { public let openBoostToUnrestrict: () -> Void public let updateRecordingTrimRange: (Double, Double, Bool, Bool) -> Void public let dismissAllTooltips: () -> Void + public let editTodoMessage: (MessageId, Bool) -> Void public let requestLayout: (ContainedViewLayoutTransition) -> Void public let chatController: () -> ViewController? public let statuses: ChatPanelInterfaceInteractionStatuses? @@ -293,6 +294,7 @@ public final class ChatPanelInterfaceInteraction { openBoostToUnrestrict: @escaping () -> Void, updateRecordingTrimRange: @escaping (Double, Double, Bool, Bool) -> Void, dismissAllTooltips: @escaping () -> Void, + editTodoMessage: @escaping (MessageId, Bool) -> Void, updateHistoryFilter: @escaping ((ChatPresentationInterfaceState.HistoryFilter?) -> ChatPresentationInterfaceState.HistoryFilter?) -> Void, updateChatLocationThread: @escaping (Int64?, ChatControllerAnimateInnerChatSwitchDirection?) -> Void, toggleChatSidebarMode: @escaping () -> Void, @@ -412,6 +414,7 @@ public final class ChatPanelInterfaceInteraction { self.openBoostToUnrestrict = openBoostToUnrestrict self.updateRecordingTrimRange = updateRecordingTrimRange self.dismissAllTooltips = dismissAllTooltips + self.editTodoMessage = editTodoMessage self.updateHistoryFilter = updateHistoryFilter self.updateChatLocationThread = updateChatLocationThread self.toggleChatSidebarMode = toggleChatSidebarMode @@ -540,6 +543,7 @@ public final class ChatPanelInterfaceInteraction { }, openBoostToUnrestrict: { }, updateRecordingTrimRange: { _, _, _, _ in }, dismissAllTooltips: { + }, editTodoMessage: { _, _ in }, updateHistoryFilter: { _ in }, updateChatLocationThread: { _, _ in }, toggleChatSidebarMode: { diff --git a/submodules/ComposePollUI/BUILD b/submodules/ComposePollUI/BUILD index 209d04ead7..4b1cb85132 100644 --- a/submodules/ComposePollUI/BUILD +++ b/submodules/ComposePollUI/BUILD @@ -41,6 +41,7 @@ swift_library( "//submodules/TelegramUI/Components/PlainButtonComponent", "//submodules/ChatPresentationInterfaceState", "//submodules/TelegramUI/Components/EmojiSuggestionsComponent", + "//submodules/TelegramUI/Components/ListComposePollOptionComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/ComposePollUI/Sources/ComposePollScreen.swift b/submodules/ComposePollUI/Sources/ComposePollScreen.swift index c96f0dd597..92636c4568 100644 --- a/submodules/ComposePollUI/Sources/ComposePollScreen.swift +++ b/submodules/ComposePollUI/Sources/ComposePollScreen.swift @@ -26,6 +26,49 @@ import ChatPresentationInterfaceState import EmojiSuggestionsComponent import TextFormat import TextFieldComponent +import ListComposePollOptionComponent + +public final class ComposedPoll { + public struct Text { + public let string: String + public let entities: [MessageTextEntity] + + public init(string: String, entities: [MessageTextEntity]) { + self.string = string + self.entities = entities + } + } + + public let publicity: TelegramMediaPollPublicity + public let kind: TelegramMediaPollKind + + public let text: Text + public let options: [TelegramMediaPollOption] + public let correctAnswers: [Data]? + public let results: TelegramMediaPollResults + public let deadlineTimeout: Int32? + public let usedCustomEmojiFiles: [Int64: TelegramMediaFile] + + public init( + publicity: TelegramMediaPollPublicity, + kind: TelegramMediaPollKind, + text: Text, + options: [TelegramMediaPollOption], + correctAnswers: [Data]?, + results: TelegramMediaPollResults, + deadlineTimeout: Int32?, + usedCustomEmojiFiles: [Int64: TelegramMediaFile] + ) { + self.publicity = publicity + self.kind = kind + self.text = text + self.options = options + self.correctAnswers = correctAnswers + self.results = results + self.deadlineTimeout = deadlineTimeout + self.usedCustomEmojiFiles = usedCustomEmojiFiles + } +} final class ComposePollScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -1585,7 +1628,7 @@ public class ComposePollScreen: ViewControllerComponentContainer, AttachmentCont maxPollAnwsersCount = Int(value) } return InitialData( - maxPollTextLength: Int(200), + maxPollTextLength: 200, maxPollOptionLength: 100, maxPollAnwsersCount: maxPollAnwsersCount ) diff --git a/submodules/ComposePollUI/Sources/CreatePollController.swift b/submodules/ComposePollUI/Sources/CreatePollController.swift deleted file mode 100644 index 86fb01227b..0000000000 --- a/submodules/ComposePollUI/Sources/CreatePollController.swift +++ /dev/null @@ -1,1245 +0,0 @@ -import Foundation -import UIKit -import Display -import SwiftSignalKit -import TelegramCore -import TelegramPresentationData -import ItemListUI -import PresentationDataUtils -import AccountContext -import AlertUI -import PresentationDataUtils -import TextFormat -import AttachmentUI - -private struct OrderedLinkedListItemOrderingId: RawRepresentable, Hashable { - var rawValue: Int -} - -private struct OrderedLinkedListItemOrdering: Comparable { - var id: OrderedLinkedListItemOrderingId - var lowerItemIds: Set - var higherItemIds: Set - - static func <(lhs: OrderedLinkedListItemOrdering, rhs: OrderedLinkedListItemOrdering) -> Bool { - if rhs.lowerItemIds.contains(lhs.id) { - return true - } - if rhs.higherItemIds.contains(lhs.id) { - return false - } - if lhs.lowerItemIds.contains(rhs.id) { - return false - } - if lhs.higherItemIds.contains(rhs.id) { - return true - } - assertionFailure() - return false - } -} - -private struct OrderedLinkedListItem { - var item: T - var ordering: OrderedLinkedListItemOrdering -} - -private struct OrderedLinkedList: Sequence, Equatable { - private var items: [OrderedLinkedListItem] = [] - private var nextId: Int = 0 - - init(items: [T]) { - for i in 0 ..< items.count { - self.insert(items[i], at: i, id: nil) - } - } - - static func ==(lhs: OrderedLinkedList, rhs: OrderedLinkedList) -> Bool { - if lhs.items.count != rhs.items.count { - return false - } - for i in 0 ..< lhs.items.count { - if lhs.items[i].item != rhs.items[i].item { - return false - } - } - return true - } - - func makeIterator() -> AnyIterator> { - var index = 0 - return AnyIterator { () -> OrderedLinkedListItem? in - if index < self.items.count { - let currentIndex = index - index += 1 - return self.items[currentIndex] - } - return nil - } - } - - subscript(index: Int) -> OrderedLinkedListItem { - return self.items[index] - } - - mutating func update(at index: Int, _ f: (inout T) -> Void) { - f(&self.items[index].item) - } - - var count: Int { - return self.items.count - } - - var isEmpty: Bool { - return self.items.isEmpty - } - - var last: OrderedLinkedListItem? { - return self.items.last - } - - mutating func append(_ item: T, id: OrderedLinkedListItemOrderingId?) { - self.insert(item, at: self.items.count, id: id) - } - - mutating func insert(_ item: T, at index: Int, id: OrderedLinkedListItemOrderingId?) { - let previousId = id - let id = previousId ?? OrderedLinkedListItemOrderingId(rawValue: self.nextId) - self.nextId += 1 - - if let previousId = previousId { - for i in 0 ..< self.items.count { - self.items[i].ordering.higherItemIds.remove(previousId) - self.items[i].ordering.lowerItemIds.remove(previousId) - } - } - - var lowerItemIds = Set() - var higherItemIds = Set() - for i in 0 ..< self.items.count { - if i < index { - lowerItemIds.insert(self.items[i].ordering.id) - self.items[i].ordering.higherItemIds.insert(id) - } else { - higherItemIds.insert(self.items[i].ordering.id) - self.items[i].ordering.lowerItemIds.insert(id) - } - } - - self.items.insert(OrderedLinkedListItem(item: item, ordering: OrderedLinkedListItemOrdering(id: id, lowerItemIds: lowerItemIds, higherItemIds: higherItemIds)), at: index) - } - - mutating func remove(at index: Int) { - self.items.remove(at: index) - } -} - -private let maxTextLength = 200 -private let maxOptionLength = 100 -private let maxOptionCount = 10 - -private func processPollText(_ text: String) -> String { - var text = text.trimmingCharacters(in: .whitespacesAndNewlines) - while text.contains("\n\n\n") { - text = text.replacingOccurrences(of: "\n\n\n", with: "\n\n") - } - return text -} - -private final class CreatePollControllerArguments { - let context: AccountContext - let updatePollText: (String) -> Void - let updateOptionText: (Int, String, Bool) -> Void - let moveToNextOption: (Int) -> Void - let moveToPreviousOption: (Int) -> Void - let removeOption: (Int, Bool) -> Void - let optionFocused: (Int, Bool) -> Void - let setItemIdWithRevealedOptions: (Int?, Int?) -> Void - let toggleOptionSelected: (Int) -> Void - let updateAnonymous: (Bool) -> Void - let updateMultipleChoice: (Bool) -> Void - let displayMultipleChoiceDisabled: () -> Void - let updateQuiz: (Bool) -> Void - let updateSolutionText: (NSAttributedString) -> Void - let solutionTextFocused: (Bool) -> Void - let questionTextFocused: (Bool) -> Void - - init(context: AccountContext, updatePollText: @escaping (String) -> Void, updateOptionText: @escaping (Int, String, Bool) -> Void, moveToNextOption: @escaping (Int) -> Void, moveToPreviousOption: @escaping (Int) -> Void, removeOption: @escaping (Int, Bool) -> Void, optionFocused: @escaping (Int, Bool) -> Void, setItemIdWithRevealedOptions: @escaping (Int?, Int?) -> Void, toggleOptionSelected: @escaping (Int) -> Void, updateAnonymous: @escaping (Bool) -> Void, updateMultipleChoice: @escaping (Bool) -> Void, displayMultipleChoiceDisabled: @escaping () -> Void, updateQuiz: @escaping (Bool) -> Void, updateSolutionText: @escaping (NSAttributedString) -> Void, solutionTextFocused: @escaping (Bool) -> Void, questionTextFocused: @escaping (Bool) -> Void) { - self.context = context - self.updatePollText = updatePollText - self.updateOptionText = updateOptionText - self.moveToNextOption = moveToNextOption - self.moveToPreviousOption = moveToPreviousOption - self.removeOption = removeOption - self.optionFocused = optionFocused - self.setItemIdWithRevealedOptions = setItemIdWithRevealedOptions - self.toggleOptionSelected = toggleOptionSelected - self.updateAnonymous = updateAnonymous - self.updateMultipleChoice = updateMultipleChoice - self.displayMultipleChoiceDisabled = displayMultipleChoiceDisabled - self.updateQuiz = updateQuiz - self.updateSolutionText = updateSolutionText - self.solutionTextFocused = solutionTextFocused - self.questionTextFocused = questionTextFocused - } -} - -private enum CreatePollSection: Int32 { - case text - case options - case settings - case quizSolution -} - -private enum CreatePollEntryId: Hashable { - case textHeader - case text - case optionsHeader - case option(Int) - case optionsInfo - case anonymousVotes - case multipleChoice - case quiz - case quizInfo - case quizSolutionHeader - case quizSolutionText - case quizSolutionInfo -} - -private enum CreatePollEntryTag: Equatable, ItemListItemTag { - case text - case option(Int) - case optionsInfo - case solution - - func isEqual(to other: ItemListItemTag) -> Bool { - if let other = other as? CreatePollEntryTag { - return self == other - } else { - return false - } - } -} - -private struct SolutionText: Equatable { - var value: NSAttributedString - - init(value: NSAttributedString) { - self.value = value - } - - static func ==(lhs: SolutionText, rhs: SolutionText) -> Bool { - return lhs.value.isEqual(to: rhs.value) - } -} - -private enum CreatePollEntry: ItemListNodeEntry { - case textHeader(String, ItemListSectionHeaderAccessoryText) - case text(String, String, Int) - case optionsHeader(String) - case option(id: Int, ordering: OrderedLinkedListItemOrdering, placeholder: String, text: String, revealed: Bool, hasNext: Bool, isLast: Bool, canMove: Bool, isSelected: Bool?) - case optionsInfo(String) - case anonymousVotes(String, Bool) - case multipleChoice(String, Bool, Bool) - case quiz(String, Bool) - case quizInfo(String) - case quizSolutionHeader(String) - case quizSolutionText(placeholder: String, text: SolutionText) - case quizSolutionInfo(String) - - var section: ItemListSectionId { - switch self { - case .textHeader, .text: - return CreatePollSection.text.rawValue - case .optionsHeader, .option, .optionsInfo: - return CreatePollSection.options.rawValue - case .anonymousVotes, .multipleChoice, .quiz, .quizInfo: - return CreatePollSection.settings.rawValue - case .quizSolutionHeader, .quizSolutionText, .quizSolutionInfo: - return CreatePollSection.quizSolution.rawValue - } - } - - var tag: ItemListItemTag? { - switch self { - case .text: - return CreatePollEntryTag.text - case let .option(id, _, _, _, _, _, _, _, _): - return CreatePollEntryTag.option(id) - default: - break - } - return nil - } - - var stableId: CreatePollEntryId { - switch self { - case .textHeader: - return .textHeader - case .text: - return .text - case .optionsHeader: - return .optionsHeader - case let .option(id, _, _, _, _, _, _, _, _): - return .option(id) - case .optionsInfo: - return .optionsInfo - case .anonymousVotes: - return .anonymousVotes - case .multipleChoice: - return .multipleChoice - case .quiz: - return .quiz - case .quizInfo: - return .quizInfo - case .quizSolutionHeader: - return .quizSolutionHeader - case .quizSolutionText: - return .quizSolutionText - case .quizSolutionInfo: - return .quizSolutionInfo - } - } - - private var sortId: Int { - switch self { - case .textHeader: - return 0 - case .text: - return 1 - case .optionsHeader: - return 2 - case .option: - return 3 - case .optionsInfo: - return 1001 - case .anonymousVotes: - return 1002 - case .multipleChoice: - return 1003 - case .quiz: - return 1004 - case .quizInfo: - return 1005 - case .quizSolutionHeader: - return 1006 - case .quizSolutionText: - return 1007 - case .quizSolutionInfo: - return 1008 - } - } - - static func <(lhs: CreatePollEntry, rhs: CreatePollEntry) -> Bool { - switch lhs { - case let .option(_, lhsOrdering, _, _, _, _, _, _, _): - switch rhs { - case let .option(_, rhsOrdering, _, _, _, _, _, _, _): - return lhsOrdering < rhsOrdering - default: - break - } - default: - break - } - return lhs.sortId < rhs.sortId - } - - func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { - let arguments = arguments as! CreatePollControllerArguments - switch self { - case let .textHeader(text, accessoryText): - return ItemListSectionHeaderItem(presentationData: presentationData, text: text, accessoryText: accessoryText, sectionId: self.section) - case let .text(placeholder, text, maxLength): - return ItemListMultilineInputItem(presentationData: presentationData, text: text, placeholder: placeholder, maxLength: ItemListMultilineInputItemTextLimit(value: maxLength, display: false), sectionId: self.section, style: .blocks, textUpdated: { value in - arguments.updatePollText(value) - }, updatedFocus: { value in - arguments.questionTextFocused(value) - }, tag: CreatePollEntryTag.text) - case let .optionsHeader(text): - return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) - case let .option(id, _, placeholder, text, revealed, hasNext, isLast, canMove, isSelected): - return CreatePollOptionItem(presentationData: presentationData, id: id, placeholder: placeholder, value: text, isSelected: isSelected, maxLength: maxOptionLength, editing: CreatePollOptionItemEditing(editable: true, hasActiveRevealControls: revealed), sectionId: self.section, setItemIdWithRevealedOptions: { id, fromId in - arguments.setItemIdWithRevealedOptions(id, fromId) - }, updated: { value, isFocused in - arguments.updateOptionText(id, value, isFocused) - }, next: hasNext ? { - arguments.moveToNextOption(id) - } : nil, delete: { focused in - if !isLast { - arguments.removeOption(id, focused) - } else { - arguments.moveToPreviousOption(id) - } - }, canDelete: !isLast, - canMove: canMove, - focused: { isFocused in - arguments.optionFocused(id, isFocused) - }, toggleSelected: { - arguments.toggleOptionSelected(id) - }, tag: CreatePollEntryTag.option(id)) - case let .optionsInfo(text): - return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section, tag: CreatePollEntryTag.optionsInfo) - case let .anonymousVotes(text, value): - return ItemListSwitchItem(presentationData: presentationData, title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateAnonymous(value) - }) - case let .multipleChoice(text, value, enabled): - return ItemListSwitchItem(presentationData: presentationData, title: text, value: value, enabled: enabled, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateMultipleChoice(value) - }, activatedWhileDisabled: { - arguments.displayMultipleChoiceDisabled() - }) - case let .quiz(text, value): - return ItemListSwitchItem(presentationData: presentationData, title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in - arguments.updateQuiz(value) - }) - case let .quizInfo(text): - return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) - case let .quizSolutionHeader(text): - return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) - case let .quizSolutionText(placeholder, text): - return CreatePollTextInputItem(context: arguments.context, presentationData: presentationData, text: text.value, placeholder: placeholder, maxLength: CreatePollTextInputItemTextLimit(value: 200, display: true), sectionId: self.section, style: .blocks, textUpdated: { text in - arguments.updateSolutionText(text) - }, updatedFocus: { value in - arguments.solutionTextFocused(value) - }, tag: CreatePollEntryTag.solution) - case let .quizSolutionInfo(text): - return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) - } - } -} - -private struct CreatePollControllerOption: Equatable { - var text: String - let id: Int - var isSelected: Bool -} - -private struct CreatePollControllerState: Equatable { - var text: String = "" - var options = OrderedLinkedList(items: [CreatePollControllerOption(text: "", id: 0, isSelected: false), CreatePollControllerOption(text: "", id: 1, isSelected: false)]) - var nextOptionId: Int = 2 - var focusOptionId: Int? - var optionIdWithRevealControls: Int? - var isAnonymous: Bool = true - var isMultipleChoice: Bool = false - var isQuiz: Bool = false - var solutionText: SolutionText = SolutionText(value: NSAttributedString(string: "")) - var isEditingSolution: Bool = false -} - -private func createPollControllerEntries(presentationData: PresentationData, peer: EnginePeer, state: CreatePollControllerState, limitsConfiguration: EngineConfiguration.UserLimits, defaultIsQuiz: Bool?) -> [CreatePollEntry] { - var entries: [CreatePollEntry] = [] - - var textLimitText = ItemListSectionHeaderAccessoryText(value: "", color: .generic) - if state.text.count >= Int(maxTextLength) * 70 / 100 { - let remainingCount = Int(maxTextLength) - state.text.count - textLimitText = ItemListSectionHeaderAccessoryText(value: "\(remainingCount)", color: remainingCount < 0 ? .destructive : .generic) - } - entries.append(.textHeader(presentationData.strings.CreatePoll_TextHeader, textLimitText)) - entries.append(.text(presentationData.strings.CreatePoll_TextPlaceholder, state.text, Int(limitsConfiguration.maxCaptionLength))) - let optionsHeaderTitle: String - if let defaultIsQuiz = defaultIsQuiz, defaultIsQuiz { - optionsHeaderTitle = presentationData.strings.CreatePoll_QuizOptionsHeader - } else { - optionsHeaderTitle = presentationData.strings.CreatePoll_OptionsHeader - } - entries.append(.optionsHeader(optionsHeaderTitle)) - for i in 0 ..< state.options.count { - let isSecondLast = state.options.count == 2 && i == 0 - let isLast = i == state.options.count - 1 - let option = state.options[i].item - entries.append(.option(id: option.id, ordering: state.options[i].ordering, placeholder: isLast ? presentationData.strings.CreatePoll_AddOption : presentationData.strings.CreatePoll_OptionPlaceholder, text: option.text, revealed: state.optionIdWithRevealControls == option.id, hasNext: i != 9, isLast: isLast || isSecondLast, canMove: !isLast || state.options.count == 10, isSelected: state.isQuiz ? option.isSelected : nil)) - } - if state.options.count < maxOptionCount { - entries.append(.optionsInfo(presentationData.strings.CreatePoll_AddMoreOptions(Int32(maxOptionCount - state.options.count)))) - } else { - entries.append(.optionsInfo(presentationData.strings.CreatePoll_AllOptionsAdded)) - } - - var canBePublic = true - if case let .channel(channel) = peer, case .broadcast = channel.info { - canBePublic = false - } - - if canBePublic { - entries.append(.anonymousVotes(presentationData.strings.CreatePoll_Anonymous, state.isAnonymous)) - } - var isQuiz = false - if let defaultIsQuiz = defaultIsQuiz { - if !defaultIsQuiz { - entries.append(.multipleChoice(presentationData.strings.CreatePoll_MultipleChoice, state.isMultipleChoice && !state.isQuiz, !state.isQuiz)) - } else { - isQuiz = true - } - } else { - entries.append(.multipleChoice(presentationData.strings.CreatePoll_MultipleChoice, state.isMultipleChoice && !state.isQuiz, !state.isQuiz)) - entries.append(.quiz(presentationData.strings.CreatePoll_Quiz, state.isQuiz)) - entries.append(.quizInfo(presentationData.strings.CreatePoll_QuizInfo)) - isQuiz = state.isQuiz - } - - if isQuiz { - entries.append(.quizSolutionHeader(presentationData.strings.CreatePoll_ExplanationHeader)) - entries.append(.quizSolutionText(placeholder: presentationData.strings.CreatePoll_Explanation, text: state.solutionText)) - entries.append(.quizSolutionInfo(presentationData.strings.CreatePoll_ExplanationInfo)) - } - - return entries -} - -public final class ComposedPoll { - public struct Text { - public let string: String - public let entities: [MessageTextEntity] - - public init(string: String, entities: [MessageTextEntity]) { - self.string = string - self.entities = entities - } - } - - public let publicity: TelegramMediaPollPublicity - public let kind: TelegramMediaPollKind - - public let text: Text - public let options: [TelegramMediaPollOption] - public let correctAnswers: [Data]? - public let results: TelegramMediaPollResults - public let deadlineTimeout: Int32? - public let usedCustomEmojiFiles: [Int64: TelegramMediaFile] - - public init( - publicity: TelegramMediaPollPublicity, - kind: TelegramMediaPollKind, - text: Text, - options: [TelegramMediaPollOption], - correctAnswers: [Data]?, - results: TelegramMediaPollResults, - deadlineTimeout: Int32?, - usedCustomEmojiFiles: [Int64: TelegramMediaFile] - ) { - self.publicity = publicity - self.kind = kind - self.text = text - self.options = options - self.correctAnswers = correctAnswers - self.results = results - self.deadlineTimeout = deadlineTimeout - self.usedCustomEmojiFiles = usedCustomEmojiFiles - } -} - -private final class CreatePollContext: AttachmentMediaPickerContext { - -} - - -public class CreatePollControllerImpl: ItemListController, AttachmentContainable { - 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 isMinimized: Bool = false - - public var mediaPickerContext: AttachmentMediaPickerContext? { - return CreatePollContext() - } - - fileprivate var stateValue: Atomic? - - private var hasContent: Bool { - if let stateValue { - let state = stateValue.with { $0 } - var hasNonEmptyOptions = false - for i in 0 ..< state.options.count { - let optionText = state.options[i].item.text.trimmingCharacters(in: .whitespacesAndNewlines) - if !optionText.isEmpty { - hasNonEmptyOptions = true - } - } - if hasNonEmptyOptions || !state.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return true - } else { - return false - } - } else { - return false - } - } - - var context: AccountContext? - public func requestDismiss(completion: @escaping () -> Void) { - if self.hasContent, let context = self.context { - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - self.present(textAlertController(context: context, updatedPresentationData: nil, title: nil, text: presentationData.strings.CreatePoll_CancelConfirmation, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_No, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Yes, action: { - completion() - })]), in: .window(.root)) - } else { - completion() - } - } - - public func shouldDismissImmediately() -> Bool { - return !self.hasContent - } -} - -public func createPollController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, peer: EnginePeer, isQuiz: Bool? = nil, completion: @escaping (ComposedPoll) -> Void) -> ViewController { - return ComposePollScreen( - context: context, - initialData: ComposePollScreen.initialData(context: context), - peer: peer, - isQuiz: isQuiz, - completion: completion - ) -} - -private func legacyCreatePollController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, peer: EnginePeer, isQuiz: Bool?, completion: @escaping (ComposedPoll) -> Void) -> ViewController { - var initialState = CreatePollControllerState() - if let isQuiz = isQuiz { - initialState.isQuiz = isQuiz - } - let statePromise = ValuePromise(initialState, ignoreRepeated: true) - let stateValue = Atomic(value: initialState) - let updateState: ((CreatePollControllerState) -> CreatePollControllerState) -> Void = { f in - statePromise.set(stateValue.modify { f($0) }) - } - - var presentControllerImpl: ((ViewController, Any?) -> Void)? - var dismissImpl: (() -> Void)? - var dismissInputImpl: (() -> Void)? - var ensureTextVisibleImpl: (() -> Void)? - var ensureOptionVisibleImpl: ((Int) -> Void)? - var ensureSolutionVisibleImpl: (() -> Void)? - var ensureQuestionVisibleImpl: (() -> Void)? - var displayQuizTooltipImpl: ((Bool) -> Void)? - - let actionsDisposable = DisposableSet() - - let checkAddressNameDisposable = MetaDisposable() - actionsDisposable.add(checkAddressNameDisposable) - - let updateAddressNameDisposable = MetaDisposable() - actionsDisposable.add(updateAddressNameDisposable) - - let arguments = CreatePollControllerArguments(context: context, updatePollText: { value in - updateState { state in - var state = state - state.focusOptionId = nil - state.text = value - state.isEditingSolution = false - return state - } - ensureTextVisibleImpl?() - }, updateOptionText: { id, value, isFocused in - var ensureVisibleId = id - updateState { state in - var state = state - for i in 0 ..< state.options.count { - if state.options[i].item.id == id { - if isFocused { - state.focusOptionId = id - } - state.options.update(at: i, { option in - option.text = value - }) - if !value.isEmpty && i == state.options.count - 1 && state.options.count < maxOptionCount { - state.options.append(CreatePollControllerOption(text: "", id: state.nextOptionId, isSelected: false), id: nil) - state.nextOptionId += 1 - } - if i != state.options.count - 1 { - ensureVisibleId = state.options[i + 1].item.id - } - break - } - } - return state - } - if isFocused { - ensureOptionVisibleImpl?(ensureVisibleId) - } - }, moveToNextOption: { id in - var resetFocusOptionId: Int? - updateState { state in - var state = state - for i in 0 ..< state.options.count { - if state.options[i].item.id == id { - if i == state.options.count - 1 { - /*state.options.append(CreatePollControllerOption(text: "", id: state.nextOptionId, isSelected: false)) - state.focusOptionId = state.nextOptionId - state.nextOptionId += 1*/ - } else { - if state.focusOptionId == state.options[i + 1].item.id { - resetFocusOptionId = state.options[i + 1].item.id - state.focusOptionId = -1 - } else { - state.focusOptionId = state.options[i + 1].item.id - } - } - break - } - } - return state - } - if let resetFocusOptionId = resetFocusOptionId { - updateState { state in - var state = state - state.focusOptionId = resetFocusOptionId - return state - } - } - }, moveToPreviousOption: { id in - var resetFocusOptionId: Int? - updateState { state in - var state = state - for i in 0 ..< state.options.count { - if state.options[i].item.id == id { - if i != 0 { - if state.focusOptionId == state.options[i - 1].item.id { - resetFocusOptionId = state.options[i - 1].item.id - state.focusOptionId = -1 - } else { - state.focusOptionId = state.options[i - 1].item.id - } - } - break - } - } - return state - } - if let resetFocusOptionId = resetFocusOptionId { - updateState { state in - var state = state - state.focusOptionId = resetFocusOptionId - return state - } - } - }, removeOption: { id, focused in - updateState { state in - var state = state - for i in 0 ..< state.options.count { - if state.options[i].item.id == id { - state.options.remove(at: i) - if focused && i != 0 { - state.focusOptionId = state.options[i - 1].item.id - } - break - } - } - let focusOnFirst = state.options.isEmpty - if state.options.count < 2 { - for i in 0 ..< (2 - state.options.count) { - if i == 0 && focusOnFirst { - state.options.append(CreatePollControllerOption(text: "", id: state.nextOptionId, isSelected: false), id: nil) - state.focusOptionId = state.nextOptionId - state.nextOptionId += 1 - } else { - state.options.append(CreatePollControllerOption(text: "", id: state.nextOptionId, isSelected: false), id: nil) - state.nextOptionId += 1 - } - } - } - return state - } - }, optionFocused: { id, isFocused in - if isFocused { - ensureOptionVisibleImpl?(id) - } else { - updateState { state in - var state = state - if state.options.count > 2 { - for i in 0 ..< state.options.count { - if state.options[i].item.id == id { - if state.options[i].item.text.isEmpty && i != state.options.count - 1 { - state.options.remove(at: i) - } - break - } - } - } - return state - } - } - }, setItemIdWithRevealedOptions: { id, fromId in - updateState { state in - var state = state - if (id == nil && fromId == state.optionIdWithRevealControls) || (id != nil && fromId == nil) { - state.optionIdWithRevealControls = id - return state - } else { - return state - } - } - }, toggleOptionSelected: { id in - updateState { state in - var state = state - for i in 0 ..< state.options.count { - if state.options[i].item.id == id { - state.options.update(at: i, { option in - option.isSelected = !option.isSelected - }) - if state.options[i].item.isSelected && state.isQuiz { - for j in 0 ..< state.options.count { - if i != j { - state.options.update(at: j, { option in - option.isSelected = false - }) - } - } - } - break - } - } - return state - } - }, updateAnonymous: { value in - updateState { state in - var state = state - state.focusOptionId = -1 - state.isAnonymous = value - return state - } - }, updateMultipleChoice: { value in - updateState { state in - var state = state - state.focusOptionId = -1 - state.isMultipleChoice = value - return state - } - }, displayMultipleChoiceDisabled: { - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - presentControllerImpl?(textAlertController(context: context, updatedPresentationData: updatedPresentationData, title: nil, text: presentationData.strings.CreatePoll_MultipleChoiceQuizAlert, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {})]), nil) - }, updateQuiz: { value in - if !value { - displayQuizTooltipImpl?(value) - } - updateState { state in - var state = state - state.focusOptionId = -1 - state.isQuiz = value - if value { - state.isMultipleChoice = false - var foundSelectedOption = false - for i in 0 ..< state.options.count { - if state.options[i].item.isSelected { - if !foundSelectedOption { - foundSelectedOption = true - } else { - state.options.update(at: i, { option in - option.isSelected = false - }) - } - } - } - } - return state - } - if value { - displayQuizTooltipImpl?(value) - } - }, updateSolutionText: { text in - updateState { state in - var state = state - state.solutionText = SolutionText(value: text) - state.focusOptionId = nil - state.isEditingSolution = true - return state - } - ensureSolutionVisibleImpl?() - }, solutionTextFocused: { isFocused in - if isFocused { - ensureSolutionVisibleImpl?() - } - }, questionTextFocused: { isFocused in - if isFocused { - ensureQuestionVisibleImpl?() - } - }) - - let previousOptionIds = Atomic<[Int]?>(value: nil) - - let presentationData = updatedPresentationData?.signal ?? context.sharedContext.presentationData - let signal = combineLatest(queue: .mainQueue(), - presentationData, - statePromise.get(), - context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: false)) - ) - |> map { presentationData, state, limitsConfiguration -> (ItemListControllerState, (ItemListNodeState, Any)) in - var presentationData = presentationData - - let updatedTheme = presentationData.theme.withModalBlocksBackground() - presentationData = presentationData.withUpdated(theme: updatedTheme) - - var enabled = true - if processPollText(state.text).isEmpty { - enabled = false - } - if state.text.count > maxTextLength { - enabled = false - } - var nonEmptyOptionCount = 0 - var hasSelectedOptions = false - for option in state.options { - if !option.item.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - nonEmptyOptionCount += 1 - } - if option.item.text.count > maxOptionLength { - enabled = false - } - if option.item.isSelected { - hasSelectedOptions = true - } - if state.isQuiz { - if option.item.text.isEmpty && option.item.isSelected { - enabled = false - } - } - } - if state.isQuiz { - if !hasSelectedOptions { - enabled = false - } - if state.solutionText.value.string.count > 200 { - enabled = false - } - } - if nonEmptyOptionCount < 2 { - enabled = false - } - var rightNavigationButton: ItemListNavigationButton? - rightNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.CreatePoll_Create), style: .bold, enabled: enabled, action: { - let state = stateValue.with { $0 } - var options: [TelegramMediaPollOption] = [] - var correctAnswers: [Data]? - for i in 0 ..< state.options.count { - let optionText = state.options[i].item.text.trimmingCharacters(in: .whitespacesAndNewlines) - if !optionText.isEmpty { - let optionData = "\(i)".data(using: .utf8)! - options.append(TelegramMediaPollOption(text: optionText, entities: [], opaqueIdentifier: optionData)) - if state.isQuiz && state.options[i].item.isSelected { - correctAnswers = [optionData] - } - } - } - let publicity: TelegramMediaPollPublicity - if state.isAnonymous { - publicity = .anonymous - } else { - publicity = .public - } - var resolvedSolution: TelegramMediaPollResults.Solution? - let kind: TelegramMediaPollKind - if state.isQuiz { - kind = .quiz - if !state.solutionText.value.string.isEmpty { - let entities = generateTextEntities(state.solutionText.value.string, enabledTypes: .allUrl, currentEntities: generateChatInputTextEntities(state.solutionText.value)) - resolvedSolution = TelegramMediaPollResults.Solution(text: state.solutionText.value.string, entities: entities) - } - } else { - kind = .poll(multipleAnswers: state.isMultipleChoice) - } - - let deadlineTimeout: Int32? = nil - - dismissImpl?() - - completion(ComposedPoll( - publicity: publicity, - kind: kind, - text: ComposedPoll.Text(string: processPollText(state.text), entities: []), - options: options, - correctAnswers: correctAnswers, - results: TelegramMediaPollResults(voters: nil, totalVoters: nil, recentVoters: [], solution: resolvedSolution), - deadlineTimeout: deadlineTimeout, - usedCustomEmojiFiles: [:] - )) - }) - - let leftNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.Common_Cancel), style: .regular, enabled: true, action: { - dismissImpl?() - }) - - let optionIds = state.options.map { $0.item.id } - let previousIds = previousOptionIds.swap(optionIds) - - var focusItemTag: ItemListItemTag? - var ensureVisibleItemTag: ItemListItemTag? - if let focusOptionId = state.focusOptionId { - focusItemTag = CreatePollEntryTag.option(focusOptionId) - if focusOptionId == state.options.last?.item.id { - ensureVisibleItemTag = nil - } else { - ensureVisibleItemTag = focusItemTag - } - } else if state.isEditingSolution { - focusItemTag = CreatePollEntryTag.solution - ensureVisibleItemTag = focusItemTag - } else { -// focusItemTag = CreatePollEntryTag.text -// ensureVisibleItemTag = focusItemTag - } - - let title: String - if let isQuiz = isQuiz, isQuiz { - title = presentationData.strings.CreatePoll_QuizTitle - } else { - title = presentationData.strings.CreatePoll_Title - } - - let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(title), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: false) - let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: createPollControllerEntries(presentationData: presentationData, peer: peer, state: state, limitsConfiguration: limitsConfiguration, defaultIsQuiz: isQuiz), style: .blocks, focusItemTag: focusItemTag, ensureVisibleItemTag: ensureVisibleItemTag, animateChanges: previousIds != nil) - - return (controllerState, (listState, arguments)) - } - |> afterDisposed { - actionsDisposable.dispose() - } - - weak var currentTooltipController: TooltipController? - let controller = CreatePollControllerImpl(context: context, state: signal) - controller.context = context - controller.stateValue = stateValue - controller.navigationPresentation = .modal - controller.visibleBottomContentOffsetChanged = { [weak controller] _ in - controller?.updateTabBarAlpha(1.0, .immediate) - } - presentControllerImpl = { [weak controller] c, a in - controller?.present(c, in: .window(.root), with: a) - } - dismissImpl = { [weak controller] in - controller?.dismiss() - } - ensureTextVisibleImpl = { [weak controller] in - controller?.afterLayout({ - guard let controller = controller else { - return - } - - var resultItemNode: ListViewItemNode? - let _ = controller.frameForItemNode({ itemNode in - if let itemNode = itemNode as? ItemListItemNode { - if let tag = itemNode.tag, tag.isEqual(to: CreatePollEntryTag.text) { - resultItemNode = itemNode as? ListViewItemNode - return true - } - } - return false - }) - if let resultItemNode = resultItemNode { - controller.ensureItemNodeVisible(resultItemNode) - } - }) - } - ensureSolutionVisibleImpl = { [weak controller] in - controller?.afterLayout({ - guard let controller = controller else { - return - } - - controller.requestAttachmentMenuExpansion() - - var resultItemNode: ListViewItemNode? - let _ = controller.frameForItemNode({ itemNode in - if let itemNode = itemNode as? ItemListItemNode { - if let tag = itemNode.tag, tag.isEqual(to: CreatePollEntryTag.solution) { - resultItemNode = itemNode as? ListViewItemNode - return true - } - } - return false - }) - if let resultItemNode = resultItemNode { - controller.ensureItemNodeVisible(resultItemNode) - } - }) - } - ensureQuestionVisibleImpl = { [weak controller] in - controller?.afterLayout({ - guard let controller = controller else { - return - } - controller.requestAttachmentMenuExpansion() - }) - } - ensureOptionVisibleImpl = { [weak controller] id in - controller?.afterLayout({ - guard let controller = controller else { - return - } - - controller.requestAttachmentMenuExpansion() - - var resultItemNode: ListViewItemNode? - let state = stateValue.with({ $0 }) - var isLast = false - if state.options.last?.item.id == id { - isLast = true - } - if resultItemNode == nil { - let _ = controller.frameForItemNode({ itemNode in - if let itemNode = itemNode as? ItemListItemNode, let tag = itemNode.tag { - if isLast { - if tag.isEqual(to: CreatePollEntryTag.optionsInfo) { - resultItemNode = itemNode as? ListViewItemNode - return true - } - } else { - if tag.isEqual(to: CreatePollEntryTag.option(id)) { - resultItemNode = itemNode as? ListViewItemNode - return true - } - } - } - return false - }) - } - - if let resultItemNode = resultItemNode { - controller.ensureItemNodeVisible(resultItemNode) - } - }) - } - displayQuizTooltipImpl = { [weak controller] display in - guard let controller = controller else { - return - } - var resultItemNode: CreatePollOptionItemNode? - let insets = controller.listInsets - let _ = controller.frameForItemNode({ itemNode in - if resultItemNode == nil, let itemNode = itemNode as? CreatePollOptionItemNode { - if itemNode.frame.minY >= insets.top { - resultItemNode = itemNode - return true - } - } - return false - }) - if let resultItemNode = resultItemNode, let localCheckNodeFrame = resultItemNode.checkNodeFrame { - let checkNodeFrame = resultItemNode.view.convert(localCheckNodeFrame, to: controller.view) - if display { - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let tooltipController = TooltipController(content: .text(presentationData.strings.CreatePoll_QuizTip), baseFontSize: presentationData.listsFontSize.baseDisplaySize, dismissByTapOutside: true) - controller.present(tooltipController, in: .window(.root), with: TooltipControllerPresentationArguments(sourceViewAndRect: { [weak controller] in - if let controller = controller { - return (controller.view, checkNodeFrame.insetBy(dx: 0.0, dy: 0.0)) - } - return nil - })) - tooltipController.displayNode.layer.animatePosition(from: CGPoint(x: -checkNodeFrame.maxX, y: 0.0), to: CGPoint(), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - currentTooltipController = tooltipController - } else if let tooltipController = currentTooltipController{ - currentTooltipController = nil - tooltipController.displayNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: -checkNodeFrame.maxX, y: 0.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) - } - } - } - controller.setReorderEntry({ (fromIndex: Int, toIndex: Int, entries: [CreatePollEntry]) -> Signal in - let fromEntry = entries[fromIndex] - guard case let .option(id, _, _, _, _, _, _, _, _) = fromEntry else { - return .single(false) - } - var referenceId: Int? - var beforeAll = false - var afterAll = false - if toIndex < entries.count { - switch entries[toIndex] { - case let .option(toId, _, _, _, _, _, _, _, _): - referenceId = toId - default: - if entries[toIndex] < fromEntry { - beforeAll = true - } else { - afterAll = true - } - } - } else { - afterAll = true - } - - var didReorder = false - - updateState { state in - var state = state - var options = state.options - var reorderOption: OrderedLinkedListItem? - var previousIndex: Int? - for i in 0 ..< options.count { - if options[i].item.id == id { - reorderOption = options[i] - previousIndex = i - options.remove(at: i) - break - } - } - if let reorderOption = reorderOption { - if let referenceId = referenceId { - var inserted = false - for i in 0 ..< options.count - 1 { - if options[i].item.id == referenceId { - if fromIndex < toIndex { - didReorder = previousIndex != i + 1 - options.insert(reorderOption.item, at: i + 1, id: reorderOption.ordering.id) - } else { - didReorder = previousIndex != i - options.insert(reorderOption.item, at: i, id: reorderOption.ordering.id) - } - inserted = true - break - } - } - if !inserted { - if options.count >= 2 { - didReorder = previousIndex != options.count - 1 - options.insert(reorderOption.item, at: options.count - 1, id: reorderOption.ordering.id) - } else { - didReorder = previousIndex != options.count - options.append(reorderOption.item, id: reorderOption.ordering.id) - - if options.count < maxOptionCount { - options.append(CreatePollControllerOption(text: "", id: state.nextOptionId, isSelected: false), id: nil) - state.nextOptionId += 1 - } - } - } - } else if beforeAll { - didReorder = previousIndex != 0 - options.insert(reorderOption.item, at: 0, id: reorderOption.ordering.id) - } else if afterAll { - if options.count >= 2 { - didReorder = previousIndex != options.count - 1 - options.insert(reorderOption.item, at: options.count - 1, id: reorderOption.ordering.id) - } else { - didReorder = previousIndex != options.count - options.append(reorderOption.item, id: reorderOption.ordering.id) - } - } - state.options = options - } - return state - } - - if didReorder { - DispatchQueue.main.async { - dismissInputImpl?() - } - } - - return .single(didReorder) - }) - dismissInputImpl = { [weak controller] in - controller?.view.endEditing(true) - } - controller.acceptsFocusWhenInOverlay = true - controller.experimentalSnapScrollToItem = true - controller.alwaysSynchronous = true - - return controller -} diff --git a/submodules/ComposePollUI/Sources/CreatePollOptionActionItem.swift b/submodules/ComposePollUI/Sources/CreatePollOptionActionItem.swift deleted file mode 100644 index 35f7a14604..0000000000 --- a/submodules/ComposePollUI/Sources/CreatePollOptionActionItem.swift +++ /dev/null @@ -1,274 +0,0 @@ -import Foundation -import UIKit -import Display -import AsyncDisplayKit -import SwiftSignalKit -import TelegramPresentationData -import ItemListUI -import PresentationDataUtils - -class CreatePollOptionActionItem: ListViewItem, ItemListItem { - let theme: PresentationTheme - let title: String - let enabled: Bool - let tag: ItemListItemTag? - let sectionId: ItemListSectionId - let action: () -> Void - - init(theme: PresentationTheme, title: String, enabled: Bool, tag: ItemListItemTag?, sectionId: ItemListSectionId, action: @escaping () -> Void) { - self.theme = theme - self.title = title - self.enabled = enabled - self.tag = tag - self.sectionId = sectionId - self.action = action - } - - func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { - async { - let node = CreatePollOptionActionItemNode() - let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) - - node.contentSize = layout.contentSize - node.insets = layout.insets - - Queue.mainQueue().async { - completion(node, { - return (nil, { _ in apply(false) }) - }) - } - } - } - - func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { - Queue.mainQueue().async { - if let nodeValue = node() as? CreatePollOptionActionItemNode { - let makeLayout = nodeValue.asyncLayout() - - var animated = true - if case .None = animation { - animated = false - } - - async { - let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) - Queue.mainQueue().async { - completion(layout, { _ in - apply(animated) - }) - } - } - } - } - } - - var selectable: Bool { - return self.enabled - } - - func selected(listView: ListView){ - listView.clearHighlightAnimated(true) - self.action() - } -} - -private let titleFont = Font.regular(17.0) - -class CreatePollOptionActionItemNode: ListViewItemNode, ItemListItemNode { - private let backgroundNode: ASDisplayNode - private let topStripeNode: ASDisplayNode - private let bottomStripeNode: ASDisplayNode - private let highlightedBackgroundNode: ASDisplayNode - private let maskNode: ASImageNode - - private let iconNode: ASImageNode - private let titleNode: TextNode - - private var item: CreatePollOptionActionItem? - - var tag: ItemListItemTag? { - return self.item?.tag - } - - init() { - self.backgroundNode = ASDisplayNode() - self.backgroundNode.isLayerBacked = true - - self.topStripeNode = ASDisplayNode() - self.topStripeNode.isLayerBacked = true - - self.bottomStripeNode = ASDisplayNode() - self.bottomStripeNode.isLayerBacked = true - - self.maskNode = ASImageNode() - - self.iconNode = ASImageNode() - self.iconNode.isLayerBacked = true - self.iconNode.displayWithoutProcessing = true - self.iconNode.displaysAsynchronously = false - - self.titleNode = TextNode() - self.titleNode.isUserInteractionEnabled = false - self.titleNode.contentMode = .left - self.titleNode.contentsScale = UIScreen.main.scale - - self.highlightedBackgroundNode = ASDisplayNode() - self.highlightedBackgroundNode.isLayerBacked = true - - super.init(layerBacked: false, dynamicBounce: false) - - self.addSubnode(self.iconNode) - self.addSubnode(self.titleNode) - } - - func asyncLayout() -> (_ item: CreatePollOptionActionItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) { - let makeTitleLayout = TextNode.asyncLayout(self.titleNode) - - let currentItem = self.item - - return { item, params, neighbors in - var updatedTheme: PresentationTheme? - var updatedIcon: UIImage? - - if currentItem?.theme !== item.theme { - updatedTheme = item.theme - updatedIcon = PresentationResourcesItemList.addPhoneIcon(item.theme) - } - let leftInset: CGFloat = 60.0 + params.leftInset - - let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: titleFont, textColor: item.enabled ? item.theme.list.itemAccentColor : item.theme.list.itemDisabledTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - 8.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - - let separatorHeight = UIScreenPixel - - let insets = itemListNeighborsGroupedInsets(neighbors, params) - let contentSize = CGSize(width: params.width, height: 44.0) - - let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) - let layoutSize = layout.size - - return (layout, { [weak self] animated in - if let strongSelf = self { - strongSelf.item = item - - if let _ = updatedTheme { - strongSelf.topStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor - strongSelf.bottomStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor - strongSelf.backgroundNode.backgroundColor = item.theme.list.itemBlocksBackgroundColor - strongSelf.highlightedBackgroundNode.backgroundColor = item.theme.list.itemHighlightedBackgroundColor - } - - let _ = titleApply() - - let transition: ContainedViewLayoutTransition - if animated { - transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) - } else { - transition = .immediate - } - - if let updatedIcon = updatedIcon { - strongSelf.iconNode.image = updatedIcon - } - if let image = strongSelf.iconNode.image { - transition.updateFrame(node: strongSelf.iconNode, frame: CGRect(origin: CGPoint(x: params.leftInset + floor((leftInset - params.leftInset - image.size.width) / 2.0 - 3.0), y: floor((contentSize.height - image.size.height) / 2.0)), size: image.size)) - } - - if strongSelf.backgroundNode.supernode == nil { - strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) - } - if strongSelf.topStripeNode.supernode == nil { - strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) - } - if strongSelf.bottomStripeNode.supernode == nil { - strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) - } - if strongSelf.maskNode.supernode == nil { - strongSelf.insertSubnode(strongSelf.maskNode, at: 3) - } - - let hasCorners = itemListHasRoundedBlockLayout(params) - var hasTopCorners = false - var hasBottomCorners = false - switch neighbors.top { - case .sameSection(false): - strongSelf.topStripeNode.isHidden = true - default: - hasTopCorners = true - strongSelf.topStripeNode.isHidden = hasCorners - } - - let bottomStripeInset: CGFloat - let bottomStripeOffset: CGFloat - switch neighbors.bottom { - case .sameSection(false): - bottomStripeInset = leftInset - bottomStripeOffset = -separatorHeight - strongSelf.bottomStripeNode.isHidden = false - default: - bottomStripeInset = 0.0 - bottomStripeOffset = 0.0 - hasBottomCorners = true - strongSelf.bottomStripeNode.isHidden = hasCorners - } - - strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil - - strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) - strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) - strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) - transition.updateFrame(node: strongSelf.bottomStripeNode, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight))) - - transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: leftInset, y: 11.0), size: titleLayout.size)) - - strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: strongSelf.backgroundNode.frame.height + UIScreenPixel + UIScreenPixel)) - } - }) - } - } - - override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { - super.setHighlighted(highlighted, at: point, animated: animated) - - if highlighted { - self.highlightedBackgroundNode.alpha = 1.0 - if self.highlightedBackgroundNode.supernode == nil { - var anchorNode: ASDisplayNode? - if self.bottomStripeNode.supernode != nil { - anchorNode = self.bottomStripeNode - } else if self.topStripeNode.supernode != nil { - anchorNode = self.topStripeNode - } else if self.backgroundNode.supernode != nil { - anchorNode = self.backgroundNode - } - if let anchorNode = anchorNode { - self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode) - } else { - self.addSubnode(self.highlightedBackgroundNode) - } - } - } else { - if self.highlightedBackgroundNode.supernode != nil { - if animated { - self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in - if let strongSelf = self { - if completed { - strongSelf.highlightedBackgroundNode.removeFromSupernode() - } - } - }) - self.highlightedBackgroundNode.alpha = 0.0 - } else { - self.highlightedBackgroundNode.removeFromSupernode() - } - } - } - } - - override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { - self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) - } - - override func animateRemoved(_ currentTimestamp: Double, duration: Double) { - self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) - } -} diff --git a/submodules/ComposePollUI/Sources/CreatePollOptionItem.swift b/submodules/ComposePollUI/Sources/CreatePollOptionItem.swift deleted file mode 100644 index 891510a807..0000000000 --- a/submodules/ComposePollUI/Sources/CreatePollOptionItem.swift +++ /dev/null @@ -1,539 +0,0 @@ -import Foundation -import UIKit -import Display -import AsyncDisplayKit -import SwiftSignalKit -import TelegramPresentationData -import ItemListUI -import PresentationDataUtils -import CheckNode - -struct CreatePollOptionItemEditing { - let editable: Bool - let hasActiveRevealControls: Bool -} - -class CreatePollOptionItem: ListViewItem, ItemListItem { - let presentationData: ItemListPresentationData - let id: Int - let placeholder: String - let value: String - let isSelected: Bool? - let maxLength: Int - let editing: CreatePollOptionItemEditing - let sectionId: ItemListSectionId - let setItemIdWithRevealedOptions: (Int?, Int?) -> Void - let updated: (String, Bool) -> Void - let next: (() -> Void)? - let delete: (Bool) -> Void - let canDelete: Bool - let canMove: Bool - let focused: (Bool) -> Void - let toggleSelected: () -> Void - let tag: ItemListItemTag? - - init(presentationData: ItemListPresentationData, id: Int, placeholder: String, value: String, isSelected: Bool?, maxLength: Int, editing: CreatePollOptionItemEditing, sectionId: ItemListSectionId, setItemIdWithRevealedOptions: @escaping (Int?, Int?) -> Void, updated: @escaping (String, Bool) -> Void, next: (() -> Void)?, delete: @escaping (Bool) -> Void, canDelete: Bool, canMove: Bool, focused: @escaping (Bool) -> Void, toggleSelected: @escaping () -> Void, tag: ItemListItemTag?) { - self.presentationData = presentationData - self.id = id - self.placeholder = placeholder - self.value = value - self.isSelected = isSelected - self.maxLength = maxLength - self.editing = editing - self.sectionId = sectionId - self.setItemIdWithRevealedOptions = setItemIdWithRevealedOptions - self.updated = updated - self.next = next - self.delete = delete - self.canDelete = canDelete - self.canMove = canMove - self.focused = focused - self.toggleSelected = toggleSelected - self.tag = tag - } - - func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { - async { - let node = CreatePollOptionItemNode() - let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) - - node.contentSize = layout.contentSize - node.insets = layout.insets - - Queue.mainQueue().async { - completion(node, { - return (nil, { _ in apply(.None) }) - }) - } - } - } - - func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { - Queue.mainQueue().async { - if let nodeValue = node() as? CreatePollOptionItemNode { - let makeLayout = nodeValue.asyncLayout() - - async { - let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) - Queue.mainQueue().async { - completion(layout, { _ in - apply(animation) - }) - } - } - } - } - } - - var selectable: Bool = false -} - -private let titleFont = Font.regular(15.0) - -class CreatePollOptionItemNode: ItemListRevealOptionsItemNode, ItemListItemNode, ItemListItemFocusableNode, ASEditableTextNodeDelegate { - private let containerNode: ASDisplayNode - private let backgroundNode: ASDisplayNode - private let topStripeNode: ASDisplayNode - private let bottomStripeNode: ASDisplayNode - private let maskNode: ASImageNode - - private var checkNode: InteractiveCheckNode? - - private let textClippingNode: ASDisplayNode - private let textNode: EditableTextNode - private let measureTextNode: TextNode - - private let textLimitNode: TextNode - private let reorderControlNode: ItemListEditableReorderControlNode - - private var item: CreatePollOptionItem? - private var layoutParams: ListViewItemLayoutParams? - - var tag: ItemListItemTag? { - return self.item?.tag - } - - override var controlsContainer: ASDisplayNode { - return self.containerNode - } - - var checkNodeFrame: CGRect? { - guard let _ = self.layoutParams, let checkNode = self.checkNode else { - return nil - } - return checkNode.frame - } - - init() { - self.containerNode = ASDisplayNode() - self.containerNode.clipsToBounds = true - - self.backgroundNode = ASDisplayNode() - self.backgroundNode.isLayerBacked = true - self.backgroundNode.backgroundColor = .white - - self.topStripeNode = ASDisplayNode() - self.topStripeNode.isLayerBacked = true - - self.bottomStripeNode = ASDisplayNode() - self.bottomStripeNode.isLayerBacked = true - - self.maskNode = ASImageNode() - - self.reorderControlNode = ItemListEditableReorderControlNode() - - self.textClippingNode = ASDisplayNode() - self.textClippingNode.clipsToBounds = true - - self.textNode = EditableTextNode() - self.measureTextNode = TextNode() - - self.textLimitNode = TextNode() - self.textLimitNode.isUserInteractionEnabled = false - - super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false) - - self.addSubnode(self.containerNode) - - self.textClippingNode.addSubnode(self.textNode) - self.containerNode.addSubnode(self.textClippingNode) - - self.containerNode.addSubnode(self.reorderControlNode) - self.containerNode.addSubnode(self.textLimitNode) - } - - override func didLoad() { - super.didLoad() - - var textColor: UIColor = .black - if let item = self.item { - textColor = item.presentationData.theme.list.itemPrimaryTextColor - } - self.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(17.0), NSAttributedString.Key.foregroundColor.rawValue: textColor] - self.textNode.clipsToBounds = true - self.textNode.delegate = self - self.textNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -5.0) - } - - func editableTextNodeDidBeginEditing(_ editableTextNode: ASEditableTextNode) { - self.item?.focused(true) - } - - func editableTextNodeDidFinishEditing(_ editableTextNode: ASEditableTextNode) { - self.internalEditableTextNodeDidUpdateText(editableTextNode, isLosingFocus: true) - self.item?.focused(false) - } - - func editableTextNode(_ editableTextNode: ASEditableTextNode, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - guard let item = self.item else { - return false - } - if text.firstIndex(of: "\n") != nil { - if text != "\n" { - let currentText = editableTextNode.attributedText?.string ?? "" - var updatedText = (currentText as NSString).replacingCharacters(in: range, with: text) - updatedText = updatedText.replacingOccurrences(of: "\n", with: " ") - if updatedText.count == 1 { - updatedText = "" - } - let updatedAttributedText = NSAttributedString(string: updatedText, font: Font.regular(17.0), textColor: item.presentationData.theme.list.itemPrimaryTextColor) - self.textNode.attributedText = updatedAttributedText - self.editableTextNodeDidUpdateText(editableTextNode) - } - if let next = item.next { - next() - } else { - editableTextNode.resignFirstResponder() - } - return false - } - return true - } - - func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) { - self.internalEditableTextNodeDidUpdateText(editableTextNode, isLosingFocus: false) - } - - private func internalEditableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode, isLosingFocus: Bool) { - if let item = self.item { - let text = self.textNode.attributedText ?? NSAttributedString() - - var updatedText = text.string - var hadReturn = false - if updatedText.firstIndex(of: "\n") != nil { - hadReturn = true - updatedText = updatedText.replacingOccurrences(of: "\n", with: " ") - } - let updatedAttributedText = NSAttributedString(string: updatedText, font: Font.regular(17.0), textColor: item.presentationData.theme.list.itemPrimaryTextColor) - if text.string != updatedAttributedText.string { - self.textNode.attributedText = updatedAttributedText - } - item.updated(updatedText, !isLosingFocus && editableTextNode.isFirstResponder()) - if hadReturn { - if let next = item.next { - next() - } else if !isLosingFocus { - editableTextNode.resignFirstResponder() - } - } - } - } - - func editableTextNodeBackspaceWhileEmpty(_ editableTextNode: ASEditableTextNode) { - self.item?.delete(editableTextNode.isFirstResponder()) - } - - func asyncLayout() -> (_ item: CreatePollOptionItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { - let reorderControlLayout = ItemListEditableReorderControlNode.asyncLayout(self.reorderControlNode) - let makeTextLayout = TextNode.asyncLayout(self.measureTextNode) - let makeTextLimitLayout = TextNode.asyncLayout(self.textLimitNode) - - let currentItem = self.item - - return { item, params, neighbors in - var updatedTheme: PresentationTheme? - - if currentItem?.presentationData.theme !== item.presentationData.theme { - updatedTheme = item.presentationData.theme - } - - let reorderSizeAndApply = reorderControlLayout(item.presentationData.theme) - - let separatorHeight = UIScreenPixel - - let insets = itemListNeighborsGroupedInsets(neighbors, params) - - let leftInset: CGFloat = params.leftInset + (item.isSelected != nil ? 60.0 : 16.0) - let rightInset: CGFloat = 44.0 + params.rightInset - - let textLength = item.value.count - let displayTextLimit = textLength > item.maxLength * 70 / 100 - let remainingCount = item.maxLength - textLength - - let (textLimitLayout, textLimitApply) = makeTextLimitLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: "\(remainingCount)", font: Font.regular(13.0), textColor: remainingCount < 0 ? item.presentationData.theme.list.itemDestructiveColor : item.presentationData.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 100.0, height: .greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) - - var measureText = item.value - if measureText.hasSuffix("\n") || measureText.isEmpty { - measureText += "|" - } - let attributedMeasureText = NSAttributedString(string: measureText, font: Font.regular(17.0), textColor: .black) - let attributedText = NSAttributedString(string: item.value, font: Font.regular(17.0), textColor: item.presentationData.theme.list.itemPrimaryTextColor) - let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedMeasureText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, lineSpacing: 0.05, cutout: nil, insets: UIEdgeInsets())) - - let textTopInset: CGFloat = 11.0 - let textBottomInset: CGFloat = 11.0 - - let contentSize = CGSize(width: params.width, height: textLayout.size.height + textTopInset + textBottomInset) - let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) - - let attributedPlaceholderText = NSAttributedString(string: item.placeholder, font: Font.regular(17.0), textColor: item.presentationData.theme.list.itemPlaceholderTextColor) - - return (layout, { [weak self] animation in - if let strongSelf = self { - let transition: ContainedViewLayoutTransition - switch animation { - case .System: - transition = .animated(duration: 0.3, curve: .spring) - default: - transition = .immediate - } - - strongSelf.item = item - strongSelf.layoutParams = params - - if let _ = updatedTheme { - strongSelf.topStripeNode.backgroundColor = item.presentationData.theme.list.itemBlocksSeparatorColor - strongSelf.bottomStripeNode.backgroundColor = item.presentationData.theme.list.itemBlocksSeparatorColor - strongSelf.backgroundNode.backgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor - - if strongSelf.isNodeLoaded { - strongSelf.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(17.0), NSAttributedString.Key.foregroundColor.rawValue: item.presentationData.theme.list.itemPrimaryTextColor] - strongSelf.textNode.tintColor = item.presentationData.theme.list.itemAccentColor - } - } - - let revealOffset = strongSelf.revealOffset - - let capitalizationType: UITextAutocapitalizationType - let autocorrectionType: UITextAutocorrectionType - let keyboardType: UIKeyboardType - - capitalizationType = .sentences - autocorrectionType = .default - keyboardType = UIKeyboardType.default - - let _ = textApply() - if let currentText = strongSelf.textNode.attributedText { - if currentText.string != attributedText.string || updatedTheme != nil { - strongSelf.textNode.attributedText = attributedText - } - } else { - strongSelf.textNode.attributedText = attributedText - } - - if strongSelf.textNode.keyboardType != keyboardType { - strongSelf.textNode.keyboardType = keyboardType - } - if strongSelf.textNode.autocapitalizationType != capitalizationType { - strongSelf.textNode.autocapitalizationType = capitalizationType - } - if strongSelf.textNode.autocorrectionType != autocorrectionType { - strongSelf.textNode.autocorrectionType = autocorrectionType - } - let returnKeyType: UIReturnKeyType - if let _ = item.next { - returnKeyType = .next - } else { - returnKeyType = .done - } - if strongSelf.textNode.returnKeyType != returnKeyType { - strongSelf.textNode.returnKeyType = returnKeyType - } - - if strongSelf.textNode.attributedPlaceholderText == nil || !strongSelf.textNode.attributedPlaceholderText!.isEqual(to: attributedPlaceholderText) { - strongSelf.textNode.attributedPlaceholderText = attributedPlaceholderText - } - - strongSelf.textNode.keyboardAppearance = item.presentationData.theme.rootController.keyboardColor.keyboardAppearance - - let checkSize = CGSize(width: 22.0, height: 22.0) - let checkFrame = CGRect(origin: CGPoint(x: params.leftInset + revealOffset + 16.0, y: floor((layout.contentSize.height - checkSize.height) / 2.0)), size: checkSize) - if let isSelected = item.isSelected { - if let checkNode = strongSelf.checkNode { - transition.updateFrame(node: checkNode, frame: checkFrame) - checkNode.setSelected(isSelected, animated: true) - } else { - let checkNode = InteractiveCheckNode(theme: CheckNodeTheme(backgroundColor: item.presentationData.theme.list.itemSwitchColors.positiveColor, strokeColor: item.presentationData.theme.list.itemCheckColors.foregroundColor, borderColor: item.presentationData.theme.list.itemCheckColors.strokeColor, overlayBorder: false, hasInset: false, hasShadow: false)) - checkNode.setSelected(isSelected, animated: false) - checkNode.valueChanged = { [weak self] value in - self?.item?.toggleSelected() - } - strongSelf.checkNode = checkNode - strongSelf.containerNode.addSubnode(checkNode) - checkNode.frame = checkFrame - transition.animatePositionAdditive(node: checkNode, offset: CGPoint(x: -checkFrame.maxX, y: 0.0)) - } - - if let checkNode = strongSelf.checkNode { - transition.updateAlpha(node: checkNode, alpha: strongSelf.textNode.textView.text.isEmpty && item.placeholder == item.presentationData.strings.CreatePoll_AddOption ? 0.0 : 1.0) - } - } else if let checkNode = strongSelf.checkNode { - strongSelf.checkNode = nil - transition.updateFrame(node: checkNode, frame: checkFrame.offsetBy(dx: -checkFrame.maxX, dy: 0.0), completion: { [weak checkNode] _ in - checkNode?.removeFromSupernode() - }) - } - - transition.updateFrame(node: strongSelf.textClippingNode, frame: CGRect(origin: CGPoint(x: revealOffset + leftInset, y: textTopInset), size: CGSize(width: params.width - leftInset - params.rightInset, height: textLayout.size.height))) - transition.updateFrame(node: strongSelf.textNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: params.width - leftInset - rightInset, height: textLayout.size.height + 1.0))) - - if strongSelf.backgroundNode.supernode == nil { - strongSelf.containerNode.insertSubnode(strongSelf.backgroundNode, at: 0) - } - if strongSelf.topStripeNode.supernode == nil { - strongSelf.containerNode.insertSubnode(strongSelf.topStripeNode, at: 1) - } - if strongSelf.bottomStripeNode.supernode == nil { - strongSelf.containerNode.insertSubnode(strongSelf.bottomStripeNode, at: 2) - } - if strongSelf.maskNode.supernode == nil { - strongSelf.containerNode.insertSubnode(strongSelf.maskNode, at: 3) - } - - let bottomStripeWasHidden = strongSelf.bottomStripeNode.isHidden - - let hasCorners = itemListHasRoundedBlockLayout(params) - var hasTopCorners = false - var hasBottomCorners = false - switch neighbors.top { - case .sameSection(false): - strongSelf.topStripeNode.isHidden = true - default: - hasTopCorners = true - strongSelf.topStripeNode.isHidden = hasCorners - } - let bottomStripeInset: CGFloat - switch neighbors.bottom { - case .sameSection(false): - bottomStripeInset = leftInset - strongSelf.bottomStripeNode.isHidden = false - default: - bottomStripeInset = 0.0 - hasBottomCorners = true - strongSelf.bottomStripeNode.isHidden = hasCorners - } - - strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil - - strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layout.contentSize.width, height: separatorHeight)) - if strongSelf.animationForKey("apparentHeight") == nil { - strongSelf.containerNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize) - strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) - strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) - let previousX = strongSelf.bottomStripeNode.frame.minX - strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - UIScreenPixel), size: CGSize(width: layout.contentSize.width, height: separatorHeight)) - if !bottomStripeWasHidden { - transition.animatePositionAdditive(node: strongSelf.bottomStripeNode, offset: CGPoint(x: previousX - strongSelf.bottomStripeNode.frame.minX, y: 0.0)) - } - } else { - let previousX = strongSelf.bottomStripeNode.frame.minX - strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: strongSelf.bottomStripeNode.frame.minY), size: CGSize(width: layout.contentSize.width, height: separatorHeight)) - if !bottomStripeWasHidden { - transition.animatePositionAdditive(node: strongSelf.bottomStripeNode, offset: CGPoint(x: previousX - strongSelf.bottomStripeNode.frame.minX, y: 0.0)) - } - } - - let _ = reorderSizeAndApply.1(layout.contentSize.height, displayTextLimit, transition) - let reorderControlFrame = CGRect(origin: CGPoint(x: params.width + revealOffset - params.rightInset - reorderSizeAndApply.0 + (!item.canMove ? 44.0 + params.rightInset : 0.0), y: 0.0), size: CGSize(width: reorderSizeAndApply.0, height: layout.contentSize.height)) - transition.updateFrameAdditive(node: strongSelf.reorderControlNode, frame: reorderControlFrame) - strongSelf.reorderControlNode.isUserInteractionEnabled = item.canMove - - let _ = textLimitApply() - strongSelf.textLimitNode.frame = CGRect(origin: CGPoint(x: reorderControlFrame.minX + floor((reorderControlFrame.width - textLimitLayout.size.width) / 2.0) - 4.0 - UIScreenPixel, y: max(floor(reorderControlFrame.midY + 2.0), layout.contentSize.height - 15.0 - textLimitLayout.size.height)), size: textLimitLayout.size) - strongSelf.textLimitNode.isHidden = !displayTextLimit - - strongSelf.updateLayout(size: layout.contentSize, leftInset: params.leftInset, rightInset: params.rightInset) - - strongSelf.setRevealOptions((left: [], right: item.canDelete ? [ItemListRevealOption(key: 0, title: item.presentationData.strings.Common_Delete, icon: .none, color: item.presentationData.theme.list.itemDisclosureActions.destructive.fillColor, textColor: item.presentationData.theme.list.itemDisclosureActions.destructive.foregroundColor)] : [])) - } - }) - } - } - - override func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { - super.updateRevealOffset(offset: offset, transition: transition) - - guard let params = self.layoutParams, let item = self.item else { - return - } - - let revealOffset = offset - - let leftInset: CGFloat - leftInset = params.leftInset + (item.isSelected != nil ? 60.0 : 16.0) - - if let checkNode = self.checkNode { - var checkNodeFrame = checkNode.frame - checkNodeFrame.origin.x = params.leftInset + 11.0 + revealOffset - transition.updateFrame(node: checkNode, frame: checkNodeFrame) - } - - var reorderFrame = self.reorderControlNode.frame - reorderFrame.origin.x = params.width + revealOffset - params.rightInset - reorderFrame.width - transition.updateFrame(node: self.reorderControlNode, frame: reorderFrame) - - var textClippingNodeFrame = self.textClippingNode.frame - textClippingNodeFrame.origin.x = revealOffset + leftInset - transition.updateFrame(node: self.textClippingNode, frame: textClippingNodeFrame) - } - - override func revealOptionSelected(_ option: ItemListRevealOption, animated: Bool) { - self.layer.allowsGroupOpacity = true - self.updateRevealOffsetInternal(offset: -self.bounds.width - 74.0, transition: .animated(duration: 0.2, curve: .spring), completion: { [weak self] in - self?.layer.allowsGroupOpacity = false - }) - self.item?.delete(self.textNode.isFirstResponder()) - } - - override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { - //self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) - } - - override func animateRemoved(_ currentTimestamp: Double, duration: Double) { - //self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) - } - - func focus() { - self.textNode.becomeFirstResponder() - } - - func selectAll() { - self.textNode.textView.selectAll(nil) - } - - override func isReorderable(at point: CGPoint) -> Bool { - if self.reorderControlNode.frame.contains(point), !self.reorderControlNode.isHidden, !self.isDisplayingRevealedOptions { - return true - } - return false - } - - override func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) { - super.animateFrameTransition(progress, currentValue) - - var separatorFrame = self.bottomStripeNode.frame - separatorFrame.origin.y = currentValue - UIScreenPixel - self.bottomStripeNode.frame = separatorFrame - - self.containerNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: self.containerNode.bounds.width, height: currentValue)) - - let insets = self.insets - let separatorHeight = UIScreenPixel - guard let params = self.layoutParams else { - return - } - - self.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: self.containerNode.bounds.width, height: currentValue + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) - self.maskNode.frame = self.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) - } -} diff --git a/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift b/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift deleted file mode 100644 index eef5401783..0000000000 --- a/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift +++ /dev/null @@ -1,683 +0,0 @@ -import Foundation -import UIKit -import Display -import AsyncDisplayKit -import SwiftSignalKit -import TelegramPresentationData -import ItemListUI -import TextFormat -import ObjCRuntimeUtils -import TextInputMenu -import AccountContext - -public enum CreatePollTextInputItemTextLimitMode { - case characters - case bytes -} - -public struct CreatePollTextInputItemTextLimit { - public let value: Int - public let display: Bool - public let mode: CreatePollTextInputItemTextLimitMode - - public init(value: Int, display: Bool, mode: CreatePollTextInputItemTextLimitMode = .characters) { - self.value = value - self.display = display - self.mode = mode - } -} - -public struct ItemListMultilineInputInlineAction { - public let icon: UIImage - public let action: (() -> Void)? - - public init(icon: UIImage, action: (() -> Void)?) { - self.icon = icon - self.action = action - } -} - -public class CreatePollTextInputItem: ListViewItem, ItemListItem { - let context: AccountContext - let presentationData: ItemListPresentationData - let text: NSAttributedString - let placeholder: String - public let sectionId: ItemListSectionId - let style: ItemListStyle - let capitalization: Bool - let autocorrection: Bool - let returnKeyType: UIReturnKeyType - let action: (() -> Void)? - let textUpdated: (NSAttributedString) -> Void - let shouldUpdateText: (String) -> Bool - let processPaste: ((String) -> Void)? - let updatedFocus: ((Bool) -> Void)? - let maxLength: CreatePollTextInputItemTextLimit? - let minimalHeight: CGFloat? - let inlineAction: ItemListMultilineInputInlineAction? - public let tag: ItemListItemTag? - - public init(context: AccountContext, presentationData: ItemListPresentationData, text: NSAttributedString, placeholder: String, maxLength: CreatePollTextInputItemTextLimit?, sectionId: ItemListSectionId, style: ItemListStyle, capitalization: Bool = true, autocorrection: Bool = true, returnKeyType: UIReturnKeyType = .default, minimalHeight: CGFloat? = nil, textUpdated: @escaping (NSAttributedString) -> Void, shouldUpdateText: @escaping (String) -> Bool = { _ in return true }, processPaste: ((String) -> Void)? = nil, updatedFocus: ((Bool) -> Void)? = nil, tag: ItemListItemTag? = nil, action: (() -> Void)? = nil, inlineAction: ItemListMultilineInputInlineAction? = nil) { - self.context = context - self.presentationData = presentationData - self.text = text - self.placeholder = placeholder - self.maxLength = maxLength - self.sectionId = sectionId - self.style = style - self.capitalization = capitalization - self.autocorrection = autocorrection - self.returnKeyType = returnKeyType - self.minimalHeight = minimalHeight - self.textUpdated = textUpdated - self.shouldUpdateText = shouldUpdateText - self.processPaste = processPaste - self.updatedFocus = updatedFocus - self.tag = tag - self.action = action - self.inlineAction = inlineAction - } - - public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { - async { - let node = CreatePollTextInputItemNode() - let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) - - node.contentSize = layout.contentSize - node.insets = layout.insets - - Queue.mainQueue().async { - completion(node, { - return (nil, { _ in apply() }) - }) - } - } - } - - public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { - Queue.mainQueue().async { - if let nodeValue = node() as? CreatePollTextInputItemNode { - let makeLayout = nodeValue.asyncLayout() - - async { - let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) - Queue.mainQueue().async { - completion(layout, { _ in - apply() - }) - } - } - } - } - } -} - -public class CreatePollTextInputItemNode: ListViewItemNode, ASEditableTextNodeDelegate, ItemListItemNode, ItemListItemFocusableNode { - private let backgroundNode: ASDisplayNode - private let topStripeNode: ASDisplayNode - private let bottomStripeNode: ASDisplayNode - private let maskNode: ASImageNode - - private let textClippingNode: ASDisplayNode - private let textNode: EditableTextNode - private let measureTextNode: TextNode - - private let limitTextNode: TextNode - private var inlineActionButtonNode: HighlightableButtonNode? - - private var item: CreatePollTextInputItem? - private var layoutParams: ListViewItemLayoutParams? - - private let inputMenu = TextInputMenu() - - public var tag: ItemListItemTag? { - return self.item?.tag - } - - public init() { - self.backgroundNode = ASDisplayNode() - self.backgroundNode.isLayerBacked = true - - self.topStripeNode = ASDisplayNode() - self.topStripeNode.isLayerBacked = true - - self.bottomStripeNode = ASDisplayNode() - self.bottomStripeNode.isLayerBacked = true - - self.maskNode = ASImageNode() - - self.textClippingNode = ASDisplayNode() - self.textClippingNode.clipsToBounds = true - - self.textNode = EditableTextNode() - self.measureTextNode = TextNode() - - self.limitTextNode = TextNode() - - super.init(layerBacked: false, dynamicBounce: false) - - self.textClippingNode.addSubnode(self.textNode) - self.addSubnode(self.textClippingNode) - - } - - override public func didLoad() { - super.didLoad() - - var textColor: UIColor = .black - if let item = self.item { - textColor = item.presentationData.theme.list.itemPrimaryTextColor - self.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(item.presentationData.fontSize.itemListBaseFontSize), NSAttributedString.Key.foregroundColor.rawValue: textColor] - } else { - self.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(17.0), NSAttributedString.Key.foregroundColor.rawValue: textColor] - } - self.textNode.clipsToBounds = true - self.textNode.delegate = self - self.textNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -5.0) - } - - public func asyncLayout() -> (_ item: CreatePollTextInputItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { - let makeTextLayout = TextNode.asyncLayout(self.measureTextNode) - let makeLimitTextLayout = TextNode.asyncLayout(self.limitTextNode) - - let currentItem = self.item - - return { item, params, neighbors in - var updatedTheme: PresentationTheme? - if currentItem?.presentationData.theme !== item.presentationData.theme { - updatedTheme = item.presentationData.theme - } - - let itemBackgroundColor: UIColor - let itemSeparatorColor: UIColor - - let leftInset = 16.0 + params.rightInset - switch item.style { - case .plain: - itemBackgroundColor = item.presentationData.theme.list.plainBackgroundColor - itemSeparatorColor = item.presentationData.theme.list.itemPlainSeparatorColor - case .blocks: - itemBackgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor - itemSeparatorColor = item.presentationData.theme.list.itemBlocksSeparatorColor - } - - var limitTextString: NSAttributedString? - var rightInset: CGFloat = params.rightInset - - if let maxLength = item.maxLength, maxLength.display { - let textLength: Int - switch maxLength.mode { - case .characters: - textLength = item.text.string.count - case .bytes: - textLength = item.text.string.data(using: .utf8, allowLossyConversion: true)?.count ?? 0 - } - let displayTextLimit = textLength > maxLength.value * 70 / 100 - let remainingCount = maxLength.value - textLength - if displayTextLimit { - limitTextString = NSAttributedString(string: "\(remainingCount)", font: Font.regular(13.0), textColor: remainingCount < 0 ? item.presentationData.theme.list.itemDestructiveColor : item.presentationData.theme.list.itemSecondaryTextColor) - } - - rightInset += 30.0 + 4.0 - } - - let (limitTextLayout, limitTextApply) = makeLimitTextLayout(TextNodeLayoutArguments(attributedString: limitTextString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 100.0, height: 100.0), alignment: .left, cutout: nil, insets: UIEdgeInsets())) - - if limitTextLayout.size.width > 30.0 { - rightInset += 30.0 - } - - if let inlineAction = item.inlineAction { - rightInset += inlineAction.icon.size.width + 8.0 - } - - let itemText = textAttributedStringForStateText(context: item.context, stateText: item.text, fontSize: 17.0, textColor: item.presentationData.theme.chat.inputPanel.primaryTextColor, accentTextColor: item.presentationData.theme.chat.inputPanel.panelControlAccentColor, writingDirection: nil, spoilersRevealed: false, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil) - let measureText = NSMutableAttributedString(attributedString: itemText) - let measureRawString = measureText.string - if measureRawString.hasSuffix("\n") || measureRawString.isEmpty { - measureText.append(NSAttributedString(string: "|", font: Font.regular(item.presentationData.fontSize.itemListBaseFontSize), textColor: .black)) - } - let attributedText = itemText - let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: measureText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: params.width - 16.0 - leftInset - rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) - - let separatorHeight = UIScreenPixel - - let textTopInset: CGFloat = 11.0 - let textBottomInset: CGFloat = 11.0 - - var contentHeight: CGFloat = textLayout.size.height + textTopInset + textBottomInset - if let minimalHeight = item.minimalHeight { - contentHeight = max(minimalHeight, contentHeight) - } - - let contentSize = CGSize(width: params.width, height: contentHeight) - let insets = itemListNeighborsGroupedInsets(neighbors, params) - - let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) - let layoutSize = layout.size - - let attributedPlaceholderText = NSAttributedString(string: item.placeholder, font: Font.regular(item.presentationData.fontSize.itemListBaseFontSize), textColor: item.presentationData.theme.list.itemPlaceholderTextColor) - - return (layout, { [weak self] in - if let strongSelf = self { - strongSelf.item = item - strongSelf.layoutParams = params - - if let _ = updatedTheme { - strongSelf.topStripeNode.backgroundColor = itemSeparatorColor - strongSelf.bottomStripeNode.backgroundColor = itemSeparatorColor - strongSelf.backgroundNode.backgroundColor = itemBackgroundColor - - if strongSelf.isNodeLoaded { - strongSelf.textNode.typingAttributes = [NSAttributedString.Key.font.rawValue: Font.regular(item.presentationData.fontSize.itemListBaseFontSize), NSAttributedString.Key.foregroundColor.rawValue: item.presentationData.theme.list.itemPrimaryTextColor] - strongSelf.textNode.tintColor = item.presentationData.theme.list.itemAccentColor - } - - if let inlineAction = item.inlineAction { - strongSelf.inlineActionButtonNode?.setImage(generateTintedImage(image: inlineAction.icon, color: item.presentationData.theme.list.itemAccentColor), for: .normal) - } - - strongSelf.inputMenu.updateStrings(item.presentationData.strings) - } - - let capitalizationType: UITextAutocapitalizationType = item.capitalization ? .sentences : .none - let autocorrectionType: UITextAutocorrectionType = item.autocorrection ? .default : .no - - if strongSelf.textNode.textView.autocapitalizationType != capitalizationType { - strongSelf.textNode.textView.autocapitalizationType = capitalizationType - } - if strongSelf.textNode.textView.autocorrectionType != autocorrectionType { - strongSelf.textNode.textView.autocorrectionType = autocorrectionType - } - if strongSelf.textNode.textView.returnKeyType != item.returnKeyType { - strongSelf.textNode.textView.returnKeyType = item.returnKeyType - } - - let _ = textApply() - if let currentText = strongSelf.textNode.attributedText { - if currentText.string != attributedText.string || updatedTheme != nil { - strongSelf.textNode.attributedText = attributedText - refreshGenericTextInputAttributes(context: item.context, textView: strongSelf.textNode.textView, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil) - } - } else { - strongSelf.textNode.attributedText = attributedText - refreshGenericTextInputAttributes(context: item.context, textView: strongSelf.textNode.textView, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil) - } - - if strongSelf.backgroundNode.supernode == nil { - strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) - } - if strongSelf.topStripeNode.supernode == nil { - strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) - } - if strongSelf.bottomStripeNode.supernode == nil { - strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) - } - if strongSelf.maskNode.supernode == nil { - strongSelf.insertSubnode(strongSelf.maskNode, at: 3) - } - - let hasCorners = itemListHasRoundedBlockLayout(params) - var hasTopCorners = false - var hasBottomCorners = false - switch neighbors.top { - case .sameSection(false): - strongSelf.topStripeNode.isHidden = true - default: - hasTopCorners = true - strongSelf.topStripeNode.isHidden = hasCorners - } - let bottomStripeInset: CGFloat - switch neighbors.bottom { - case .sameSection(false): - bottomStripeInset = leftInset - strongSelf.bottomStripeNode.isHidden = false - default: - bottomStripeInset = 0.0 - hasBottomCorners = true - strongSelf.bottomStripeNode.isHidden = hasCorners - } - - strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil - - strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) - strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) - - if strongSelf.textNode.attributedPlaceholderText == nil || !strongSelf.textNode.attributedPlaceholderText!.isEqual(to: attributedPlaceholderText) { - strongSelf.textNode.attributedPlaceholderText = attributedPlaceholderText - } - - strongSelf.textNode.keyboardAppearance = item.presentationData.theme.rootController.keyboardColor.keyboardAppearance - - if strongSelf.animationForKey("apparentHeight") == nil { - strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) - strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) - strongSelf.textClippingNode.frame = CGRect(origin: CGPoint(x: leftInset, y: textTopInset), size: CGSize(width: params.width - leftInset - params.rightInset, height: textLayout.size.height)) - } - strongSelf.textNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: params.width - leftInset - 16.0 - rightInset, height: textLayout.size.height + 1.0)) - - let _ = limitTextApply() - strongSelf.limitTextNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - 16.0 - limitTextLayout.size.width, y: layout.contentSize.height - 15.0 - limitTextLayout.size.height), size: limitTextLayout.size) - if limitTextString != nil { - if strongSelf.limitTextNode.supernode == nil { - strongSelf.addSubnode(strongSelf.limitTextNode) - } - } else if strongSelf.limitTextNode.supernode != nil { - strongSelf.limitTextNode.removeFromSupernode() - } - - if let inlineAction = item.inlineAction { - let inlineActionButtonNode: HighlightableButtonNode - if let currentInlineActionButtonNode = strongSelf.inlineActionButtonNode { - inlineActionButtonNode = currentInlineActionButtonNode - } else { - inlineActionButtonNode = HighlightableButtonNode() - inlineActionButtonNode.setImage(generateTintedImage(image: inlineAction.icon, color: item.presentationData.theme.list.itemAccentColor), for: .normal) - inlineActionButtonNode.addTarget(strongSelf, action: #selector(strongSelf.inlineActionPressed), forControlEvents: .touchUpInside) - strongSelf.addSubnode(inlineActionButtonNode) - strongSelf.inlineActionButtonNode = inlineActionButtonNode - } - inlineActionButtonNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - inlineAction.icon.size.width - 11.0, y: 7.0), size: inlineAction.icon.size) - } else if let inlineActionButtonNode = strongSelf.inlineActionButtonNode { - inlineActionButtonNode.removeFromSupernode() - strongSelf.inlineActionButtonNode = nil - } - } - }) - } - } - - override public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { - self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) - } - - override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { - self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) - } - - override public func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) { - super.animateFrameTransition(progress, currentValue) - - guard let params = self.layoutParams else { - return - } - - let separatorHeight = UIScreenPixel - let insets = self.insets - let contentSize = CGSize(width: params.width, height: max(1.0, currentValue - insets.top - insets.bottom)) - - let leftInset = 16.0 + params.leftInset - let textTopInset: CGFloat = 11.0 - let textBottomInset: CGFloat = 11.0 - - self.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) - self.maskNode.frame = self.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) - self.bottomStripeNode.frame = CGRect(origin: CGPoint(x: self.bottomStripeNode.frame.minX, y: contentSize.height), size: CGSize(width: self.bottomStripeNode.frame.size.width, height: separatorHeight)) - - self.textClippingNode.frame = CGRect(origin: CGPoint(x: leftInset, y: textTopInset), size: CGSize(width: max(0.0, params.width - leftInset - params.rightInset), height: max(0.0, contentSize.height - textTopInset - textBottomInset))) - } - - public func editableTextNodeDidBeginEditing(_ editableTextNode: ASEditableTextNode) { - self.item?.updatedFocus?(true) - self.inputMenu.activate() - } - - public func editableTextNodeDidFinishEditing(_ editableTextNode: ASEditableTextNode) { - self.item?.updatedFocus?(false) - self.inputMenu.deactivate() - } - - public func editableTextNodeTarget(forAction action: Selector) -> ASEditableTextNodeTargetForAction? { - if action == makeSelectorFromString("_showTextStyleOptions:") { - if #available(iOS 16.0, *) { - return ASEditableTextNodeTargetForAction(target: nil) - } else { - if case .general = self.inputMenu.state { - if self.textNode.attributedText == nil || self.textNode.attributedText!.length == 0 || self.textNode.selectedRange.length == 0 { - return ASEditableTextNodeTargetForAction(target: nil) - } - return ASEditableTextNodeTargetForAction(target: self) - } else { - return ASEditableTextNodeTargetForAction(target: nil) - } - } - } else if action == #selector(self.formatAttributesBold(_:)) || action == #selector(self.formatAttributesItalic(_:)) || action == #selector(self.formatAttributesMonospace(_:)) || action == #selector(self.formatAttributesLink(_:)) || action == #selector(self.formatAttributesStrikethrough(_:)) || action == #selector(self.formatAttributesUnderline(_:)) { - if case .format = self.inputMenu.state { - return ASEditableTextNodeTargetForAction(target: self) - } else { - return ASEditableTextNodeTargetForAction(target: nil) - } - } - if case .format = self.inputMenu.state { - return ASEditableTextNodeTargetForAction(target: nil) - } - return nil - } - - @objc func _showTextStyleOptions(_ sender: Any) { - self.inputMenu.format(view: self.textNode.view, rect: self.textNode.selectionRect.offsetBy(dx: 0.0, dy: -self.textNode.textView.contentOffset.y).insetBy(dx: 0.0, dy: -1.0)) - } - - @available(iOS 16.0, *) - public func editableTextNodeMenu(_ editableTextNode: ASEditableTextNode, forTextRange textRange: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu { - var actions = suggestedActions - - if editableTextNode.attributedText == nil || editableTextNode.attributedText!.length == 0 || editableTextNode.selectedRange.length == 0 { - - } else if let strings = self.item?.presentationData.strings { - let children: [UIAction] = [ - UIAction(title: strings.TextFormat_Bold, image: nil) { [weak self] (action) in - if let strongSelf = self { - strongSelf.formatAttributesBold(strongSelf) - } - }, - UIAction(title: strings.TextFormat_Italic, image: nil) { [weak self] (action) in - if let strongSelf = self { - strongSelf.formatAttributesItalic(strongSelf) - } - }, - UIAction(title: strings.TextFormat_Monospace, image: nil) { [weak self] (action) in - if let strongSelf = self { - strongSelf.formatAttributesMonospace(strongSelf) - } - }, - UIAction(title: strings.TextFormat_Link, image: nil) { [weak self] (action) in - if let strongSelf = self { - strongSelf.formatAttributesLink(strongSelf) - } - }, - UIAction(title: strings.TextFormat_Strikethrough, image: nil) { [weak self] (action) in - if let strongSelf = self { - strongSelf.formatAttributesStrikethrough(strongSelf) - } - }, - UIAction(title: strings.TextFormat_Underline, image: nil) { [weak self] (action) in - if let strongSelf = self { - strongSelf.formatAttributesUnderline(strongSelf) - } - }, - UIAction(title: strings.TextFormat_Spoiler, image: nil) { [weak self] (action) in - if let strongSelf = self { - strongSelf.formatAttributesSpoiler(strongSelf) - } - } - ] - - let formatMenu = UIMenu(title: strings.TextFormat_Format, image: nil, children: children) - actions.insert(formatMenu, at: 3) - } - - return UIMenu(children: actions) - } - - @objc func formatAttributesBold(_ sender: Any) { - self.inputMenu.back() - if let item = self.item { - chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.bold, value: nil) - } - } - - @objc func formatAttributesItalic(_ sender: Any) { - self.inputMenu.back() - if let item = self.item { - chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.italic, value: nil) - } - } - - @objc func formatAttributesMonospace(_ sender: Any) { - self.inputMenu.back() - if let item = self.item { - chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.monospace, value: nil) - } - } - - @objc func formatAttributesLink(_ sender: Any) { - self.inputMenu.back() - //self.interfaceInteraction?.openLinkEditing() - } - - @objc func formatAttributesStrikethrough(_ sender: Any) { - self.inputMenu.back() - if let item = self.item { - chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.strikethrough, value: nil) - } - } - - @objc func formatAttributesUnderline(_ sender: Any) { - self.inputMenu.back() - if let item = self.item { - chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.underline, value: nil) - } - } - - @objc func formatAttributesSpoiler(_ sender: Any) { - self.inputMenu.back() - if let item = self.item { - chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.spoiler, value: nil) - } - } - - @objc func formatAttributesQuote(_ sender: Any) { - self.inputMenu.back() - if let item = self.item { - chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .quote, isCollapsed: false)) - } - } - - @objc func formatAttributesCodeBlock(_ sender: Any) { - self.inputMenu.back() - if let item = self.item { - chatTextInputAddFormattingAttribute(item: item, textNode: self.textNode, theme: item.presentationData.theme, attribute: ChatTextInputAttributes.block, value: ChatTextInputTextQuoteAttribute(kind: .code(language: nil), isCollapsed: false)) - } - } - - public func editableTextNode(_ editableTextNode: ASEditableTextNode, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - if let item = self.item { - if text.count > 1, let processPaste = item.processPaste { - processPaste(text) - return false - } - - if let action = item.action, text == "\n" { - action() - return false - } - - let newText = (editableTextNode.textView.text as NSString).replacingCharacters(in: range, with: text) - if !item.shouldUpdateText(newText) { - return false - } - } - return true - } - - public func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) { - if let item = self.item { - if let _ = self.textNode.attributedText { - refreshGenericTextInputAttributes(context: item.context, textView: editableTextNode.textView, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil) - let updatedText = stateAttributedStringForText(self.textNode.attributedText!) - item.textUpdated(updatedText) - } else { - item.textUpdated(NSAttributedString(string: "")) - } - } - } - - public func editableTextNodeShouldPaste(_ editableTextNode: ASEditableTextNode) -> Bool { - if let _ = self.item { - let text: String? = UIPasteboard.general.string - if let _ = text { - return true - } - } - return false - } - - public func editableTextNodeDidChangeSelection(_ editableTextNode: ASEditableTextNode, fromSelectedRange: NSRange, toSelectedRange: NSRange, dueToEditing: Bool) { - /*if !dueToEditing && !self.updatingInputState { - }*/ - - if let item = self.item { - if case .format = self.inputMenu.state { - self.inputMenu.deactivate() - UIMenuController.shared.update() - } - - refreshChatTextInputTypingAttributes(editableTextNode.textView, theme: item.presentationData.theme, baseFontSize: 17.0) - refreshGenericTextInputAttributes(context: item.context, textView: editableTextNode.textView, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil) - } - } - - public func focus() { - if !self.textNode.textView.isFirstResponder { - self.textNode.textView.becomeFirstResponder() - } - } - - public func selectAll() { - self.textNode.textView.selectAll(nil) - } - - public func animateError() { - self.textNode.layer.addShakeAnimation() - } - - @objc private func inlineActionPressed() { - if let action = self.item?.inlineAction?.action { - action() - } - } -} - -private func chatTextInputAddFormattingAttribute(item: CreatePollTextInputItem, textNode: EditableTextNode, theme: PresentationTheme, attribute: NSAttributedString.Key, value: Any?) { - if let currentText = textNode.attributedText, textNode.selectedRange.length > 0 { - let nsRange = NSRange(location: textNode.selectedRange.location, length: textNode.selectedRange.length) - var addAttribute = true - var attributesToRemove: [NSAttributedString.Key] = [] - currentText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, stop in - for (key, _) in attributes { - if key == attribute && range == nsRange { - addAttribute = false - attributesToRemove.append(key) - } - } - } - - let result = NSMutableAttributedString(attributedString: currentText) - for attribute in attributesToRemove { - result.removeAttribute(attribute, range: nsRange) - } - if addAttribute { - result.addAttribute(attribute, value: true as Bool, range: nsRange) - } - - textNode.attributedText = result - textNode.selectedRange = nsRange - - refreshChatTextInputTypingAttributes(textNode.textView, theme: theme, baseFontSize: 17.0) - refreshGenericTextInputAttributes(context: item.context, textView: textNode.textView, theme: theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil, makeCollapsedQuoteAttachment: nil) - - let updatedText = stateAttributedStringForText(textNode.attributedText!) - item.textUpdated(updatedText) - } -} diff --git a/submodules/GalleryUI/BUILD b/submodules/GalleryUI/BUILD index 1ebf1cad4c..8e1eb1f63d 100644 --- a/submodules/GalleryUI/BUILD +++ b/submodules/GalleryUI/BUILD @@ -60,9 +60,12 @@ swift_library( "//submodules/TelegramUI/Components/AnimatedTextComponent", "//submodules/TelegramUI/Components/LottieComponent", "//submodules/Components/MultilineTextComponent", + "//submodules/Components/BundleIconComponent", "//submodules/Components/BalancedTextComponent", "//submodules/Components/ComponentDisplayAdapters", "//submodules/ComponentFlow", + "//submodules/TelegramUI/Components/ToastComponent", + "//submodules/SemanticStatusNode", ], visibility = [ "//visibility:public", diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index f4bf3b8f89..501a12a52f 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -33,6 +33,9 @@ import RasterizedCompositionComponent import BadgeComponent import ComponentFlow import ComponentDisplayAdapters +import ToastComponent +import MultilineTextComponent +import BundleIconComponent public enum UniversalVideoGalleryItemContentInfo { case message(Message, Int?) @@ -1390,8 +1393,94 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { if dismiss { self.dismiss() } + + if self.adDisposable == nil, let contentInfo = self.item?.contentInfo, case let .message(message, _) = contentInfo { + let adContext = self.context.engine.messages.adMessages(peerId: message.id.peerId, messageId: message.id) + self.adContext = adContext + self.adDisposable = (adContext.state + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let self else { + return + } + if let message = state.messages.first { + Queue.mainQueue().after(2.0, { + self.adMessage = message + if let validLayout = self.validLayout { + self.containerLayoutUpdated(validLayout.layout, navigationBarHeight: validLayout.navigationBarHeight, transition: .immediate) + } + }) + } + }) + } + + if let adMessage = self.adMessage { + let sideInset: CGFloat = 16.0 + let title = adMessage.author.flatMap { EnginePeer($0) }?.compactDisplayTitle ?? "" + + let adSize = self.adView.update( + transition: .immediate, + component: AnyComponent( + ToastContentComponent( + icon: AnyComponent( + BundleIconComponent(name: "Components/AdMock", tintColor: nil, maxSize: CGSize(width: 30.0, height: 30.0)) + ), + content: AnyComponent( + HStack([ + AnyComponentWithIdentity(id: 0, component: AnyComponent( + VStack([ + AnyComponentWithIdentity(id: 0, component: AnyComponent( + HStack([ + AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: title, font: Font.semibold(14.0), textColor: .white))))), + AnyComponentWithIdentity(id: 1, component: AnyComponent(Image(image: PresentationResourcesChatList.searchAdIcon(presentationData.theme, strings: presentationData.strings), size: CGSize(width: 31.0, height: 15.0)))) + ], spacing: 5.0) + )), + AnyComponentWithIdentity(id: 1, component: AnyComponent( + MultilineTextComponent(text: .plain(NSAttributedString(string: adMessage.text, font: Font.regular(14.0), textColor: .white))) + )) + ], alignment: .left, spacing: 3.0, fillWidth: false) + )), + AnyComponentWithIdentity(id: 1, component: AnyComponent( + AdRemainingProgressComponent(action: { [weak self] in + guard let self else { + return + } + self.adMessage = nil + if let validLayout = self.validLayout { + self.containerLayoutUpdated(validLayout.layout, navigationBarHeight: validLayout.navigationBarHeight, transition: .immediate) + } + }) + )) + ], spacing: 16.0, alignment: .alternatingLeftRight) + ), action: { [weak self] in + if let self, let item = self.item, let ad = adMessage.adAttribute { + item.performAction(.url(url: ad.url, concealed: false)) + } + } + ) + ), + environment: {}, + containerSize: CGSize(width: layout.size.width - sideInset * 2.0, height: 70.0) + ) + if let adView = self.adView.view { + if adView.superview == nil { + self.view.addSubview(adView) + + adView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + adView.layer.animatePosition(from: CGPoint(x: 0.0, y: 64.0), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + } + adView.frame = CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - adSize.height - 145.0), size: adSize) + } + } else if let adView = self.adView.view { + adView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false) + adView.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 64.0), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) + } } + private var adView = ComponentView() + private var adContext: AdMessagesHistoryContext? + private var adDisposable: Disposable? + private var adMessage: Message? + func setupItem(_ item: UniversalVideoGalleryItem) { if self.item?.content.id != item.content.id { var chapters = parseMediaPlayerChapters(item.caption) diff --git a/submodules/PremiumUI/Sources/PremiumDemoScreen.swift b/submodules/PremiumUI/Sources/PremiumDemoScreen.swift index 885dac1f52..2e8bf13ea2 100644 --- a/submodules/PremiumUI/Sources/PremiumDemoScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumDemoScreen.swift @@ -1118,6 +1118,26 @@ private final class DemoSheetContent: CombinedComponent { ) ) + //TODO:localize + availableItems[.todo] = DemoPagerComponent.Item( + AnyComponentWithIdentity( + id: PremiumDemoScreen.Subject.todo, + component: AnyComponent( + PageComponent( + content: AnyComponent(PhoneDemoComponent( + context: component.context, + position: .top, + videoFile: configuration.videos["todo"], + decoration: .badgeStars + )), + title: "To-Do Lists", + text: "Plan, assign and complete tasks – seamlessly and efficiently.", + textColor: textColor + ) + ) + ) + ) + let index: Int = 0 var items: [DemoPagerComponent.Item] = [] if let item = availableItems.first(where: { $0.value.content.id == component.subject as AnyHashable }) { @@ -1216,6 +1236,9 @@ private final class DemoSheetContent: CombinedComponent { text = strings.Premium_MessageEffectsInfo case .paidMessages: text = strings.Premium_PaidMessagesInfo + case .todo: + //TODO:localize + text = "Plan, assign and complete tasks – seamlessly and efficiently." default: text = "" } @@ -1302,6 +1325,8 @@ private final class DemoSheetContent: CombinedComponent { buttonAnimationName = "premium_unlock" case .paidMessages: buttonText = strings.Premium_PaidMessages_Proceed + case .todo: + buttonText = strings.Premium_PaidMessages_Proceed default: buttonText = strings.Common_OK } @@ -1492,6 +1517,7 @@ public class PremiumDemoScreen: ViewControllerComponentContainer { case folderTags case messageEffects case paidMessages + case todo case businessLocation case businessHours @@ -1552,6 +1578,8 @@ public class PremiumDemoScreen: ViewControllerComponentContainer { return .messageEffects case .paidMessages: return .paidMessages + case .todo: + return .todo case .businessLocation: return .businessLocation case .businessHours: diff --git a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift index c96c0668d6..659e7d0e36 100644 --- a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift @@ -309,6 +309,12 @@ public enum PremiumSource: Equatable { } else { return false } + case .todo: + if case .todo = rhs { + return true + } else { + return false + } case let .auth(lhsPrice): if case let .auth(rhsPrice) = rhs, lhsPrice == rhsPrice { return true @@ -363,6 +369,7 @@ public enum PremiumSource: Equatable { case folderTags case messageEffects case paidMessages + case todo case auth(String) var identifier: String? { @@ -459,6 +466,8 @@ public enum PremiumSource: Equatable { return "effects" case .paidMessages: return "paid_messages" + case .todo: + return "todo" case .auth: return "auth" } @@ -490,6 +499,7 @@ public enum PremiumPerk: CaseIterable { case folderTags case messageEffects case paidMessages + case todo case businessLocation case businessHours @@ -601,6 +611,8 @@ public enum PremiumPerk: CaseIterable { return "effects" case .paidMessages: return "paid_messages" + case .todo: + return "todo" case .business: return "business" case .businessLocation: @@ -672,6 +684,9 @@ public enum PremiumPerk: CaseIterable { return strings.Premium_MessageEffects case .paidMessages: return strings.Premium_PaidMessages + case .todo: + //TODO:localize + return "To-Do Lists" case .businessLocation: return strings.Business_Location case .businessHours: @@ -741,6 +756,9 @@ public enum PremiumPerk: CaseIterable { return strings.Premium_MessageEffectsInfo case .paidMessages: return strings.Premium_PaidMessagesInfo + case .todo: + //TODO:localize + return "Plan, assign and complete tasks – seamlessly and efficiently." case .businessLocation: return strings.Business_LocationInfo case .businessHours: @@ -810,6 +828,8 @@ public enum PremiumPerk: CaseIterable { return "Premium/Perk/MessageEffects" case .paidMessages: return "Premium/Perk/PaidMessages" + case .todo: + return "Premium/Perk/PaidMessages" case .businessLocation: return "Premium/BusinessPerk/Location" case .businessHours: diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaTodo.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaTodo.swift index ef033d8581..e9c9850d27 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaTodo.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaTodo.swift @@ -131,4 +131,24 @@ public final class TelegramMediaTodo: Media, Equatable { } return true } + + func withUpdated(items: [TelegramMediaTodo.Item]) -> TelegramMediaTodo { + return TelegramMediaTodo( + flags: self.flags, + text: self.text, + textEntities: self.textEntities, + items: items, + completions: self.completions + ) + } + + func withUpdated(completions: [TelegramMediaTodo.Completion]) -> TelegramMediaTodo { + return TelegramMediaTodo( + flags: self.flags, + text: self.text, + textEntities: self.textEntities, + items: self.items, + completions: completions + ) + } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Todo.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Todo.swift index c976c8cabd..6d4c3e0a47 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Todo.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Todo.swift @@ -10,26 +10,36 @@ public enum RequestUpdateTodoMessageError { } func _internal_requestUpdateTodoMessageItems(account: Account, messageId: MessageId, completedIds: [Int32], incompletedIds: [Int32]) -> Signal { - return account.postbox.loadedPeerWithId(messageId.peerId) - |> take(1) - |> castError(RequestUpdateTodoMessageError.self) - |> mapToSignal { peer -> Signal in - if let inputPeer = apiInputPeer(peer) { - return account.network.request(Api.functions.messages.toggleTodoCompleted(peer: inputPeer, msgId: messageId.id, completed: completedIds, incompleted: incompletedIds)) - |> mapError { _ -> RequestUpdateTodoMessageError in - return .generic - } - |> mapToSignal { result -> Signal in - return account.postbox.transaction { transaction in - account.stateManager.addUpdates(result) - } - |> castError(RequestUpdateTodoMessageError.self) - } - |> ignoreValues - } else { + return account.postbox.transaction { transaction -> Signal in + guard let peer = transaction.getPeer(messageId.peerId), let inputPeer = apiInputPeer(peer) else { return .complete() } + transaction.updateMessage(messageId, update: { currentMessage in + let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init) + var media: [Media] = [] + if let todo = currentMessage.media.first(where: { $0 is TelegramMediaTodo }) as? TelegramMediaTodo { + let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) + var updatedCompletions = todo.completions + for id in completedIds { + updatedCompletions.append(TelegramMediaTodo.Completion(id: id, date: timestamp, completedBy: account.peerId)) + } + updatedCompletions.removeAll(where: { incompletedIds.contains($0.id) }) + media = [todo.withUpdated(completions: updatedCompletions)] + } + return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: currentMessage.attributes, media: media)) + }) + return account.network.request(Api.functions.messages.toggleTodoCompleted(peer: inputPeer, msgId: messageId.id, completed: completedIds, incompleted: incompletedIds)) + |> mapError { _ -> RequestUpdateTodoMessageError in + return .generic + } + |> map { result in + account.stateManager.addUpdates(result) + } + |> ignoreValues } + |> castError(RequestUpdateTodoMessageError.self) + |> switchToLatest + |> ignoreValues } public enum AppendTodoMessageError { @@ -37,30 +47,30 @@ public enum AppendTodoMessageError { } func _internal_appendTodoMessageItems(account: Account, messageId: MessageId, items: [TelegramMediaTodo.Item]) -> Signal { - return account.postbox.loadedPeerWithId(messageId.peerId) - |> take(1) - |> castError(AppendTodoMessageError.self) - |> mapToSignal { peer -> Signal in - guard let inputPeer = apiInputPeer(peer) else { - return .single(nil) + return account.postbox.transaction { transaction -> Signal in + guard let peer = transaction.getPeer(messageId.peerId), let inputPeer = apiInputPeer(peer) else { + return .complete() } + transaction.updateMessage(messageId, update: { currentMessage in + let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init) + var media: [Media] = [] + if let todo = currentMessage.media.first(where: { $0 is TelegramMediaTodo }) as? TelegramMediaTodo { + var updatedItems = todo.items + updatedItems.append(contentsOf: items) + media = [todo.withUpdated(items: updatedItems)] + } + return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: currentMessage.attributes, media: media)) + }) return account.network.request(Api.functions.messages.appendTodoList(peer: inputPeer, msgId: messageId.id, list: items.map { $0.apiItem })) |> mapError { _ -> AppendTodoMessageError in return .generic } - |> mapToSignal { result -> Signal in - return account.postbox.transaction { transaction -> TelegramMediaTodo? in - switch result { - case let .updates(updates, _, _, _, _): - let _ = updates - default: - break - } - account.stateManager.addUpdates(result) - return nil - } - |> castError(AppendTodoMessageError.self) + |> map { result in + account.stateManager.addUpdates(result) } + |> ignoreValues } + |> castError(AppendTodoMessageError.self) + |> switchToLatest |> ignoreValues } diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift index b82e8717a7..150061ba27 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift @@ -168,6 +168,11 @@ public enum PresentationResourceKey: Int32 { case chatBubbleFileCloudFetchedIncomingIcon case chatBubbleFileCloudFetchedOutgoingIcon + case chatBubbleTodoDotIncomingIcon + case chatBubbleTodoDotOutgoingIcon + case chatBubbleTodoCheckIncomingIcon + case chatBubbleTodoCheckOutgoingIcon + case chatBubbleReplyThumbnailPlayImage case chatBubbleDeliveryFailedIcon diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChat.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChat.swift index 0d7dac3104..6826b80f08 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChat.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChat.swift @@ -1402,4 +1402,28 @@ public struct PresentationResourcesChat { return nil }) } + + public static func chatBubbleTodoDotIncomingIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.chatBubbleTodoDotIncomingIcon.rawValue, { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/TodoDot"), color: theme.chat.message.incoming.accentTextColor) + }) + } + + public static func chatBubbleTodoDotOutgoingIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.chatBubbleTodoDotOutgoingIcon.rawValue, { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/TodoDot"), color: theme.chat.message.outgoing.accentTextColor) + }) + } + + public static func chatBubbleTodoCheckIncomingIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.chatBubbleTodoCheckIncomingIcon.rawValue, { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/TodoCheck"), color: theme.chat.message.incoming.accentTextColor) + }) + } + + public static func chatBubbleTodoCheckOutgoingIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.chatBubbleTodoCheckOutgoingIcon.rawValue, { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/TodoCheck"), color: theme.chat.message.outgoing.accentTextColor) + }) + } } diff --git a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift index 0b500598aa..529f802938 100644 --- a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift +++ b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift @@ -1286,12 +1286,81 @@ public func universalServiceMessageString(presentationData: (PresentationTheme, } attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: attributes) } - case .unknown: - attributedString = nil case let .todoCompletions(completed, incompleted): - //TODO:release - let _ = completed - let _ = incompleted + var todo: TelegramMediaTodo? + for attribute in message.attributes { + if let attribute = attribute as? ReplyMessageAttribute, let message = message.associatedMessages[attribute.messageId] { + for media in message.media { + if let media = media as? TelegramMediaTodo { + todo = media + } + } + } + } + if let todo { + if message.author?.id == accountPeerId { + let resultString: PresentationStrings.FormattedString + if let completedTaskId = completed.first, let completedTask = todo.items.first(where: { $0.id == completedTaskId }) { + resultString = strings.Notification_TodoCompletedYou(completedTask.text) + } else if let incompletedTaskId = incompleted.first, let incompletedTask = todo.items.first(where: { $0.id == incompletedTaskId }) { + resultString = strings.Notification_TodoIncompletedYou(incompletedTask.text) + } else { + fatalError() + } + attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: [0: boldAttributes]) + } else { + let peerName = message.author?.compactDisplayTitle ?? "" + + var attributes = peerMentionsAttributes(primaryTextColor: primaryTextColor, peerIds: [(0, message.author?.id)]) + attributes[1] = boldAttributes + + let resultString: PresentationStrings.FormattedString + if let completedTaskId = completed.first, let completedTask = todo.items.first(where: { $0.id == completedTaskId }) { + resultString = strings.Notification_TodoCompleted(peerName, completedTask.text) + } else if let incompletedTaskId = incompleted.first, let incompletedTask = todo.items.first(where: { $0.id == incompletedTaskId }) { + resultString = strings.Notification_TodoIncompleted(peerName, incompletedTask.text) + } else { + fatalError() + } + attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: attributes) + } + } else { + attributedString = NSAttributedString(string: ".") + } + case let .todoAppendTasks(tasks): + var todoTitle = "" + for attribute in message.attributes { + if let attribute = attribute as? ReplyMessageAttribute, let message = message.associatedMessages[attribute.messageId] { + for media in message.media { + if let todo = media as? TelegramMediaTodo { + todoTitle = todo.text + } + } + } + } + if message.author?.id == accountPeerId { + let resultString: PresentationStrings.FormattedString + if tasks.count == 1, let task = tasks.first { + resultString = strings.Notification_TodoAddedTaskYou(task.text, todoTitle) + } else { + resultString = strings.Notification_TodoAddedMultipleTasksYou(strings.Notification_TodoTasks(Int32(tasks.count)), todoTitle) + } + attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: [0: boldAttributes, 1: boldAttributes]) + } else { + let peerName = message.author?.compactDisplayTitle ?? "" + var attributes = peerMentionsAttributes(primaryTextColor: primaryTextColor, peerIds: [(0, message.author?.id)]) + attributes[1] = boldAttributes + attributes[2] = boldAttributes + + let resultString: PresentationStrings.FormattedString + if tasks.count == 1, let task = tasks.first { + resultString = strings.Notification_TodoAddedTask(peerName, task.text, todoTitle) + } else { + resultString = strings.Notification_TodoAddedMultipleTasks(peerName, strings.Notification_TodoTasks(Int32(tasks.count)), todoTitle) + } + attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: attributes) + } + case .unknown: attributedString = nil } break diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 922a08f279..4add7cbd2c 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -481,6 +481,7 @@ swift_library( "//submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel", "//submodules/TelegramUI/Components/GifVideoLayer", "//submodules/TelegramUI/Components/BatchVideoRendering", + "//submodules/TelegramUI/Components/ComposeTodoScreen", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, "//build-system:ios_sim_arm64": [], diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD index 26f2d17330..0359206708 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD @@ -51,6 +51,7 @@ swift_library( "//submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode", "//submodules/TelegramUI/Components/Chat/ChatMessageWebpageBubbleContentNode", "//submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode", "//submodules/TelegramUI/Components/Chat/ChatMessageItem", "//submodules/TelegramUI/Components/Chat/ChatMessageItemView", "//submodules/TelegramUI/Components/Chat/ChatMessageSwipeToReplyNode", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 6fbd76a16d..2f87452a9d 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -41,6 +41,7 @@ import ChatMessageInteractiveFileNode import ChatMessageFileBubbleContentNode import ChatMessageWebpageBubbleContentNode import ChatMessagePollBubbleContentNode +import ChatMessageTodoBubbleContentNode import ChatMessageItem import ChatMessageItemView import ChatMessageSwipeToReplyNode @@ -269,6 +270,9 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ } else if let _ = media as? TelegramMediaPoll { result.append((message, ChatMessagePollBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) needReactions = false + } else if let _ = media as? TelegramMediaTodo { + result.append((message, ChatMessageTodoBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) + needReactions = false } else if let _ = media as? TelegramMediaGiveaway { result.append((message, ChatMessageGiveawayBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) needReactions = false diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index 0017cedcf4..472ecf3efc 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -449,10 +449,12 @@ private final class ChatMessagePollOptionNode: ASDisplayNode { self.buttonNode.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { - if "".isEmpty, let contentNode = strongSelf.supernode as? ChatMessagePollBubbleContentNode, let backdropNode = contentNode.bubbleBackgroundNode?.backdropNode { + if let theme = strongSelf.theme, theme.overallDarkAppearance, let contentNode = strongSelf.supernode as? ChatMessagePollBubbleContentNode, let backdropNode = contentNode.bubbleBackgroundNode?.backdropNode { strongSelf.highlightedBackgroundNode.layer.compositingFilter = "overlayBlendMode" strongSelf.highlightedBackgroundNode.frame = strongSelf.view.convert(strongSelf.highlightedBackgroundNode.frame, to: backdropNode.view) backdropNode.addSubnode(strongSelf.highlightedBackgroundNode) + } else { + strongSelf.insertSubnode(strongSelf.highlightedBackgroundNode, at: 0) } strongSelf.highlightedBackgroundNode.layer.removeAnimation(forKey: "opacity") diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode/BUILD new file mode 100644 index 0000000000..07268d6b55 --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode/BUILD @@ -0,0 +1,35 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ChatMessageTodoBubbleContentNode", + module_name = "ChatMessageTodoBubbleContentNode", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/TelegramCore", + "//submodules/Postbox", + "//submodules/TextFormat", + "//submodules/UrlEscaping", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/AccountContext", + "//submodules/AvatarNode", + "//submodules/TelegramPresentationData", + "//submodules/ChatMessageBackground", + "//submodules/TelegramUI/Components/Chat/ChatMessageDateAndStatusNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUI/Components/Chat/PollBubbleTimerNode", + "//submodules/TelegramUI/Components/Chat/MergedAvatarsNode", + "//submodules/TelegramUI/Components/TextNodeWithEntities", + "//submodules/TelegramUI/Components/Chat/ShimmeringLinkNode", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode/Sources/ChatMessageTodoBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode/Sources/ChatMessageTodoBubbleContentNode.swift new file mode 100644 index 0000000000..7087dece29 --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode/Sources/ChatMessageTodoBubbleContentNode.swift @@ -0,0 +1,1244 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramCore +import Postbox +import TextFormat +import UrlEscaping +import SwiftSignalKit +import AccountContext +import AvatarNode +import TelegramPresentationData +import ChatMessageBackground +import ChatMessageDateAndStatusNode +import ChatMessageBubbleContentNode +import ChatMessageItemCommon +import PollBubbleTimerNode +import TextNodeWithEntities +import ShimmeringLinkNode + +private final class ChatMessageTaskOptionRadioNodeParameters: NSObject { + let timestamp: Double + let staticColor: UIColor + let animatedColor: UIColor + let fillColor: UIColor + let foregroundColor: UIColor + let offset: Double? + let isChecked: Bool? + let checkTransition: ChatMessageTaskOptionRadioNodeCheckTransition? + + init(timestamp: Double, staticColor: UIColor, animatedColor: UIColor, fillColor: UIColor, foregroundColor: UIColor, offset: Double?, isChecked: Bool?, checkTransition: ChatMessageTaskOptionRadioNodeCheckTransition?) { + self.timestamp = timestamp + self.staticColor = staticColor + self.animatedColor = animatedColor + self.fillColor = fillColor + self.foregroundColor = foregroundColor + self.offset = offset + self.isChecked = isChecked + self.checkTransition = checkTransition + + super.init() + } +} + +private final class ChatMessageTaskOptionRadioNodeCheckTransition { + let startTime: Double + let duration: Double + let previousValue: Bool + let updatedValue: Bool + + init(startTime: Double, duration: Double, previousValue: Bool, updatedValue: Bool) { + self.startTime = startTime + self.duration = duration + self.previousValue = previousValue + self.updatedValue = updatedValue + } +} + +private final class ChatMessageTaskOptionRadioNode: ASDisplayNode { + private(set) var staticColor: UIColor? + private(set) var animatedColor: UIColor? + private(set) var fillColor: UIColor? + private(set) var foregroundColor: UIColor? + private var isInHierarchyValue: Bool = false + private(set) var isAnimating: Bool = false + private var startTime: Double? + private var checkTransition: ChatMessageTaskOptionRadioNodeCheckTransition? + private(set) var isChecked: Bool? + + private var displayLink: ConstantDisplayLinkAnimator? + + private var shouldBeAnimating: Bool { + return self.isInHierarchyValue && (self.isAnimating || self.checkTransition != nil) + } + + func updateIsChecked(_ value: Bool, animated: Bool) { + if let previousValue = self.isChecked, previousValue != value { + self.checkTransition = ChatMessageTaskOptionRadioNodeCheckTransition(startTime: CACurrentMediaTime(), duration: 0.15, previousValue: previousValue, updatedValue: value) + self.isChecked = value + self.updateAnimating() + self.setNeedsDisplay() + } + } + + override init() { + super.init() + + self.isUserInteractionEnabled = false + self.isOpaque = false + } + + deinit { + self.displayLink?.isPaused = true + } + + override func willEnterHierarchy() { + super.willEnterHierarchy() + + let previous = self.shouldBeAnimating + self.isInHierarchyValue = true + let updated = self.shouldBeAnimating + if previous != updated { + self.updateAnimating() + } + } + + override func didExitHierarchy() { + super.didExitHierarchy() + + let previous = self.shouldBeAnimating + self.isInHierarchyValue = false + let updated = self.shouldBeAnimating + if previous != updated { + self.updateAnimating() + } + } + + func update(staticColor: UIColor, animatedColor: UIColor, fillColor: UIColor, foregroundColor: UIColor, isSelectable: Bool, isAnimating: Bool) { + var updated = false + let shouldHaveBeenAnimating = self.shouldBeAnimating + if !staticColor.isEqual(self.staticColor) { + self.staticColor = staticColor + updated = true + } + if !animatedColor.isEqual(self.animatedColor) { + self.animatedColor = animatedColor + updated = true + } + if !fillColor.isEqual(self.fillColor) { + self.fillColor = fillColor + updated = true + } + if !foregroundColor.isEqual(self.foregroundColor) { + self.foregroundColor = foregroundColor + updated = true + } + if isSelectable != (self.isChecked != nil) { + if isSelectable { + self.isChecked = false + } else { + self.isChecked = nil + self.checkTransition = nil + } + updated = true + } + if isAnimating != self.isAnimating { + self.isAnimating = isAnimating + let updated = self.shouldBeAnimating + if shouldHaveBeenAnimating != updated { + self.updateAnimating() + } + } + if updated { + self.setNeedsDisplay() + } + } + + private func updateAnimating() { + let timestamp = CACurrentMediaTime() + if let checkTransition = self.checkTransition { + if checkTransition.startTime + checkTransition.duration <= timestamp { + self.checkTransition = nil + } + } + + if self.shouldBeAnimating { + if self.isAnimating && self.startTime == nil { + self.startTime = timestamp + } + if self.displayLink == nil { + self.displayLink = ConstantDisplayLinkAnimator(update: { [weak self] in + self?.updateAnimating() + self?.setNeedsDisplay() + }) + self.displayLink?.isPaused = false + self.setNeedsDisplay() + } + } else if let displayLink = self.displayLink { + self.startTime = nil + displayLink.invalidate() + self.displayLink = nil + self.setNeedsDisplay() + } + } + + override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { + if let staticColor = self.staticColor, let animatedColor = self.animatedColor, let fillColor = self.fillColor, let foregroundColor = self.foregroundColor { + let timestamp = CACurrentMediaTime() + var offset: Double? + if let startTime = self.startTime { + offset = CACurrentMediaTime() - startTime + } + return ChatMessageTaskOptionRadioNodeParameters(timestamp: timestamp, staticColor: staticColor, animatedColor: animatedColor, fillColor: fillColor, foregroundColor: foregroundColor, offset: offset, isChecked: self.isChecked, checkTransition: self.checkTransition) + } else { + return nil + } + } + + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { + if isCancelled() { + return + } + + guard let parameters = parameters as? ChatMessageTaskOptionRadioNodeParameters else { + return + } + + let context = UIGraphicsGetCurrentContext()! + + if let offset = parameters.offset { + let t = max(0.0, offset) + let colorFadeInDuration = 0.2 + let color: UIColor + if t < colorFadeInDuration { + color = parameters.staticColor.mixedWith(parameters.animatedColor, alpha: CGFloat(t / colorFadeInDuration)) + } else { + color = parameters.animatedColor + } + context.setStrokeColor(color.cgColor) + + let rotationDuration = 1.15 + let rotationProgress = CGFloat(offset.truncatingRemainder(dividingBy: rotationDuration) / rotationDuration) + context.translateBy(x: bounds.midX, y: bounds.midY) + context.rotate(by: rotationProgress * 2.0 * CGFloat.pi) + context.translateBy(x: -bounds.midX, y: -bounds.midY) + + let fillDuration = 1.0 + if offset < fillDuration { + let fillT = CGFloat(offset.truncatingRemainder(dividingBy: fillDuration) / fillDuration) + let startAngle = fillT * 2.0 * CGFloat.pi - CGFloat.pi / 2.0 + let endAngle = -CGFloat.pi / 2.0 + + let path = UIBezierPath(arcCenter: CGPoint(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0), radius: (bounds.size.width - 1.0) / 2.0, startAngle: startAngle, endAngle: endAngle, clockwise: true) + path.lineWidth = 1.0 + path.lineCapStyle = .round + path.stroke() + } else { + let halfProgress: CGFloat = 0.7 + let fillPhase = 0.6 + let keepPhase = 0.0 + let finishPhase = 0.6 + let totalDuration = fillPhase + keepPhase + finishPhase + let localOffset = (offset - fillDuration).truncatingRemainder(dividingBy: totalDuration) + + let angleOffsetT: CGFloat = -CGFloat(floor((offset - fillDuration) / totalDuration)) + let angleOffset = (angleOffsetT * (1.0 - halfProgress) * 2.0 * CGFloat.pi).truncatingRemainder(dividingBy: 2.0 * CGFloat.pi) + context.translateBy(x: bounds.midX, y: bounds.midY) + context.rotate(by: angleOffset) + context.translateBy(x: -bounds.midX, y: -bounds.midY) + + if localOffset < fillPhase + keepPhase { + let fillT = CGFloat(min(1.0, localOffset / fillPhase)) + let startAngle = -CGFloat.pi / 2.0 + let endAngle = (fillT * halfProgress) * 2.0 * CGFloat.pi - CGFloat.pi / 2.0 + + let path = UIBezierPath(arcCenter: CGPoint(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0), radius: (bounds.size.width - 1.0) / 2.0, startAngle: startAngle, endAngle: endAngle, clockwise: true) + path.lineWidth = 1.0 + path.lineCapStyle = .round + path.stroke() + } else { + let finishT = CGFloat((localOffset - (fillPhase + keepPhase)) / finishPhase) + let endAngle = halfProgress * 2.0 * CGFloat.pi - CGFloat.pi / 2.0 + let startAngle = -CGFloat.pi / 2.0 * (1.0 - finishT) + endAngle * finishT + + let path = UIBezierPath(arcCenter: CGPoint(x: bounds.size.width / 2.0, y: bounds.size.height / 2.0), radius: (bounds.size.width - 1.0) / 2.0, startAngle: startAngle, endAngle: endAngle, clockwise: true) + path.lineWidth = 1.0 + path.lineCapStyle = .round + path.stroke() + } + } + } else { + if let isChecked = parameters.isChecked { + let checkedT: CGFloat + let fromValue: CGFloat + let toValue: CGFloat + let fromAlpha: CGFloat + let toAlpha: CGFloat + if let checkTransition = parameters.checkTransition { + checkedT = CGFloat(max(0.0, min(1.0, (parameters.timestamp - checkTransition.startTime) / checkTransition.duration))) + fromValue = checkTransition.previousValue ? bounds.width : 0.0 + fromAlpha = checkTransition.previousValue ? 1.0 : 0.0 + toValue = checkTransition.updatedValue ? bounds.width : 0.0 + toAlpha = checkTransition.updatedValue ? 1.0 : 0.0 + } else { + checkedT = 1.0 + fromValue = isChecked ? bounds.width : 0.0 + fromAlpha = isChecked ? 1.0 : 0.0 + toValue = isChecked ? bounds.width : 0.0 + toAlpha = isChecked ? 1.0 : 0.0 + } + + let diameter = fromValue * (1.0 - checkedT) + toValue * checkedT + let alpha = fromAlpha * (1.0 - checkedT) + toAlpha * checkedT + + if abs(diameter - 1.0) > CGFloat.ulpOfOne { + context.setStrokeColor(parameters.staticColor.cgColor) + context.strokeEllipse(in: CGRect(origin: CGPoint(x: 0.5, y: 0.5), size: CGSize(width: bounds.width - 1.0, height: bounds.height - 1.0))) + } + + if !diameter.isZero { + context.setFillColor(parameters.fillColor.withAlphaComponent(alpha).cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: (bounds.width - diameter) / 2.0, y: (bounds.width - diameter) / 2.0), size: CGSize(width: diameter, height: diameter))) + + context.setLineWidth(1.5) + context.setLineJoin(.round) + context.setLineCap(.round) + + context.setStrokeColor(parameters.foregroundColor.withAlphaComponent(alpha).cgColor) + if parameters.foregroundColor.alpha.isZero { + context.setBlendMode(.clear) + } + let startPoint = CGPoint(x: 6.0, y: 12.13) + let centerPoint = CGPoint(x: 9.28, y: 15.37) + let endPoint = CGPoint(x: 16.0, y: 8.0) + + let pathStartT: CGFloat = 0.15 + let pathT = max(0.0, (alpha - pathStartT) / (1.0 - pathStartT)) + let pathMiddleT: CGFloat = 0.4 + + context.move(to: startPoint) + if pathT >= pathMiddleT { + context.addLine(to: centerPoint) + + let pathEndT = (pathT - pathMiddleT) / (1.0 - pathMiddleT) + if pathEndT >= 1.0 { + context.addLine(to: endPoint) + } else { + context.addLine(to: CGPoint(x: (1.0 - pathEndT) * centerPoint.x + pathEndT * endPoint.x, y: (1.0 - pathEndT) * centerPoint.y + pathEndT * endPoint.y)) + } + } else { + context.addLine(to: CGPoint(x: (1.0 - pathT) * startPoint.x + pathT * centerPoint.x, y: (1.0 - pathT) * startPoint.y + pathT * centerPoint.y)) + } + context.strokePath() + context.setBlendMode(.normal) + } + } else { + context.setStrokeColor(parameters.staticColor.cgColor) + context.strokeEllipse(in: CGRect(origin: CGPoint(x: 0.5, y: 0.5), size: CGSize(width: bounds.width - 1.0, height: bounds.height - 1.0))) + } + } + } +} + +private let percentageFont = Font.bold(14.5) +private let percentageSmallFont = Font.bold(12.5) + +private func generatePercentageImage(presentationData: ChatPresentationData, incoming: Bool, value: Int, targetValue: Int) -> UIImage { + return generateImage(CGSize(width: 42.0, height: 20.0), rotatedContext: { size, context in + UIGraphicsPushContext(context) + context.clear(CGRect(origin: CGPoint(), size: size)) + let font: UIFont + if targetValue == 100 { + font = percentageSmallFont + } else { + font = percentageFont + } + let string = NSAttributedString(string: "\(value)%", font: font, textColor: incoming ? presentationData.theme.theme.chat.message.incoming.primaryTextColor : presentationData.theme.theme.chat.message.outgoing.primaryTextColor, paragraphAlignment: .right) + string.draw(in: CGRect(origin: CGPoint(x: 0.0, y: targetValue == 100 ? 3.0 : 2.0), size: size)) + UIGraphicsPopContext() + })! +} + +private func generatePercentageAnimationImages(presentationData: ChatPresentationData, incoming: Bool, from fromValue: Int, to toValue: Int, duration: Double) -> [UIImage] { + let minimumFrameDuration = 1.0 / 40.0 + let numberOfFrames = max(1, Int(duration / minimumFrameDuration)) + var images: [UIImage] = [] + for i in 0 ..< numberOfFrames { + let t = CGFloat(i) / CGFloat(numberOfFrames) + images.append(generatePercentageImage(presentationData: presentationData, incoming: incoming, value: Int((1.0 - t) * CGFloat(fromValue) + t * CGFloat(toValue)), targetValue: toValue)) + } + return images +} + +private final class ChatMessageTodoItemNode: ASDisplayNode { + private let highlightedBackgroundNode: ASDisplayNode + private var avatarNode: AvatarNode? + private(set) var radioNode: ChatMessageTaskOptionRadioNode? + private var iconNode: ASImageNode? + fileprivate var titleNode: TextNodeWithEntities? + private let buttonNode: HighlightTrackingButtonNode + let separatorNode: ASDisplayNode + var option: TelegramMediaTodo.Item? + var pressed: (() -> Void)? + var selectionUpdated: (() -> Void)? + private var theme: PresentationTheme? + + weak var previousOptionNode: ChatMessageTodoItemNode? + + private var canMark = false + private var isPremium = false + + var visibilityRect: CGRect? { + didSet { + if self.visibilityRect != oldValue { + if let titleNode = self.titleNode { + if let visibilityRect = self.visibilityRect { + titleNode.visibilityRect = visibilityRect.offsetBy(dx: 0.0, dy: titleNode.textNode.frame.minY) + } else { + titleNode.visibilityRect = nil + } + } + } + } + } + + override init() { + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.alpha = 0.0 + self.highlightedBackgroundNode.isUserInteractionEnabled = false + + self.buttonNode = HighlightTrackingButtonNode() + self.separatorNode = ASDisplayNode() + self.separatorNode.isLayerBacked = true + + super.init() + + self.addSubnode(self.highlightedBackgroundNode) + self.addSubnode(self.separatorNode) + self.addSubnode(self.buttonNode) + + self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) + self.buttonNode.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + if let theme = strongSelf.theme, theme.overallDarkAppearance, let contentNode = strongSelf.supernode as? ChatMessageTodoBubbleContentNode, let backdropNode = contentNode.bubbleBackgroundNode?.backdropNode { + strongSelf.highlightedBackgroundNode.layer.compositingFilter = "overlayBlendMode" + strongSelf.highlightedBackgroundNode.frame = strongSelf.view.convert(strongSelf.highlightedBackgroundNode.frame, to: backdropNode.view) + backdropNode.addSubnode(strongSelf.highlightedBackgroundNode) + } else { + strongSelf.insertSubnode(strongSelf.highlightedBackgroundNode, at: 0) + } + + strongSelf.highlightedBackgroundNode.layer.removeAnimation(forKey: "opacity") + strongSelf.highlightedBackgroundNode.alpha = 1.0 + + strongSelf.separatorNode.layer.removeAnimation(forKey: "opacity") + strongSelf.separatorNode.alpha = 0.0 + + strongSelf.previousOptionNode?.separatorNode.layer.removeAnimation(forKey: "opacity") + strongSelf.previousOptionNode?.separatorNode.alpha = 0.0 + } else { + strongSelf.highlightedBackgroundNode.alpha = 0.0 + strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, completion: { finished in + if finished && strongSelf.highlightedBackgroundNode.supernode != strongSelf { + strongSelf.highlightedBackgroundNode.layer.compositingFilter = nil + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: strongSelf.highlightedBackgroundNode.frame.size) + strongSelf.insertSubnode(strongSelf.highlightedBackgroundNode, at: 0) + } + }) + + strongSelf.separatorNode.alpha = 1.0 + strongSelf.separatorNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + + strongSelf.previousOptionNode?.separatorNode.alpha = 1.0 + strongSelf.previousOptionNode?.separatorNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + } + } + } + } + + @objc private func buttonPressed() { + if let radioNode = self.radioNode, let isChecked = radioNode.isChecked, self.canMark, self.isPremium { + radioNode.updateIsChecked(!isChecked, animated: true) + self.selectionUpdated?() + } else { + self.pressed?() + } + } + + static func asyncLayout(_ maybeNode: ChatMessageTodoItemNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ todo: TelegramMediaTodo, _ option: TelegramMediaTodo.Item, _ completion: TelegramMediaTodo.Completion?, _ translation: TranslationMessageAttribute.Additional?, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessageTodoItemNode))) { + let makeTitleLayout = TextNodeWithEntities.asyncLayout(maybeNode?.titleNode) + + return { context, presentationData, message, todo, option, completion, translation, constrainedWidth in + var canMark = false + if (todo.flags.contains(.othersCanComplete) || message.author?.id == context.account.peerId) { + canMark = true + } + + let leftInset: CGFloat = canMark ? 57.0 : 29.0 + let rightInset: CGFloat = 12.0 + + let incoming = message.effectivelyIncoming(context.account.peerId) + + var optionText = option.text + var optionEntities = option.entities + if let translation { + optionText = translation.text + optionEntities = translation.entities + } + + if !canMark && completion != nil { + optionEntities.append(MessageTextEntity(range: 0 ..< (optionText as NSString).length, type: .Strikethrough)) + } + + let optionTextColor: UIColor = incoming ? presentationData.theme.theme.chat.message.incoming.primaryTextColor : presentationData.theme.theme.chat.message.outgoing.primaryTextColor + let optionAttributedText = stringWithAppliedEntities( + optionText, + entities: optionEntities, + baseColor: optionTextColor, + linkColor: optionTextColor, + baseFont: presentationData.messageFont, + linkFont: presentationData.messageFont, + boldFont: presentationData.messageFont, + italicFont: presentationData.messageFont, + boldItalicFont: presentationData.messageFont, + fixedFont: presentationData.messageFont, + blockQuoteFont: presentationData.messageFont, + message: message + ) + + let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: optionAttributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: max(1.0, constrainedWidth - leftInset - rightInset), height: CGFloat.greatestFiniteMagnitude), alignment: .left, cutout: nil, insets: UIEdgeInsets(top: 1.0, left: 0.0, bottom: 1.0, right: 0.0))) + + let contentHeight: CGFloat = max(46.0, titleLayout.size.height + 22.0) + + let isSelectable: Bool = true + + return (titleLayout.size.width + leftInset + rightInset, { width in + return (CGSize(width: width, height: contentHeight), { animated, inProgress, attemptSynchronous in + let node: ChatMessageTodoItemNode + if let maybeNode = maybeNode { + node = maybeNode + } else { + node = ChatMessageTodoItemNode() + } + + node.canMark = canMark + node.isPremium = context.isPremium + node.option = option + node.theme = presentationData.theme.theme + + node.highlightedBackgroundNode.backgroundColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.highlight : presentationData.theme.theme.chat.message.outgoing.polls.highlight + + node.buttonNode.accessibilityLabel = option.text + + if animated { + if let titleNode = node.titleNode, let cachedLayout = titleNode.textNode.cachedLayout { + if !cachedLayout.areLinesEqual(to: titleLayout) { + if let textContents = titleNode.textNode.contents { + let fadeNode = ASDisplayNode() + fadeNode.displaysAsynchronously = false + fadeNode.contents = textContents + fadeNode.frame = titleNode.textNode.frame + fadeNode.isLayerBacked = true + node.addSubnode(fadeNode) + fadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak fadeNode] _ in + fadeNode?.removeFromSupernode() + }) + titleNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + } + } + } + + let titleNode = titleApply(TextNodeWithEntities.Arguments( + context: context, + cache: context.animationCache, + renderer: context.animationRenderer, + placeholderColor: incoming ? presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor, + attemptSynchronous: attemptSynchronous + )) + let titleNodeFrame: CGRect + if titleLayout.hasRTL { + titleNodeFrame = CGRect(origin: CGPoint(x: width - rightInset - titleLayout.size.width, y: 12.0), size: titleLayout.size) + } else { + titleNodeFrame = CGRect(origin: CGPoint(x: leftInset, y: 12.0), size: titleLayout.size) + } + if node.titleNode !== titleNode { + node.titleNode = titleNode + node.addSubnode(titleNode.textNode) + titleNode.textNode.isUserInteractionEnabled = false + + if let visibilityRect = node.visibilityRect { + titleNode.visibilityRect = visibilityRect.offsetBy(dx: 0.0, dy: titleNodeFrame.minY) + } + } + titleNode.textNode.frame = titleNodeFrame + + if let completion, canMark && todo.flags.contains(.othersCanComplete) { + let avatarNode: AvatarNode + if let current = node.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 12.0)) + node.insertSubnode(avatarNode, at: 0) + node.avatarNode = avatarNode + if animated { + avatarNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + avatarNode.layer.animateScale(from: 0.01, to: 1.0, duration: 0.2, removeOnCompletion: false) + } + } + let avatarSize = CGSize(width: 22.0, height: 22.0) + avatarNode.frame = CGRect(origin: CGPoint(x: 24.0, y: 12.0), size: avatarSize) + if let peer = message.peers[completion.completedBy] { + avatarNode.setPeer(context: context, theme: presentationData.theme.theme, peer: EnginePeer(peer), displayDimensions: avatarSize, cutoutRect: CGRect(origin: CGPoint(x: -12.0, y: -1.0), size: CGSize(width: 24.0, height: 24.0))) + //avatarNode.setPeerV2(context: context, theme: presentationData.theme.theme, peer: EnginePeer(peer), displayDimensions: avatarSize) + } + } else if let avatarNode = node.avatarNode { + node.avatarNode = nil + if animated { + avatarNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak avatarNode] _ in + avatarNode?.removeFromSupernode() + }) + avatarNode.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) + } else { + avatarNode.removeFromSupernode() + } + } + + if canMark { + let radioNode: ChatMessageTaskOptionRadioNode + if let current = node.radioNode { + radioNode = current + } else { + radioNode = ChatMessageTaskOptionRadioNode() + node.addSubnode(radioNode) + node.radioNode = radioNode + if animated { + radioNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + } + } + let radioSize: CGFloat = 22.0 + radioNode.frame = CGRect(origin: CGPoint(x: 12.0, y: 12.0), size: CGSize(width: radioSize, height: radioSize)) + radioNode.update(staticColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.radioButton : presentationData.theme.theme.chat.message.outgoing.polls.radioButton, animatedColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.radioProgress : presentationData.theme.theme.chat.message.outgoing.polls.radioProgress, fillColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.bar : presentationData.theme.theme.chat.message.outgoing.polls.bar, foregroundColor: incoming ? presentationData.theme.theme.chat.message.incoming.polls.barIconForeground : presentationData.theme.theme.chat.message.outgoing.polls.barIconForeground, isSelectable: isSelectable, isAnimating: inProgress) + + radioNode.updateIsChecked(completion != nil, animated: false) + } else if let radioNode = node.radioNode { + node.radioNode = nil + if animated { + radioNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak radioNode] _ in + radioNode?.removeFromSupernode() + }) + } else { + radioNode.removeFromSupernode() + } + } + + if !canMark { + let iconNode: ASImageNode + if let current = node.iconNode { + iconNode = current + } else { + iconNode = ASImageNode() + iconNode.displaysAsynchronously = false + node.addSubnode(iconNode) + node.iconNode = iconNode + if animated { + iconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + } + } + + let icon: UIImage? + if incoming { + icon = completion != nil ? PresentationResourcesChat.chatBubbleTodoCheckIncomingIcon(presentationData.theme.theme) : PresentationResourcesChat.chatBubbleTodoDotIncomingIcon(presentationData.theme.theme) + } else { + icon = completion != nil ? PresentationResourcesChat.chatBubbleTodoCheckOutgoingIcon(presentationData.theme.theme) : PresentationResourcesChat.chatBubbleTodoDotOutgoingIcon(presentationData.theme.theme) + } + iconNode.image = icon + + let iconSize: CGFloat = 10.0 + iconNode.frame = CGRect(origin: CGPoint(x: 10.0, y: 19.0), size: CGSize(width: iconSize, height: iconSize)) + } else if let iconNode = node.iconNode { + node.iconNode = nil + if animated { + iconNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak iconNode] _ in + iconNode?.removeFromSupernode() + }) + } else { + iconNode.removeFromSupernode() + } + } + + node.buttonNode.frame = CGRect(origin: CGPoint(x: 1.0, y: 0.0), size: CGSize(width: width - 2.0, height: contentHeight)) + if node.highlightedBackgroundNode.supernode == node { + node.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: contentHeight + UIScreenPixel)) + } + node.separatorNode.backgroundColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.separator : presentationData.theme.theme.chat.message.outgoing.polls.separator + node.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentHeight - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + + node.buttonNode.isAccessibilityElement = true + + return node + }) + }) + } + } +} + +private let labelsFont = Font.regular(14.0) + + +public class ChatMessageTodoBubbleContentNode: ChatMessageBubbleContentNode { + private let textNode: TextNodeWithEntities + private let typeNode: TextNode + private var timerNode: PollBubbleTimerNode? + private let buttonViewResultsTextNode: TextNode + private let buttonNode: HighlightableButtonNode + private let statusNode: ChatMessageDateAndStatusNode + private var optionNodes: [ChatMessageTodoItemNode] = [] + private var shimmeringNodes: [ShimmeringLinkNode] = [] + + private var todo: TelegramMediaTodo? + + override public var visibility: ListViewItemNodeVisibility { + didSet { + if oldValue != self.visibility { + switch self.visibility { + case .none: + self.textNode.visibilityRect = nil + for optionNode in self.optionNodes { + optionNode.visibilityRect = nil + } + case let .visible(_, subRect): + var subRect = subRect + subRect.origin.x = 0.0 + subRect.size.width = 10000.0 + self.textNode.visibilityRect = subRect.offsetBy(dx: 0.0, dy: -self.textNode.textNode.frame.minY) + for optionNode in self.optionNodes { + optionNode.visibilityRect = subRect.offsetBy(dx: 0.0, dy: -optionNode.frame.minY) + } + } + } + } + } + + required public init() { + self.textNode = TextNodeWithEntities() + self.textNode.textNode.isUserInteractionEnabled = false + self.textNode.textNode.contentMode = .topLeft + self.textNode.textNode.contentsScale = UIScreenScale + self.textNode.textNode.displaysAsynchronously = false + + self.typeNode = TextNode() + self.typeNode.isUserInteractionEnabled = false + self.typeNode.contentMode = .topLeft + self.typeNode.contentsScale = UIScreenScale + self.typeNode.displaysAsynchronously = false + + self.buttonViewResultsTextNode = TextNode() + self.buttonViewResultsTextNode.isUserInteractionEnabled = false + self.buttonViewResultsTextNode.contentMode = .topLeft + self.buttonViewResultsTextNode.contentsScale = UIScreenScale + self.buttonViewResultsTextNode.displaysAsynchronously = false + + self.buttonNode = HighlightableButtonNode() + + self.statusNode = ChatMessageDateAndStatusNode() + + super.init() + + self.addSubnode(self.textNode.textNode) + self.addSubnode(self.typeNode) + self.addSubnode(self.buttonViewResultsTextNode) + self.addSubnode(self.buttonNode) + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { + let makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode) + let makeTypeLayout = TextNode.asyncLayout(self.typeNode) + let makeViewResultsTextLayout = TextNode.asyncLayout(self.buttonViewResultsTextNode) + let statusLayout = self.statusNode.asyncLayout() + + var previousOptionNodeLayouts: [Int32: (_ contet: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ poll: TelegramMediaTodo, _ option: TelegramMediaTodo.Item, _ completion: TelegramMediaTodo.Completion?, _ translation: TranslationMessageAttribute.Additional?, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessageTodoItemNode)))] = [:] + for optionNode in self.optionNodes { + if let option = optionNode.option { + previousOptionNodeLayouts[option.id] = ChatMessageTodoItemNode.asyncLayout(optionNode) + } + } + + return { item, layoutConstants, _, _, _, _ in + let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none) + + return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in + let message = item.message + + let incoming = item.message.effectivelyIncoming(item.context.account.peerId) + + let additionalTextRightInset: CGFloat = 24.0 + + let horizontalInset = layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right + let textConstrainedSize = CGSize(width: constrainedSize.width - horizontalInset - additionalTextRightInset, height: constrainedSize.height) + + var edited = false + if item.attributes.updatingMedia != nil { + edited = true + } + var viewCount: Int? + var dateReplies = 0 + var starsCount: Int64? + var dateReactionsAndPeers = mergedMessageReactionsAndPeers(accountPeerId: item.context.account.peerId, accountPeer: item.associatedData.accountPeer, message: item.message) + if item.message.isRestricted(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) { + dateReactionsAndPeers = ([], []) + } + for attribute in item.message.attributes { + if let attribute = attribute as? EditedMessageAttribute { + edited = !attribute.isHidden + } else if let attribute = attribute as? ViewCountMessageAttribute { + viewCount = attribute.count + } else if let attribute = attribute as? ReplyThreadMessageAttribute, case .peer = item.chatLocation { + if let channel = item.message.peers[item.message.id.peerId] as? TelegramChannel, case .group = channel.info { + dateReplies = Int(attribute.count) + } + } else if let attribute = attribute as? PaidStarsMessageAttribute, item.message.id.peerId.namespace == Namespaces.Peer.CloudChannel { + starsCount = attribute.stars.value + } + } + + let dateFormat: MessageTimestampStatusFormat + if item.presentationData.isPreview { + dateFormat = .full + } else { + dateFormat = .regular + } + let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: item.message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, format: dateFormat, associatedData: item.associatedData) + + let statusType: ChatMessageDateAndStatusType? + if case .customChatContents = item.associatedData.subject { + statusType = nil + } else { + switch position { + case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): + if incoming { + statusType = .BubbleIncoming + } else { + if message.flags.contains(.Failed) { + statusType = .BubbleOutgoing(.Failed) + } else if (message.flags.isSending && !message.isSentOrAcknowledged) || item.attributes.updatingMedia != nil { + statusType = .BubbleOutgoing(.Sending) + } else { + statusType = .BubbleOutgoing(.Sent(read: item.read)) + } + } + default: + statusType = nil + } + } + + var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))? + + if let statusType = statusType { + var isReplyThread = false + if case .replyThread = item.chatLocation { + isReplyThread = true + } + + statusSuggestedWidthAndContinue = statusLayout(ChatMessageDateAndStatusNode.Arguments( + context: item.context, + presentationData: item.presentationData, + edited: edited, + impressionCount: viewCount, + dateText: dateText, + type: statusType, + layoutInput: .trailingContent(contentWidth: 1000.0, reactionSettings: shouldDisplayInlineDateReactions(message: item.message, isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions) ? ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: true, preferAdditionalInset: false) : nil), + constrainedSize: textConstrainedSize, + availableReactions: item.associatedData.availableReactions, + savedMessageTags: item.associatedData.savedMessageTags, + reactions: dateReactionsAndPeers.reactions, + reactionPeers: dateReactionsAndPeers.peers, + displayAllReactionPeers: item.message.id.peerId.namespace == Namespaces.Peer.CloudUser, + areReactionsTags: item.topMessage.areReactionsTags(accountPeerId: item.context.account.peerId), + messageEffect: item.topMessage.messageEffect(availableMessageEffects: item.associatedData.availableMessageEffects), + replyCount: dateReplies, + starsCount: starsCount, + isPinned: item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, + hasAutoremove: item.message.isSelfExpiring, + canViewReactionList: canViewMessageReactionList(message: item.topMessage), + animationCache: item.controllerInteraction.presentationContext.animationCache, + animationRenderer: item.controllerInteraction.presentationContext.animationRenderer + )) + } + + var todo: TelegramMediaTodo? + for media in item.message.media { + if let media = media as? TelegramMediaTodo { + todo = media + break + } + } + + let messageTheme = incoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing + + + var pollTitleText = todo?.text ?? "" + var pollTitleEntities = todo?.textEntities ?? [] + var pollOptions: [TranslationMessageAttribute.Additional] = [] + + var isTranslating = false + if let todo, let translateToLanguage = item.associatedData.translateToLanguage, !todo.text.isEmpty && incoming { + isTranslating = true + for attribute in item.message.attributes { + if let attribute = attribute as? TranslationMessageAttribute, !attribute.text.isEmpty, attribute.toLang == translateToLanguage { + pollTitleText = attribute.text + pollTitleEntities = attribute.entities + pollOptions = attribute.additional + isTranslating = false + break + } + } + } + + let attributedText = stringWithAppliedEntities( + pollTitleText, + entities: pollTitleEntities, + baseColor: messageTheme.primaryTextColor, + linkColor: messageTheme.linkTextColor, + baseFont: item.presentationData.messageBoldFont, + linkFont: item.presentationData.messageBoldFont, + boldFont: item.presentationData.messageBoldFont, + italicFont: item.presentationData.messageBoldFont, + boldItalicFont: item.presentationData.messageBoldFont, + fixedFont: item.presentationData.messageBoldFont, + blockQuoteFont: item.presentationData.messageBoldFont, + message: message + ) + + let textInsets = UIEdgeInsets(top: 2.0, left: 0.0, bottom: 5.0, right: 0.0) + + let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets)) + let typeText: String + + //TODO:localize + if let todo, todo.flags.contains(.othersCanComplete) { + typeText = "Group To Do List" + } else { + if let author = item.message.author, author.id != item.context.account.peerId { + typeText = "\(EnginePeer(author).compactDisplayTitle)'s To Do List" + } else { + typeText = "To Do List" + } + } + + let (typeLayout, typeApply) = makeTypeLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: typeText, font: labelsFont, textColor: messageTheme.secondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + + + var bottomText: String = "" + if let todo { + if let author = item.message.author, author.id != item.context.account.peerId && !todo.flags.contains(.othersCanComplete) { + bottomText = "\(todo.completions.count) of \(todo.items.count) completed by \(EnginePeer(author).compactDisplayTitle)" + } else { + bottomText = "\(todo.completions.count) of \(todo.items.count) completed" + } + } + + let (buttonViewResultsTextLayout, buttonViewResultsTextApply) = makeViewResultsTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: bottomText, font: labelsFont, textColor: messageTheme.secondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: textInsets)) + + var textFrame = CGRect(origin: CGPoint(x: -textInsets.left, y: -textInsets.top), size: textLayout.size) + var textFrameWithoutInsets = CGRect(origin: CGPoint(x: textFrame.origin.x + textInsets.left, y: textFrame.origin.y + textInsets.top), size: CGSize(width: textFrame.width - textInsets.left - textInsets.right, height: textFrame.height - textInsets.top - textInsets.bottom)) + + textFrame = textFrame.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: layoutConstants.text.bubbleInsets.top) + textFrameWithoutInsets = textFrameWithoutInsets.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: layoutConstants.text.bubbleInsets.top) + + var boundingSize: CGSize = textFrameWithoutInsets.size + boundingSize.width += additionalTextRightInset + boundingSize.width = max(boundingSize.width, typeLayout.size.width) + boundingSize.width = max(boundingSize.width, buttonViewResultsTextLayout.size.width + 4.0/* + (statusSize?.width ?? 0.0)*/) + + if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue { + boundingSize.width = max(boundingSize.width, statusSuggestedWidthAndContinue.0) + } + + boundingSize.width += layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right + boundingSize.height += layoutConstants.text.bubbleInsets.top + layoutConstants.text.bubbleInsets.bottom + + var pollOptionsFinalizeLayouts: [(CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessageTodoItemNode)] = [] + if let todo { + for i in 0 ..< todo.items.count { + let todoItem = todo.items[i] + + let makeLayout: (_ context: AccountContext, _ presentationData: ChatPresentationData, _ message: Message, _ todo: TelegramMediaTodo, _ item: TelegramMediaTodo.Item, _ completion: TelegramMediaTodo.Completion?, _ translation: TranslationMessageAttribute.Additional?, _ constrainedWidth: CGFloat) -> (minimumWidth: CGFloat, layout: ((CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessageTodoItemNode))) + if let previous = previousOptionNodeLayouts[todoItem.id] { + makeLayout = previous + } else { + makeLayout = ChatMessageTodoItemNode.asyncLayout(nil) + } + + var translation: TranslationMessageAttribute.Additional? + if !pollOptions.isEmpty && i < pollOptions.count { + translation = pollOptions[i] + } + + let itemCompletion = todo.completions.first(where: { $0.id == todoItem.id }) + + let result = makeLayout(item.context, item.presentationData, item.message, todo, todoItem, itemCompletion, translation, constrainedSize.width - layoutConstants.bubble.borderInset * 2.0) + boundingSize.width = max(boundingSize.width, result.minimumWidth + layoutConstants.bubble.borderInset * 2.0) + pollOptionsFinalizeLayouts.append(result.1) + } + } + + boundingSize.width = max(boundingSize.width, min(270.0, constrainedSize.width)) + + return (boundingSize.width, { boundingWidth in + var resultSize = CGSize(width: max(boundingSize.width, boundingWidth), height: boundingSize.height) + + let titleTypeSpacing: CGFloat = -4.0 + let typeOptionsSpacing: CGFloat = 3.0 + resultSize.height += titleTypeSpacing + typeLayout.size.height + typeOptionsSpacing + + var optionNodesSizesAndApply: [(CGSize, (Bool, Bool, Bool) -> ChatMessageTodoItemNode)] = [] + for finalizeLayout in pollOptionsFinalizeLayouts { + let result = finalizeLayout(boundingWidth - layoutConstants.bubble.borderInset * 2.0) + resultSize.width = max(resultSize.width, result.0.width + layoutConstants.bubble.borderInset * 2.0) + resultSize.height += result.0.height + optionNodesSizesAndApply.append(result) + } + + let statusSpacing: CGFloat = 33.0 + let optionsButtonSpacing: CGFloat = 12.0 + resultSize.height += 44.0 + + var statusSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> Void)? + if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue { + statusSizeAndApply = statusSuggestedWidthAndContinue.1(boundingWidth) + } + + if let statusSizeAndApply = statusSizeAndApply { + resultSize.height += statusSizeAndApply.0.height - 6.0 + } + + let buttonViewResultsTextFrame = CGRect(origin: CGPoint(x: floor((resultSize.width - buttonViewResultsTextLayout.size.width) / 2.0), y: optionsButtonSpacing), size: buttonViewResultsTextLayout.size) + + return (resultSize, { [weak self] animation, synchronousLoad, _ in + if let strongSelf = self { + strongSelf.item = item + strongSelf.todo = todo + + let cachedLayout = strongSelf.textNode.textNode.cachedLayout + if case .System = animation { + if let cachedLayout = cachedLayout { + if !cachedLayout.areLinesEqual(to: textLayout) { + if let textContents = strongSelf.textNode.textNode.contents { + let fadeNode = ASDisplayNode() + fadeNode.displaysAsynchronously = false + fadeNode.contents = textContents + fadeNode.frame = strongSelf.textNode.textNode.frame + fadeNode.isLayerBacked = true + strongSelf.addSubnode(fadeNode) + fadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak fadeNode] _ in + fadeNode?.removeFromSupernode() + }) + strongSelf.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + } + } + } + + let _ = textApply(TextNodeWithEntities.Arguments( + context: item.context, + cache: item.context.animationCache, + renderer: item.context.animationRenderer, + placeholderColor: incoming ? item.presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : item.presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor, + attemptSynchronous: synchronousLoad) + ) + let _ = typeApply() + + var verticalOffset = textFrame.maxY + titleTypeSpacing + typeLayout.size.height + typeOptionsSpacing + var updatedOptionNodes: [ChatMessageTodoItemNode] = [] + for i in 0 ..< optionNodesSizesAndApply.count { + let (size, apply) = optionNodesSizesAndApply[i] + var isRequesting = false + if let todo, i < todo.items.count { + isRequesting = false +// if let inProgressOpaqueIds = item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] { +// isRequesting = inProgressOpaqueIds.contains(poll.options[i].opaqueIdentifier) +// } + } + let optionNode = apply(animation.isAnimated, isRequesting, synchronousLoad) + let optionNodeFrame = CGRect(origin: CGPoint(x: layoutConstants.bubble.borderInset, y: verticalOffset), size: size) + if optionNode.supernode !== strongSelf { + strongSelf.addSubnode(optionNode) + let todoItem = optionNode.option + optionNode.selectionUpdated = { + guard let strongSelf = self, let item = strongSelf.item, let todoItem else { + return + } + item.controllerInteraction.requestToggleTodoMessageItem(item.message.id, todoItem.id, optionNode.radioNode?.isChecked == true) + } + optionNode.pressed = { + guard let strongSelf = self, let item = strongSelf.item else { + return + } + item.controllerInteraction.displayTodoToggleUnavailable(item.message.id) + } + optionNode.frame = optionNodeFrame + } else { + animation.animator.updateFrame(layer: optionNode.layer, frame: optionNodeFrame, completion: nil) + } + + verticalOffset += size.height + updatedOptionNodes.append(optionNode) + + if i > 0 { + optionNode.previousOptionNode = updatedOptionNodes[i - 1] + } + } + for optionNode in strongSelf.optionNodes { + if !updatedOptionNodes.contains(where: { $0 === optionNode }) { + optionNode.removeFromSupernode() + } + } + strongSelf.optionNodes = updatedOptionNodes + + if textLayout.hasRTL { + strongSelf.textNode.textNode.frame = CGRect(origin: CGPoint(x: resultSize.width - textFrame.size.width - textInsets.left - layoutConstants.text.bubbleInsets.right - additionalTextRightInset, y: textFrame.origin.y), size: textFrame.size) + } else { + strongSelf.textNode.textNode.frame = textFrame + } + let typeFrame = CGRect(origin: CGPoint(x: textFrame.minX, y: textFrame.maxY + titleTypeSpacing), size: typeLayout.size) + animation.animator.updateFrame(layer: strongSelf.typeNode.layer, frame: typeFrame, completion: nil) + + if let statusSizeAndApply = statusSizeAndApply { + let statusFrame = CGRect(origin: CGPoint(x: resultSize.width - statusSizeAndApply.0.width - layoutConstants.text.bubbleInsets.right, y: verticalOffset + statusSpacing), size: statusSizeAndApply.0) + + if strongSelf.statusNode.supernode == nil { + statusSizeAndApply.1(.None) + strongSelf.statusNode.frame = statusFrame + strongSelf.addSubnode(strongSelf.statusNode) + } else { + statusSizeAndApply.1(animation) + animation.animator.updateFrame(layer: strongSelf.statusNode.layer, frame: statusFrame, completion: nil) + } + } else if strongSelf.statusNode.supernode != nil { + strongSelf.statusNode.removeFromSupernode() + } + + let _ = buttonViewResultsTextApply() + strongSelf.buttonViewResultsTextNode.frame = buttonViewResultsTextFrame.offsetBy(dx: 0.0, dy: verticalOffset) + + strongSelf.buttonNode.frame = CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: CGSize(width: resultSize.width, height: 44.0)) + + strongSelf.updateIsTranslating(isTranslating) + } + }) + }) + }) + } + } + + private func updateIsTranslating(_ isTranslating: Bool) { + guard let item = self.item else { + return + } + var rects: [[CGRect]] = [] + let titleRects = (self.textNode.textNode.rangeRects(in: NSRange(location: 0, length: self.textNode.textNode.cachedLayout?.attributedString?.length ?? 0))?.rects ?? []).map { self.textNode.textNode.view.convert($0, to: self.view) } + rects.append(titleRects) + + for optionNode in self.optionNodes { + if let titleNode = optionNode.titleNode { + let optionRects = (titleNode.textNode.rangeRects(in: NSRange(location: 0, length: titleNode.textNode.cachedLayout?.attributedString?.length ?? 0))?.rects ?? []).map { titleNode.textNode.view.convert($0, to: self.view) } + rects.append(optionRects) + } + } + + if isTranslating, !rects.isEmpty { + if self.shimmeringNodes.isEmpty { + for rects in rects { + let shimmeringNode = ShimmeringLinkNode(color: item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.secondaryTextColor.withAlphaComponent(0.1) : item.presentationData.theme.theme.chat.message.outgoing.secondaryTextColor.withAlphaComponent(0.1)) + shimmeringNode.updateRects(rects) + shimmeringNode.frame = self.bounds + shimmeringNode.updateLayout(self.bounds.size) + shimmeringNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.shimmeringNodes.append(shimmeringNode) + self.insertSubnode(shimmeringNode, belowSubnode: self.textNode.textNode) + } + } + } else if !self.shimmeringNodes.isEmpty { + let shimmeringNodes = self.shimmeringNodes + self.shimmeringNodes = [] + + for shimmeringNode in shimmeringNodes { + shimmeringNode.alpha = 0.0 + shimmeringNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak shimmeringNode] _ in + shimmeringNode?.removeFromSupernode() + }) + } + } + } + + override public func animateInsertion(_ currentTimestamp: Double, duration: Double) { + self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + override public func animateAdded(_ currentTimestamp: Double, duration: Double) { + self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.textNode.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + self.statusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + } + + override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { + let textNodeFrame = self.textNode.textNode.frame + if let (index, attributes) = self.textNode.textNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY)) { + if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { + var concealed = true + if let (attributeText, fullText) = self.textNode.textNode.attributeSubstring(name: TelegramTextAttributes.URL, index: index) { + concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText) + } + return ChatMessageBubbleContentTapAction(content: .url(ChatMessageBubbleContentTapAction.Url(url: url, concealed: concealed))) + } else if let peerMention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention { + return ChatMessageBubbleContentTapAction(content: .peerMention(peerId: peerMention.peerId, mention: peerMention.mention, openProfile: false)) + } else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { + return ChatMessageBubbleContentTapAction(content: .textMention(peerName)) + } else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String { + return ChatMessageBubbleContentTapAction(content: .botCommand(botCommand)) + } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { + return ChatMessageBubbleContentTapAction(content: .hashtag(hashtag.peerName, hashtag.hashtag)) + } else { + return ChatMessageBubbleContentTapAction(content: .none) + } + } else { + for optionNode in self.optionNodes { + if optionNode.frame.contains(point), case .tap = gesture { + if optionNode.isUserInteractionEnabled { + return ChatMessageBubbleContentTapAction(content: .ignore) + } + } + } + if self.buttonNode.isUserInteractionEnabled, !self.buttonNode.isHidden, self.buttonNode.frame.contains(point) { + return ChatMessageBubbleContentTapAction(content: .ignore) + } + return ChatMessageBubbleContentTapAction(content: .none) + } + } + + override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { + if !self.statusNode.isHidden { + return self.statusNode.reactionView(value: value) + } + return nil + } + + override public func messageEffectTargetView() -> UIView? { + if !self.statusNode.isHidden { + return self.statusNode.messageEffectTargetView() + } + return nil + } +} diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift index f84db49c41..2250de1168 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift @@ -170,6 +170,7 @@ public final class ChatRecentActionsController: TelegramBaseController { }, openBoostToUnrestrict: { }, updateRecordingTrimRange: { _, _, _, _ in }, dismissAllTooltips: { + }, editTodoMessage: { _, _ in }, updateHistoryFilter: { _ in }, updateChatLocationThread: { _, _ in }, toggleChatSidebarMode: { diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index 8d5710fe52..0df71d23a7 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -647,6 +647,8 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { }, playShakeAnimation: { }, displayQuickShare: { _, _ ,_ in }, updateChatLocationThread: { _, _ in + }, requestToggleTodoMessageItem: { _, _, _ in + }, displayTodoToggleUnavailable: { _ in }, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: self.backgroundNode)) self.controllerInteraction = controllerInteraction diff --git a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift index a5a00e7cde..cc30bf2a7b 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift @@ -502,6 +502,8 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess }, playShakeAnimation: { }, displayQuickShare: { _, _ ,_ in }, updateChatLocationThread: { _, _ in + }, requestToggleTodoMessageItem: { _, _, _ in + }, displayTodoToggleUnavailable: { _ in }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: self.context, backgroundNode: self.wallpaperBackgroundNode)) diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 871e2b3caa..bfcc2f5bc4 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -281,7 +281,8 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let playShakeAnimation: () -> Void public let displayQuickShare: (MessageId, ASDisplayNode, ContextGesture) -> Void public let updateChatLocationThread: (Int64?, ChatControllerAnimateInnerChatSwitchDirection?) -> Void - + public let requestToggleTodoMessageItem: (MessageId, Int32, Bool) -> Void + public let displayTodoToggleUnavailable: (MessageId) -> Void public var canPlayMedia: Bool = false public var hiddenMedia: [MessageId: [Media]] = [:] public var expandedTranslationMessageStableIds: Set = Set() @@ -444,6 +445,8 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol playShakeAnimation: @escaping () -> Void, displayQuickShare: @escaping (MessageId, ASDisplayNode, ContextGesture) -> Void, updateChatLocationThread: @escaping (Int64?, ChatControllerAnimateInnerChatSwitchDirection?) -> Void, + requestToggleTodoMessageItem: @escaping (MessageId, Int32, Bool) -> Void, + displayTodoToggleUnavailable: @escaping (MessageId) -> Void, automaticMediaDownloadSettings: MediaAutoDownloadSettings, pollActionState: ChatInterfacePollActionState, stickerSettings: ChatInterfaceStickerSettings, @@ -563,6 +566,8 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol self.playShakeAnimation = playShakeAnimation self.displayQuickShare = displayQuickShare self.updateChatLocationThread = updateChatLocationThread + self.requestToggleTodoMessageItem = requestToggleTodoMessageItem + self.displayTodoToggleUnavailable = displayTodoToggleUnavailable self.automaticMediaDownloadSettings = automaticMediaDownloadSettings diff --git a/submodules/TelegramUI/Components/ComposeTodoScreen/BUILD b/submodules/TelegramUI/Components/ComposeTodoScreen/BUILD new file mode 100644 index 0000000000..bdd35b09ee --- /dev/null +++ b/submodules/TelegramUI/Components/ComposeTodoScreen/BUILD @@ -0,0 +1,51 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ComposeTodoScreen", + module_name = "ComposeTodoScreen", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/TelegramCore", + "//submodules/TelegramPresentationData", + "//submodules/ItemListUI", + "//submodules/AccountContext", + "//submodules/AlertUI", + "//submodules/PresentationDataUtils", + "//submodules/TextFormat", + "//submodules/ObjCRuntimeUtils", + "//submodules/AttachmentUI", + "//submodules/TextInputMenu", + "//submodules/ComponentFlow", + "//submodules/Components/ComponentDisplayAdapters", + "//submodules/Components/ViewControllerComponent", + "//submodules/Components/MultilineTextComponent", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/AppBundle", + "//submodules/TelegramUI/Components/EntityKeyboard", + "//submodules/UndoUI", + "//submodules/Components/BundleIconComponent", + "//submodules/TelegramUI/Components/AnimatedTextComponent", + "//submodules/TelegramUI/Components/PeerAllowedReactionsScreen", + "//submodules/TelegramUI/Components/ListMultilineTextFieldItemComponent", + "//submodules/TelegramUI/Components/ListActionItemComponent", + "//submodules/TelegramUI/Components/TextFieldComponent", + "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/ChatPresentationInterfaceState", + "//submodules/TelegramUI/Components/EmojiSuggestionsComponent", + "//submodules/TelegramUI/Components/ListComposePollOptionComponent", + "//submodules/ComposePollUI", + "//submodules/Markdown", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift b/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift new file mode 100644 index 0000000000..04714e2e0e --- /dev/null +++ b/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift @@ -0,0 +1,1531 @@ +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 + +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: String? + + init(id: Int32) { + self.id = id + } + } + + final class View: UIView, UIScrollViewDelegate { + private let scrollView: UIScrollView + + private let todoTextSection = ComponentView() + + private let todoItemsSectionHeader = ComponentView() + private let todoItemsSectionFooterContainer = UIView() + private var todoItemsSectionFooter = ComponentView() + private var todoItemsSectionContainer: ListSectionContentView + + private let todoSettingsSection = ComponentView() + private let actionButton = ComponentView() + + 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: String? + + 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() + + private var currentEmojiSuggestionView: ComponentHostView? + + private var currentEditingTag: AnyObject? + + var isAppendableByOthers = false + var isCompletableByOthers = false + + 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) + } + + 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() -> TelegramMediaTodo? { + if self.todoTextInputState.text.length == 0 { + return nil + } + + var mappedItems: [TelegramMediaTodo.Item] = [] + for todoItem in self.todoItems { + if todoItem.textInputState.text.length == 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) + } + } + + 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, 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 isFirstTime = self.component == nil + if self.component == nil { + if let existingTodo = component.initialData.existingTodo { + self.resetTodoText = existingTodo.text + + for item in existingTodo.items { + let todoItem = ComposeTodoScreenComponent.TodoItem( + id: item.id + ) + todoItem.resetText = item.text + 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 = environment.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] = [] + todoTextSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListComposePollOptionComponent( + externalState: self.todoTextInputState, + context: component.context, + theme: environment.theme, + strings: environment.strings, + isEnabled: canEdit, + resetText: self.resetTodoText.flatMap { resetText in + return ListComposePollOptionComponent.ResetText(value: NSAttributedString(string: 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: environment.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: "Title", size: itemView.bounds.size, transition: .immediate) + } + } + contentHeight += todoTextSectionSize.height + contentHeight += sectionSpacing + + var todoItemsSectionItems: [AnyComponentWithIdentity] = [] + + 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 + } + + todoItemsSectionItems.append(AnyComponentWithIdentity(id: todoItem.id, component: AnyComponent(ListComposePollOptionComponent( + externalState: todoItem.textInputState, + context: component.context, + theme: environment.theme, + strings: environment.strings, + isEnabled: isEnabled, + resetText: todoItem.resetText.flatMap { resetText in + return ListComposePollOptionComponent.ResetText(value: NSAttributedString(string: resetText)) + }, + assumeIsEditing: self.inputMediaNodeTargetTag === todoItem.textFieldTag, + characterLimit: component.initialData.maxTodoItemLength, + 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)) + }, + 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 + )) + } + + 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) + } + } + + for i in 0 ..< todoItemsSectionReadyItems.count { + var activate = false + let placeholder: String + if i == todoItemsSectionReadyItems.count - 1 { + placeholder = "Add a Task" + if isFirstTime, component.initialData.append { + activate = true + } + } else { + placeholder = "Task" + } + + 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: environment.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: "TO DO LIST", + 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 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 + if !"".isEmpty, todoItemsLimitReached { + todoItemsFooterTransition = todoItemsFooterTransition.withAnimation(.none) + + let textFont = Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize) + let boldTextFont = Font.semibold(presentationData.listsFontSize.itemListBaseHeaderFontSize) + let textColor = environment.theme.list.freeTextColor + todoItemsComponent = AnyComponent(MultilineTextComponent( + text: .markdown( + text: "Limit of tasks reached. You can increase the limit to **20 tasks** by subscribing to [Telegram Premium]().", + attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: textFont, textColor: textColor), + bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), + link: MarkdownAttributeSet(font: textFont, textColor: environment.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 = "You can add {count} more tasks." //environment.strings.CreatePoll_OptionCountFooterFormat(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: environment.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] = [] + if canEdit && component.peer.id != component.context.account.peerId { + todoSettingsSectionItems.append(AnyComponentWithIdentity(id: "completable", component: AnyComponent(ListActionItemComponent( + theme: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Allow Others to Mark as Done", + 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.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: environment.theme, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Allow Others to Add Tasks", + 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.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: environment.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 + 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, backgroundColor: environment.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.scrollIndicatorInsets != scrollInsets { + self.scrollView.scrollIndicatorInsets = 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, 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 append: Bool + fileprivate let canEdit: Bool + + fileprivate init( + maxTodoTextLength: Int, + maxTodoItemLength: Int, + maxTodoItemsCount: Int, + existingTodo: TelegramMediaTodo?, + append: Bool, + canEdit: Bool + ) { + self.maxTodoTextLength = maxTodoTextLength + self.maxTodoItemLength = maxTodoItemLength + self.maxTodoItemsCount = maxTodoItemsCount + self.existingTodo = existingTodo + 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 = "Add a Task" + } else { + self.title = initialData.existingTodo != nil ? "Edit To Do List" : "New To Do List" + } + + 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 ? "Save" : presentationData.strings.CreatePoll_Create, 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, 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, + 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) { + completion() + } + + public func shouldDismissImmediately() -> Bool { + return true + } +} diff --git a/submodules/TelegramUI/Components/ListComposePollOptionComponent/BUILD b/submodules/TelegramUI/Components/ListComposePollOptionComponent/BUILD new file mode 100644 index 0000000000..52d1774caa --- /dev/null +++ b/submodules/TelegramUI/Components/ListComposePollOptionComponent/BUILD @@ -0,0 +1,30 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ListComposePollOptionComponent", + module_name = "ListComposePollOptionComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/TelegramPresentationData", + "//submodules/ComponentFlow", + "//submodules/Components/MultilineTextComponent", + "//submodules/TelegramUI/Components/SliderComponent", + "//submodules/TelegramUI/Components/ListSectionComponent", + "//submodules/TelegramUI/Components/TextFieldComponent", + "//submodules/TelegramUI/Components/LottieComponent", + "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/CheckNode", + "//submodules/AccountContext", + "//submodules/PresentationDataUtils", + ], + visibility = [ + "//visibility:public", + ], +) + diff --git a/submodules/ComposePollUI/Sources/ListComposePollOptionComponent.swift b/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift similarity index 98% rename from submodules/ComposePollUI/Sources/ListComposePollOptionComponent.swift rename to submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift index 6a0986e23d..83b11d55bf 100644 --- a/submodules/ComposePollUI/Sources/ListComposePollOptionComponent.swift +++ b/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift @@ -73,6 +73,7 @@ public final class ListComposePollOptionComponent: Component { public let theme: PresentationTheme public let strings: PresentationStrings public let placeholder: NSAttributedString? + public let isEnabled: Bool public let resetText: ResetText? public let assumeIsEditing: Bool public let characterLimit: Int? @@ -92,6 +93,7 @@ public final class ListComposePollOptionComponent: Component { theme: PresentationTheme, strings: PresentationStrings, placeholder: NSAttributedString? = nil, + isEnabled: Bool = true, resetText: ResetText? = nil, assumeIsEditing: Bool = false, characterLimit: Int, @@ -110,6 +112,7 @@ public final class ListComposePollOptionComponent: Component { self.theme = theme self.strings = strings self.placeholder = placeholder + self.isEnabled = isEnabled self.resetText = resetText self.assumeIsEditing = assumeIsEditing self.characterLimit = characterLimit @@ -140,6 +143,9 @@ public final class ListComposePollOptionComponent: Component { if lhs.placeholder != rhs.placeholder { return false } + if lhs.isEnabled != rhs.isEnabled { + return false + } if lhs.resetText != rhs.resetText { return false } @@ -400,6 +406,9 @@ public final class ListComposePollOptionComponent: Component { self.textField.parentState = state } transition.setFrame(view: textFieldView, frame: textFieldFrame) + + transition.setAlpha(view: textFieldView, alpha: component.isEnabled ? 1.0 : 0.3) + textFieldView.isUserInteractionEnabled = component.isEnabled } if let selection = component.selection { diff --git a/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift b/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift index 0c89c05b2c..85e90bf1cb 100644 --- a/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift +++ b/submodules/TelegramUI/Components/ListSectionComponent/Sources/ListSectionComponent.swift @@ -76,6 +76,7 @@ public final class ListSectionContentView: UIView { private let contentItemContainerView: UIView public let externalContentBackgroundView: DynamicCornerRadiusView + public var automaticallyLayoutExternalContentBackgroundView = true public var itemViews: [AnyHashable: ItemView] = [:] private var highlightedItemId: AnyHashable? @@ -283,7 +284,9 @@ public final class ListSectionContentView: UIView { } self.externalContentBackgroundView.update(size: backgroundFrame.size, corners: corners, transition: transition) } - transition.setFrame(view: self.externalContentBackgroundView, frame: backgroundFrame) + if self.automaticallyLayoutExternalContentBackgroundView { + transition.setFrame(view: self.externalContentBackgroundView, frame: backgroundFrame) + } transition.setAlpha(view: self.externalContentBackgroundView, alpha: backgroundAlpha) transition.setCornerRadius(layer: self.layer, cornerRadius: contentCornerRadius) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index a6b28d8ba4..9c8fd57fd5 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -435,6 +435,7 @@ final class PeerInfoSelectionPanelNode: ASDisplayNode { }, openBoostToUnrestrict: { }, updateRecordingTrimRange: { _, _, _, _ in }, dismissAllTooltips: { + }, editTodoMessage: { _, _ in }, updateHistoryFilter: { _ in }, updateChatLocationThread: { _, _ in }, toggleChatSidebarMode: { @@ -3854,6 +3855,8 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro }, playShakeAnimation: { }, displayQuickShare: { _, _ ,_ in }, updateChatLocationThread: { _, _ in + }, requestToggleTodoMessageItem: { _, _, _ in + }, displayTodoToggleUnavailable: { _ in }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) self.hiddenMediaDisposable = context.sharedContext.mediaManager.galleryHiddenMediaManager.hiddenIds().startStrict(next: { [weak self] ids in diff --git a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift index 087b0199bf..a6655583a8 100644 --- a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift @@ -823,6 +823,7 @@ final class PeerSelectionControllerNode: ASDisplayNode { }, openBoostToUnrestrict: { }, updateRecordingTrimRange: { _, _, _, _ in }, dismissAllTooltips: { + }, editTodoMessage: { _, _ in }, updateHistoryFilter: { _ in }, updateChatLocationThread: { _, _ in }, toggleChatSidebarMode: { diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift index 940c8c781b..a54f61bdb7 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionScreen/Sources/StarsTransactionScreen.swift @@ -835,11 +835,21 @@ private final class StarsTransactionSheetContent: CombinedComponent { ) )) } else if case .unique = giftAnimationSubject { + let reason: String + if count < StarsAmount.zero, case let .transaction(transaction, _) = subject { + if transaction.flags.contains(.isStarGiftResale) { + reason = strings.Stars_Transaction_GiftPurchase + } else { + reason = strings.Stars_Transaction_GiftTransfer + } + } else { + reason = strings.Stars_Transaction_GiftSale + } tableItems.append(.init( id: "reason", title: strings.Stars_Transaction_Giveaway_Reason, component: AnyComponent( - MultilineTextComponent(text: .plain(NSAttributedString(string: count < StarsAmount.zero ? strings.Stars_Transaction_GiftPurchase : strings.Stars_Transaction_GiftSale, font: tableFont, textColor: tableTextColor))) + MultilineTextComponent(text: .plain(NSAttributedString(string: reason, font: tableFont, textColor: tableTextColor))) ) )) } diff --git a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift index 9d29afc635..65e069f264 100644 --- a/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift +++ b/submodules/TelegramUI/Components/Stars/StarsTransactionsScreen/Sources/StarsTransactionsListPanelComponent.swift @@ -328,7 +328,15 @@ final class StarsTransactionsListPanelComponent: Component { break } } - itemSubtitle = item.count > StarsAmount.zero ? environment.strings.Stars_Intro_Transaction_GiftSale : environment.strings.Stars_Intro_Transaction_GiftPurchase + if item.count > StarsAmount.zero { + itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftSale + } else { + if item.flags.contains(.isStarGiftResale) { + itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftPurchase + } else { + itemSubtitle = environment.strings.Stars_Intro_Transaction_GiftTransfer + } + } } } } else if let _ = item.giveawayMessageId { diff --git a/submodules/TelegramUI/Components/ToastComponent/Sources/ToastContentComponent.swift b/submodules/TelegramUI/Components/ToastComponent/Sources/ToastContentComponent.swift index 4d7b6b112c..bedee031cf 100644 --- a/submodules/TelegramUI/Components/ToastComponent/Sources/ToastContentComponent.swift +++ b/submodules/TelegramUI/Components/ToastComponent/Sources/ToastContentComponent.swift @@ -9,17 +9,20 @@ public final class ToastContentComponent: Component { public let content: AnyComponent public let insets: UIEdgeInsets public let iconSpacing: CGFloat + public let action: (() -> Void)? public init( icon: AnyComponent, content: AnyComponent, insets: UIEdgeInsets = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0), - iconSpacing: CGFloat = 10.0 + iconSpacing: CGFloat = 10.0, + action: (() -> Void)? = nil ) { self.icon = icon self.content = content self.insets = insets self.iconSpacing = iconSpacing + self.action = action } public static func ==(lhs: ToastContentComponent, rhs: ToastContentComponent) -> Bool { @@ -39,6 +42,8 @@ public final class ToastContentComponent: Component { } public final class View: UIView { + private var component: ToastContentComponent? + private let backgroundView: BlurredBackgroundView private let icon = ComponentView() private let content = ComponentView() @@ -63,9 +68,21 @@ public final class ToastContentComponent: Component { fatalError("init(coder:) has not been implemented") } + + @objc private func tapped() { + self.component?.action?() + } + func update(component: ToastContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { var contentHeight: CGFloat = 0.0 + if self.component == nil { + if let _ = component.action { + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapped))) + } + } + self.component = component + let leftInset: CGFloat = component.insets.left let rightInset: CGFloat = component.insets.right let topInset: CGFloat = component.insets.top diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Todo.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Todo.imageset/Contents.json new file mode 100644 index 0000000000..8985bddebc --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Todo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "todolist_30.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Todo.imageset/todolist_30.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Todo.imageset/todolist_30.pdf new file mode 100644 index 0000000000..9532ae73d8 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Chat/Attach Menu/Todo.imageset/todolist_30.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoCheck.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoCheck.imageset/Contents.json new file mode 100644 index 0000000000..e801cb50df --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoCheck.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "todo_check.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoCheck.imageset/todo_check.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoCheck.imageset/todo_check.pdf new file mode 100644 index 0000000000..2c72316d33 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoCheck.imageset/todo_check.pdf differ diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoDot.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoDot.imageset/Contents.json new file mode 100644 index 0000000000..3e11632391 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoDot.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "todo_dot.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoDot.imageset/todo_dot.pdf b/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoDot.imageset/todo_dot.pdf new file mode 100644 index 0000000000..618b27e745 Binary files /dev/null and b/submodules/TelegramUI/Images.xcassets/Chat/Message/TodoDot.imageset/todo_dot.pdf differ diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index 3e64d87570..a924ac308f 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -21,7 +21,6 @@ import DeviceLocationManager import ShareController import UrlEscaping import ContextUI -import ComposePollUI import AlertUI import PresentationDataUtils import UndoUI @@ -4127,6 +4126,11 @@ extension ChatControllerImpl { return } self.dismissAllTooltips() + }, editTodoMessage: { [weak self] messageId, append in + guard let self else { + return + } + self.openTodoEditing(messageId: messageId, append: append) }, updateHistoryFilter: { [weak self] update in guard let self else { return diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift index 733ef38465..8e0cb7ac42 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift @@ -595,7 +595,17 @@ extension ChatControllerImpl { self.dismissAllTooltips() let insets = layout.insets(options: [.input]) - let location = CGRect(origin: CGPoint(x: layout.size.width - layout.safeInsets.right - 42.0 - UIScreenPixel, y: layout.size.height - insets.bottom - 122.0), size: CGSize()) + + var screenWidth = layout.size.width + if layout.metrics.isTablet { + if layout.size.height == layout.deviceMetrics.screenSize.width { + screenWidth = layout.deviceMetrics.screenSize.height + } else { + screenWidth = layout.deviceMetrics.screenSize.width + } + } + + let location = CGRect(origin: CGPoint(x: screenWidth - layout.safeInsets.right - 42.0 - UIScreenPixel, y: layout.size.height - insets.bottom - 122.0), size: CGSize()) let tooltipController = TooltipScreen( account: self.context.account, diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index ec5bd31af6..1ff6f9702b 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -22,6 +22,7 @@ import ShareController import UrlEscaping import ContextUI import ComposePollUI +import ComposeTodoScreen import AlertUI import PresentationDataUtils import UndoUI @@ -342,6 +343,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G var selectMessagePollOptionDisposables: DisposableDict? var selectPollOptionFeedback: HapticFeedback? + var updateMessageTodoDisposables: DisposableDict? + var resolveUrlDisposable: MetaDisposable? var contextQueryStates: [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)] = [:] @@ -4766,6 +4769,68 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return direction ? .right : .left } self.updateChatLocationThread(threadId: threadId, animationDirection: animationDirection ?? defaultDirection) + }, requestToggleTodoMessageItem: { [weak self] messageId, itemId, value in + guard let self else { + return + } + + let disposables: DisposableDict + if let current = self.updateMessageTodoDisposables { + disposables = current + } else { + disposables = DisposableDict() + self.updateMessageTodoDisposables = disposables + } + var completedIds: [Int32] = [] + var incompletedIds: [Int32] = [] + if value { + completedIds.append(itemId) + } else { + incompletedIds.append(itemId) + } + let signal = self.context.engine.messages.requestUpdateTodoMessageItems(messageId: messageId, completedIds: completedIds, incompletedIds: incompletedIds) + disposables.set((signal + |> deliverOnMainQueue).startStrict(next: { todo in + + }, error: { _ in + + }), forKey: messageId) + }, displayTodoToggleUnavailable: { [weak self] messageId in + guard let self else { + return + } + self.dismissAllUndoControllers() + //TODO:localize + if !self.context.isPremium { + let controller = UndoOverlayController( + presentationData: self.presentationData, + content: .premiumPaywall(title: nil, text: "Only [Telegram Premium]() subscribers can mark tasks as done.", customUndoText: nil, timeout: nil, linkAction: nil), + action: { [weak self] action in + guard let self else { + return false + } + if case .info = action { + let controller = self.context.sharedContext.makePremiumIntroController(context: context, source: .presence, forceDark: false, dismissed: nil) + self.push(controller) + } + return false + } + ) + self.present(controller, in: .current) + } else if let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) { + var peerName = "" + if let author = message.author { + peerName = EnginePeer(author).compactDisplayTitle + } + let controller = UndoOverlayController( + presentationData: self.presentationData, + content: .universalImage(image: generateTintedImage(image: UIImage(bundleImageName: "Chat/Stickers/Lock"), color: .white)!, size: nil, title: nil, text: "\(peerName) has restricted others from editing this to do list.", customUndoText: nil, timeout: nil), + action: { _ in + return false + } + ) + self.present(controller, in: .current) + } }, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: self.stickerSettings, presentationContext: ChatPresentationContext(context: context, backgroundNode: self.chatBackgroundNode)) controllerInteraction.enableFullTranslucency = context.sharedContext.energyUsageSettings.fullTranslucency @@ -5819,6 +5884,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.updateChatLocationThreadDisposable?.dispose() self.accountPeerDisposable?.dispose() self.contentDataDisposable?.dispose() + self.updateMessageTodoDisposables?.dispose() } public func updatePresentationMode(_ mode: ChatControllerPresentationMode) { @@ -7549,57 +7615,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.present(tooltipScreen, in: .current) } - - func configurePollCreation(isQuiz: Bool? = nil) -> ViewController? { - guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { - return nil - } - return createPollController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: EnginePeer(peer), isQuiz: isQuiz, completion: { [weak self] poll in - guard let strongSelf = self else { - return - } - strongSelf.presentPaidMessageAlertIfNeeded(completion: { [weak self] postpone in - guard let strongSelf = self else { - return - } - let replyMessageSubject = strongSelf.presentationInterfaceState.interfaceState.replyMessageSubject - strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ - if let strongSelf = self { - strongSelf.chatDisplayNode.collapseInput() - - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { - $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil) } - }) - } - }, nil) - let message: EnqueueMessage = .message( - text: "", - attributes: [], - inlineStickers: [:], - mediaReference: .standalone(media: TelegramMediaPoll( - pollId: MediaId(namespace: Namespaces.Media.LocalPoll, id: Int64.random(in: Int64.min ... Int64.max)), - publicity: poll.publicity, - kind: poll.kind, - text: poll.text.string, - textEntities: poll.text.entities, - options: poll.options, - correctAnswers: poll.correctAnswers, - results: poll.results, - isClosed: false, - deadlineTimeout: poll.deadlineTimeout - )), - threadId: strongSelf.chatLocation.threadId, - replyToMessageId: nil, - replyToStoryId: nil, - localGroupingKey: nil, - correlationId: nil, - bubbleUpEmojiOrStickersets: [] - ) - strongSelf.sendMessages([message.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel)]) - }) - }) - } - + func transformEnqueueMessages(_ messages: [EnqueueMessage], postpone: Bool = false) -> [EnqueueMessage] { let silentPosting = self.presentationInterfaceState.interfaceState.silentPosting return transformEnqueueMessages(messages, silentPosting: silentPosting, postpone: postpone) diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift index ccd523a091..c9a0000ce1 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift @@ -33,6 +33,8 @@ import AutomaticBusinessMessageSetupScreen import MediaEditorScreen import CameraScreen import ShareController +import ComposeTodoScreen +import ComposePollUI extension ChatControllerImpl { enum AttachMenuSubject { @@ -113,6 +115,8 @@ extension ChatControllerImpl { availableButtons.insert(.poll, at: max(0, availableButtons.count - 1)) } + availableButtons.append(.todo) + let presentationData = self.presentationData var isScheduledMessages = false @@ -622,6 +626,26 @@ extension ChatControllerImpl { completion(controller, controller.mediaPickerContext) strongSelf.controllerNavigationDisposable.set(nil) } + case .todo: + if strongSelf.context.isPremium { + if let controller = strongSelf.configureTodoCreation() as? AttachmentContainable { + completion(controller, controller.mediaPickerContext) + strongSelf.controllerNavigationDisposable.set(nil) + } + } else { + var replaceImpl: ((ViewController) -> Void)? + let demoController = strongSelf.context.sharedContext.makePremiumDemoController(context: strongSelf.context, subject: .todo, forceDark: false, action: { + let controller = context.sharedContext.makePremiumIntroController(context: context, source: .todo, forceDark: false, dismissed: nil) + replaceImpl?(controller) + }, dismissed: nil) + replaceImpl = { [weak demoController] c in + demoController?.replace(with: c) + } + strongSelf.push(demoController) + Queue.mainQueue().after(0.4) { + strongSelf.attachmentController?.dismiss(animated: false) + } + } case .gift: if let peer = strongSelf.presentationInterfaceState.renderedPeer?.peer, let starsContext = context.starsContext { let premiumGiftOptions = strongSelf.presentationInterfaceState.premiumGiftOptions @@ -677,12 +701,12 @@ extension ChatControllerImpl { let _ = self.context.engine.messages.acceptAttachMenuBotDisclaimer(botId: bot.peer.id).startStandalone() } let _ = (self.context.engine.messages.addBotToAttachMenu(botId: bot.peer.id, allowWrite: allowWrite) - |> deliverOnMainQueue).startStandalone(error: { _ in + |> deliverOnMainQueue).startStandalone(error: { _ in }, completed: { [weak controller] in controller?.refresh() }) }, - dismissed: { + dismissed: { strongSelf.attachmentController?.dismiss(animated: true) }) strongSelf.present(alertController, in: .window(.root)) @@ -1967,4 +1991,147 @@ extension ChatControllerImpl { mainController.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) self.push(mainController) } + + func configurePollCreation(isQuiz: Bool? = nil) -> ViewController? { + guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { + return nil + } + return ComposePollScreen( + context: self.context, + initialData: ComposePollScreen.initialData(context: self.context), + peer: EnginePeer(peer), + isQuiz: isQuiz, + completion: { [weak self] poll in + guard let self else { + return + } + self.presentPaidMessageAlertIfNeeded(completion: { [weak self] postpone in + guard let self else { + return + } + let replyMessageSubject = self.presentationInterfaceState.interfaceState.replyMessageSubject + self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in + if let self { + self.chatDisplayNode.collapseInput() + + self.updateChatPresentationInterfaceState(animated: true, interactive: false, { + $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil) } + }) + } + }, nil) + let message: EnqueueMessage = .message( + text: "", + attributes: [], + inlineStickers: [:], + mediaReference: .standalone(media: TelegramMediaPoll( + pollId: MediaId(namespace: Namespaces.Media.LocalPoll, id: Int64.random(in: Int64.min...Int64.max)), + publicity: poll.publicity, + kind: poll.kind, + text: poll.text.string, + textEntities: poll.text.entities, + options: poll.options, + correctAnswers: poll.correctAnswers, + results: poll.results, + isClosed: false, + deadlineTimeout: poll.deadlineTimeout + )), + threadId: self.chatLocation.threadId, + replyToMessageId: nil, + replyToStoryId: nil, + localGroupingKey: nil, + correlationId: nil, + bubbleUpEmojiOrStickersets: [] + ) + self.sendMessages([message.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel)]) + }) + } + ) + } + + func configureTodoCreation() -> ViewController? { + guard let peer = self.presentationInterfaceState.renderedPeer?.peer else { + return nil + } + return ComposeTodoScreen( + context: self.context, + initialData: ComposeTodoScreen.initialData( + context: self.context + ), + peer: EnginePeer(peer), + completion: { [weak self] todo in + guard let self else { + return + } + self.presentPaidMessageAlertIfNeeded(completion: { [weak self] postpone in + guard let self else { + return + } + let replyMessageSubject = self.presentationInterfaceState.interfaceState.replyMessageSubject + self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in + if let self { + self.chatDisplayNode.collapseInput() + + self.updateChatPresentationInterfaceState(animated: true, interactive: false, { + $0.updatedInterfaceState { $0.withUpdatedReplyMessageSubject(nil).withUpdatedSendMessageEffect(nil) } + }) + } + }, nil) + let message: EnqueueMessage = .message( + text: "", + attributes: [], + inlineStickers: [:], + mediaReference: .standalone(media: todo), + threadId: self.chatLocation.threadId, + replyToMessageId: nil, + replyToStoryId: nil, + localGroupingKey: nil, + correlationId: nil, + bubbleUpEmojiOrStickersets: [] + ) + self.sendMessages([message.withUpdatedReplyToMessageId(replyMessageSubject?.subjectModel)]) + }) + } + ) + } + + func openTodoEditing(messageId: EngineMessage.Id, append: Bool) { + guard let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId), let peer = self.presentationInterfaceState.renderedPeer?.peer else { + return + } + guard let existingTodo = message.media.first(where: { $0 is TelegramMediaTodo }) as? TelegramMediaTodo else { + return + } + + let canEdit = canEditMessage(context: self.context, limitsConfiguration: self.context.currentLimitsConfiguration.with { EngineConfiguration.Limits($0) }, message: message) + + let controller = ComposeTodoScreen( + context: self.context, + initialData: ComposeTodoScreen.initialData( + context: self.context, + existingTodo: existingTodo, + append: append, + canEdit: canEdit + ), + peer: EnginePeer(peer), + completion: { [weak self] todo in + guard let self else { + return + } + if canEdit { + let _ = self.context.engine.messages.requestEditMessage( + messageId: messageId, + text: "", + media: .update(.standalone(media: todo)), + entities: nil, + inlineStickers: [:] + ).start() + } else { + let appendedItems = Array(todo.items[existingTodo.items.count ..< todo.items.count]) + let _ = self.context.engine.messages.appendTodoMessageItems(messageId: messageId, items: appendedItems).start() + } + } + ) + controller.navigationPresentation = .modal + self.push(controller) + } } diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index 6b3cff7b36..ef44aefeca 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -790,22 +790,22 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto self.prefetchManager = InChatPrefetchManager(context: context) self.adMessagesContext = adMessagesContext - var adMessages: Signal<(interPostInterval: Int32?, messages: [Message]), NoError> + var adMessages: Signal<(interPostInterval: Int32?, messages: [Message], startDelay: Int32?, betweenDelay: Int32?), NoError> if case .bubbles = mode, let adMessagesContext { let peerId = adMessagesContext.peerId if peerId.namespace == Namespaces.Peer.CloudUser { - adMessages = .single((nil, [])) + adMessages = .single((nil, [], nil, nil)) } else { if context.sharedContext.immediateExperimentalUISettings.fakeAds { adMessages = context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) ) - |> map { peer -> (interPostInterval: Int32?, messages: [Message]) in + |> map { peer -> (interPostInterval: Int32?, messages: [Message], startDelay: Int32?, betweenDelay: Int32?) in let fakeAdMessages: [Message] = (0 ..< 10).map { i -> Message in var attributes: [MessageAttribute] = [] let mappedMessageType: AdMessageAttribute.MessageType = .sponsored - attributes.append(AdMessageAttribute(opaqueId: "fake_ad_\(i)".data(using: .utf8)!, messageType: mappedMessageType, url: "t.me/telegram", buttonText: "VIEW", sponsorInfo: nil, additionalInfo: nil, canReport: false, hasContentMedia: false)) + attributes.append(AdMessageAttribute(opaqueId: "fake_ad_\(i)".data(using: .utf8)!, messageType: mappedMessageType, url: "t.me/telegram", buttonText: "VIEW", sponsorInfo: nil, additionalInfo: nil, canReport: false, hasContentMedia: false, minDisplayDuration: nil, maxDisplayDuration: nil)) var messagePeers = SimpleDictionary() @@ -874,14 +874,14 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto associatedStories: [:] ) } - return (10, fakeAdMessages) + return (10, fakeAdMessages, nil, nil) } } else { adMessages = adMessagesContext.state } } } else { - adMessages = .single((nil, [])) + adMessages = .single((nil, [], nil, nil)) } let clientId = Atomic(value: nextClientId) @@ -1223,9 +1223,9 @@ public final class ChatHistoryListNodeImpl: ListView, ChatHistoryNode, ChatHisto self.beginChatHistoryTransitions(resetScrolling: true, switchedToAnotherSource: false) } - private func beginAdMessageManagement(adMessages: Signal<(interPostInterval: Int32?, messages: [Message]), NoError>) { + private func beginAdMessageManagement(adMessages: Signal<(interPostInterval: Int32?, messages: [Message], startDelay: Int32?, betweenDelay: Int32?), NoError>) { self.adMessagesDisposable = (adMessages - |> deliverOnMainQueue).startStrict(next: { [weak self] interPostInterval, messages in + |> deliverOnMainQueue).startStrict(next: { [weak self] interPostInterval, messages, _, _ in guard let self else { return } diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index a0e51dbbc2..a77786f6e0 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -1479,25 +1479,33 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState isMigrated = false } - if data.canEdit && !isPinnedMessages && !isMigrated { - actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_MessageDialogEdit, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.actionSheet.primaryTextColor) - }, action: { c, f in - interfaceInteraction.setupEditMessage(messages[0].id, { transition in - f(.custom(transition)) - }) - }))) - } - var activePoll: TelegramMediaPoll? + var activeTodo: TelegramMediaTodo? for media in message.media { if let poll = media as? TelegramMediaPoll, !poll.isClosed, message.id.namespace == Namespaces.Message.Cloud, poll.pollId.namespace == Namespaces.Media.CloudPoll { if !isPollEffectivelyClosed(message: message, poll: poll) { activePoll = poll } + } else if let todo = media as? TelegramMediaTodo { + activeTodo = todo } } + if data.canEdit && !isPinnedMessages && !isMigrated { + actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_MessageDialogEdit, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.actionSheet.primaryTextColor) + }, action: { c, f in + if let _ = activeTodo { + interfaceInteraction.editTodoMessage(messages[0].id, false) + f(.dismissWithoutContent) + } else { + interfaceInteraction.setupEditMessage(messages[0].id, { transition in + f(.custom(transition)) + }) + } + }))) + } + if let activePoll = activePoll, let voters = activePoll.results.voters { var hasSelected = false for result in voters { @@ -1515,6 +1523,21 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } + if let activeTodo { + var canAppend = false + if message.author?.id == context.account.peerId || activeTodo.flags.contains(.othersCanAppend) { + canAppend = true + } + if canAppend { + actions.append(.action(ContextMenuActionItem(text: "Add a Task", icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddCircle"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + interfaceInteraction.editTodoMessage(messages[0].id, true) + f(.dismissWithoutContent) + }))) + } + } + var canPin = data.canPin if case let .replyThread(message) = chatPresentationInterfaceState.chatLocation { if !message.isForumPost { diff --git a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift index c1f3276d74..fc0892252c 100644 --- a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift +++ b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift @@ -194,6 +194,8 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu }, playShakeAnimation: { }, displayQuickShare: { _, _ ,_ in }, updateChatLocationThread: { _, _ in + }, requestToggleTodoMessageItem: { _, _, _ in + }, displayTodoToggleUnavailable: { _ in }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) self.dimNode = ASDisplayNode() diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index dfdf148d15..7e040a92df 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -2399,6 +2399,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { }, playShakeAnimation: { }, displayQuickShare: { _, _ ,_ in }, updateChatLocationThread: { _, _ in + }, requestToggleTodoMessageItem: { _, _, _ in + }, displayTodoToggleUnavailable: { _ in }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: backgroundNode as? WallpaperBackgroundNode)) @@ -2714,6 +2716,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { mappedSource = .animatedEmoji case .paidMessages: mappedSource = .paidMessages + case .todo: + mappedSource = .paidMessages case let .auth(price): mappedSource = .auth(price) } @@ -2792,6 +2796,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { mappedSubject = .messageEffects case .paidMessages: mappedSubject = .paidMessages + case .todo: + mappedSubject = .todo case .business: mappedSubject = .business buttonText = presentationData.strings.Chat_EmptyStateIntroFooterPremiumActionButton