diff --git a/submodules/ComposePollUI/Sources/CreatePollController.swift b/submodules/ComposePollUI/Sources/CreatePollController.swift index 366603c926..0ff86c245f 100644 --- a/submodules/ComposePollUI/Sources/CreatePollController.swift +++ b/submodules/ComposePollUI/Sources/CreatePollController.swift @@ -160,9 +160,10 @@ private final class CreatePollControllerArguments { let updateMultipleChoice: (Bool) -> Void let displayMultipleChoiceDisabled: () -> Void let updateQuiz: (Bool) -> Void - let updateSolutionText: (String) -> Void + let updateSolutionText: (NSAttributedString) -> Void + let solutionTextFocused: (Bool) -> Void - init(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 (String) -> Void) { + init(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) { self.updatePollText = updatePollText self.updateOptionText = updateOptionText self.moveToNextOption = moveToNextOption @@ -176,6 +177,7 @@ private final class CreatePollControllerArguments { self.displayMultipleChoiceDisabled = displayMultipleChoiceDisabled self.updateQuiz = updateQuiz self.updateSolutionText = updateSolutionText + self.solutionTextFocused = solutionTextFocused } } @@ -216,6 +218,18 @@ private enum CreatePollEntryTag: Equatable, ItemListItemTag { } } +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) @@ -227,7 +241,7 @@ private enum CreatePollEntry: ItemListNodeEntry { case quiz(String, Bool) case quizInfo(String) case quizSolutionHeader(String) - case quizSolutionText(placeholder: String, text: String) + case quizSolutionText(placeholder: String, text: SolutionText) case quizSolutionInfo(String) var section: ItemListSectionId { @@ -380,8 +394,10 @@ private enum CreatePollEntry: ItemListNodeEntry { case let .quizSolutionHeader(text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .quizSolutionText(placeholder, text): - return ItemListMultilineInputItem(presentationData: presentationData, text: text, placeholder: placeholder, maxLength: ItemListMultilineInputItemTextLimit(value: 200, display: true), sectionId: self.section, style: .blocks, textUpdated: { text in + return CreatePollTextInputItem(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) @@ -404,7 +420,7 @@ private struct CreatePollControllerState: Equatable { var isAnonymous: Bool = true var isMultipleChoice: Bool = false var isQuiz: Bool = false - var solutionText: String = "" + var solutionText: SolutionText = SolutionText(value: NSAttributedString(string: "")) var isEditingSolution: Bool = false } @@ -460,6 +476,7 @@ private func createPollControllerEntries(presentationData: PresentationData, pee } if isQuiz { + //TODO:localize entries.append(.quizSolutionHeader("EXPLANATION")) entries.append(.quizSolutionText(placeholder: "Add a Comment (Optional)", text: state.solutionText)) entries.append(.quizSolutionInfo("Users will see this comment after choosing a wrong answer, good for educational purposes.")) @@ -481,6 +498,7 @@ public func createPollController(context: AccountContext, peer: Peer, isQuiz: Bo var presentControllerImpl: ((ViewController, Any?) -> Void)? var dismissImpl: (() -> Void)? + var dismissInputImpl: (() -> Void)? var ensureTextVisibleImpl: (() -> Void)? var ensureOptionVisibleImpl: ((Int) -> Void)? var ensureSolutionVisibleImpl: (() -> Void)? @@ -713,12 +731,16 @@ public func createPollController(context: AccountContext, peer: Peer, isQuiz: Bo }, updateSolutionText: { text in updateState { state in var state = state - state.solutionText = text + state.solutionText = SolutionText(value: text) state.focusOptionId = nil state.isEditingSolution = true return state } ensureSolutionVisibleImpl?() + }, solutionTextFocused: { isFocused in + if isFocused { + ensureSolutionVisibleImpl?() + } }) let previousOptionIds = Atomic<[Int]?>(value: nil) @@ -757,7 +779,7 @@ public func createPollController(context: AccountContext, peer: Peer, isQuiz: Bo if !hasSelectedOptions { enabled = false } - if state.solutionText.count > 200 { + if state.solutionText.value.string.count > 200 { enabled = false } } @@ -789,9 +811,9 @@ public func createPollController(context: AccountContext, peer: Peer, isQuiz: Bo let kind: TelegramMediaPollKind if state.isQuiz { kind = .quiz - if !state.solutionText.isEmpty { - let entities = generateTextEntities(state.solutionText, enabledTypes: [.url]) - resolvedSolution = TelegramMediaPollResults.Solution(text: state.solutionText, entities: entities) + if !state.solutionText.value.string.isEmpty { + let entities = generateTextEntities(state.solutionText.value.string, enabledTypes: .url, currentEntities: generateChatInputTextEntities(state.solutionText.value)) + resolvedSolution = TelegramMediaPollResults.Solution(text: state.solutionText.value.string, entities: entities) } } else { kind = .poll(multipleAnswers: state.isMultipleChoice) @@ -1032,6 +1054,11 @@ public func createPollController(context: AccountContext, peer: Peer, isQuiz: Bo } 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 { @@ -1051,6 +1078,12 @@ public func createPollController(context: AccountContext, peer: Peer, isQuiz: Bo return state } + if didReorder { + DispatchQueue.main.async { + dismissInputImpl?() + } + } + return .single(didReorder) }) attemptNavigationImpl = { @@ -1078,6 +1111,9 @@ public func createPollController(context: AccountContext, peer: Peer, isQuiz: Bo } return false } + dismissInputImpl = { [weak controller] in + controller?.view.endEditing(true) + } controller.isOpaqueWhenInOverlay = true controller.blocksBackgroundWhenInOverlay = true controller.experimentalSnapScrollToItem = true diff --git a/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift b/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift new file mode 100644 index 0000000000..3b007588be --- /dev/null +++ b/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift @@ -0,0 +1,685 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import TelegramPresentationData +import ItemListUI +import TextFormat + +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 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(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.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() + }) + } + } + } + } + } +} + +private enum ChatTextInputMenuState { + case inactive + case general + case format +} + +private final class ChatTextInputMenu { + private var stringBold: String = "Bold" + private var stringItalic: String = "Italic" + private var stringMonospace: String = "Monospace" + private var stringLink: String = "Link" + private var stringStrikethrough: String = "Strikethrough" + private var stringUnderline: String = "Underline" + + private(set) var state: ChatTextInputMenuState = .inactive { + didSet { + if self.state != oldValue { + switch self.state { + case .inactive: + UIMenuController.shared.menuItems = [] + case .general: + UIMenuController.shared.menuItems = [] + case .format: + UIMenuController.shared.menuItems = [ + UIMenuItem(title: self.stringBold, action: Selector(("formatAttributesBold:"))), + UIMenuItem(title: self.stringItalic, action: Selector(("formatAttributesItalic:"))), + UIMenuItem(title: self.stringMonospace, action: Selector(("formatAttributesMonospace:"))), + UIMenuItem(title: self.stringStrikethrough, action: Selector(("formatAttributesStrikethrough:"))), + UIMenuItem(title: self.stringUnderline, action: Selector(("formatAttributesUnderline:"))) + ] + } + + } + } + } + + private var observer: NSObjectProtocol? + + init() { + self.observer = NotificationCenter.default.addObserver(forName: UIMenuController.didHideMenuNotification, object: nil, queue: nil, using: { [weak self] _ in + self?.back() + }) + } + + deinit { + if let observer = self.observer { + NotificationCenter.default.removeObserver(observer) + } + } + + func updateStrings(_ strings: PresentationStrings) { + self.stringBold = strings.TextFormat_Bold + self.stringItalic = strings.TextFormat_Italic + self.stringMonospace = strings.TextFormat_Monospace + self.stringLink = strings.TextFormat_Link + self.stringStrikethrough = strings.TextFormat_Strikethrough + self.stringUnderline = strings.TextFormat_Underline + } + + func activate() { + if self.state == .inactive { + self.state = .general + } + } + + func deactivate() { + self.state = .inactive + } + + func format(view: UIView, rect: CGRect) { + if self.state == .general { + self.state = .format + if #available(iOS 13.0, *) { + UIMenuController.shared.showMenu(from: view, rect: rect) + } else { + UIMenuController.shared.isMenuVisible = true + UIMenuController.shared.update() + } + } + } + + func back() { + if self.state == .format { + self.state = .general + } + } +} + + +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 = ChatTextInputMenu() + + 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 ?? 0 + 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(item.text, fontSize: 17.0, textColor: item.presentationData.theme.chat.inputPanel.primaryTextColor, accentTextColor: item.presentationData.theme.chat.inputPanel.panelControlAccentColor, writingDirection: 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) + + 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(strongSelf.textNode, theme: item.presentationData.theme, baseFontSize: 17.0) + } + } else { + strongSelf.textNode.attributedText = attributedText + refreshGenericTextInputAttributes(strongSelf.textNode, theme: item.presentationData.theme, baseFontSize: 17.0) + } + + 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 + 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, short: Bool) { + 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 == Selector(("_showTextStyleOptions:")) { + 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)) + } + + @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) + } + } + + @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) + } + } + + @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) + } + } + + @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) + } + } + + @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) + } + } + + 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(editableTextNode, theme: item.presentationData.theme, baseFontSize: 17.0) + 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, theme: item.presentationData.theme, baseFontSize: 17.0) + refreshGenericTextInputAttributes(editableTextNode, theme: item.presentationData.theme, baseFontSize: 17.0) + } + } + + public func focus() { + if !self.textNode.textView.isFirstResponder { + self.textNode.textView.becomeFirstResponder() + } + } + + 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) { + 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, theme: theme, baseFontSize: 17.0) + refreshGenericTextInputAttributes(textNode, theme: theme, baseFontSize: 17.0) + + let updatedText = stateAttributedStringForText(textNode.attributedText!) + item.textUpdated(updatedText) + } +} diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index 0eed0bf19b..37e8b62e41 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -361,7 +361,7 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture self.view.addGestureRecognizer(trackingRecognizer) self.view.addGestureRecognizer(ListViewReorderingGestureRecognizer(shouldBegin: { [weak self] point in - if let strongSelf = self { + if let strongSelf = self, !strongSelf.isTracking { if let index = strongSelf.itemIndexAtPoint(point) { for i in 0 ..< strongSelf.itemNodes.count { if strongSelf.itemNodes[i].index == index { diff --git a/submodules/Display/Source/ListViewReorderingGestureRecognizer.swift b/submodules/Display/Source/ListViewReorderingGestureRecognizer.swift index fa631b4d6f..22748518c5 100644 --- a/submodules/Display/Source/ListViewReorderingGestureRecognizer.swift +++ b/submodules/Display/Source/ListViewReorderingGestureRecognizer.swift @@ -25,6 +25,12 @@ final class ListViewReorderingGestureRecognizer: UIGestureRecognizer { override func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) + if self.numberOfTouches > 1 { + self.state = .failed + self.ended() + return + } + if self.state == .possible { if let location = touches.first?.location(in: self.view), self.shouldBegin(location) { self.initialLocation = location diff --git a/submodules/ShareController/Sources/ShareControllerNode.swift b/submodules/ShareController/Sources/ShareControllerNode.swift index 8d17ecde81..a504392dbd 100644 --- a/submodules/ShareController/Sources/ShareControllerNode.swift +++ b/submodules/ShareController/Sources/ShareControllerNode.swift @@ -801,7 +801,7 @@ final class ShareControllerNode: ViewControllerTracingNode, UIScrollViewDelegate self.actionButtonNode.badge = nil } else if let defaultAction = self.defaultAction { self.actionButtonNode.setTitle(defaultAction.title, with: Font.regular(20.0), with: self.presentationData.theme.actionSheet.standardActionTextColor, for: .normal) - self.actionButtonNode.isEnabled = false + self.actionButtonNode.isEnabled = true self.actionButtonNode.badge = nil } else { self.actionButtonNode.setTitle(self.presentationData.strings.ShareMenu_Send, with: Font.medium(20.0), with: self.presentationData.theme.actionSheet.disabledActionTextColor, for: .normal) diff --git a/submodules/TelegramCore/Sources/AccountViewTracker.swift b/submodules/TelegramCore/Sources/AccountViewTracker.swift index bfffaf4267..642ef9cc22 100644 --- a/submodules/TelegramCore/Sources/AccountViewTracker.swift +++ b/submodules/TelegramCore/Sources/AccountViewTracker.swift @@ -402,6 +402,7 @@ public final class AccountViewTracker { } private func updatePolls(viewId: Int32, messageIds: Set, messages: [MessageId: Message]) { + let queue = self.queue self.queue.async { var addedMessageIds: [MessageId] = [] var removedMessageIds: [MessageId] = [] @@ -446,13 +447,59 @@ public final class AccountViewTracker { if let account = self.account { for messageId in addedMessageIds { if self.pollDisposables[messageId] == nil { - var signal: Signal = fetchPoll(account: account, messageId: messageId) - |> ignoreValues - signal = (signal |> then( - .complete() - |> delay(30.0, queue: Queue.concurrentDefaultQueue()) - )) |> restart - self.pollDisposables[messageId] = signal.start() + var deadlineTimer: Signal = .single(false) + + if let message = messages[messageId] { + for media in message.media { + if let poll = media as? TelegramMediaPoll { + if let deadlineTimeout = poll.deadlineTimeout, message.id.namespace == Namespaces.Message.Cloud { + let startDate: Int32 + if let forwardInfo = message.forwardInfo { + startDate = forwardInfo.date + } else { + startDate = message.timestamp + } + let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) + let remainingTime = timestamp - startDate - 1 + + if remainingTime > 0 { + deadlineTimer = .single(false) + |> then( + .single(true) + |> suspendAwareDelay(Double(remainingTime), queue: queue) + ) + } else { + deadlineTimer = .single(true) + } + } + } + } + } + + let pollSignal: Signal = deadlineTimer + |> distinctUntilChanged + |> mapToSignal { reachedDeadline -> Signal in + if reachedDeadline { + var signal = fetchPoll(account: account, messageId: messageId) + |> ignoreValues + signal = (signal |> then( + .complete() + |> delay(0.5, queue: Queue.concurrentDefaultQueue()) + )) + |> restart + return signal + } else { + var signal = fetchPoll(account: account, messageId: messageId) + |> ignoreValues + signal = (signal |> then( + .complete() + |> delay(30.0, queue: Queue.concurrentDefaultQueue()) + )) + |> restart + return signal + } + } + self.pollDisposables[messageId] = pollSignal.start() } else { assertionFailure() } diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 6046c53597..11c62f9621 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -1962,8 +1962,47 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let absoluteFrame = sourceNode.view.convert(sourceNode.bounds, to: strongSelf.view).insetBy(dx: 0.0, dy: -4.0).offsetBy(dx: 0.0, dy: 0.0) let tooltipScreen = TooltipScreen(text: solution.text, textEntities: solution.entities, icon: nil, location: absoluteFrame, shouldDismissOnTouch: { point in return .dismiss(consume: absoluteFrame.contains(point)) - }, openUrl: { url in - self?.openUrl(url, concealed: false) + }, openActiveTextItem: { item, action in + guard let strongSelf = self else { + return + } + switch item { + case let .url(url): + switch action { + case .tap: + strongSelf.openUrl(url, concealed: false) + case .longTap: + strongSelf.controllerInteraction?.longTap(.url(url), nil) + } + case let .mention(peerId, mention): + switch action { + case .tap: + strongSelf.controllerInteraction?.openPeer(peerId, .default, nil) + case .longTap: + strongSelf.controllerInteraction?.longTap(.peerMention(peerId, mention), nil) + } + case let .textMention(mention): + switch action { + case .tap: + strongSelf.controllerInteraction?.openPeerMention(mention) + case .longTap: + strongSelf.controllerInteraction?.longTap(.mention(mention), nil) + } + case let .botCommand(command): + switch action { + case .tap: + strongSelf.controllerInteraction?.sendBotCommand(nil, command) + case .longTap: + strongSelf.controllerInteraction?.longTap(.command(command), nil) + } + case let .hashtag(hashtag): + switch action { + case .tap: + strongSelf.controllerInteraction?.openHashtag(nil, hashtag) + case .longTap: + strongSelf.controllerInteraction?.longTap(.hashtag(hashtag), nil) + } + } }) tooltipScreen.becameDismissed = { tooltipScreen in guard let strongSelf = self else { diff --git a/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift index 7b5ee09db1..fd6d151c9a 100644 --- a/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessagePollBubbleContentNode.swift @@ -15,7 +15,7 @@ import TelegramPresentationData func isPollEffectivelyClosed(message: Message, poll: TelegramMediaPoll) -> Bool { if poll.isClosed { return true - } else if let deadlineTimeout = poll.deadlineTimeout, message.id.namespace == Namespaces.Message.Cloud { + }/* else if let deadlineTimeout = poll.deadlineTimeout, message.id.namespace == Namespaces.Message.Cloud { let startDate: Int32 if let forwardInfo = message.forwardInfo { startDate = forwardInfo.date @@ -29,7 +29,7 @@ func isPollEffectivelyClosed(message: Message, poll: TelegramMediaPoll) -> Bool } else { return false } - } else { + }*/ else { return false } } @@ -1382,7 +1382,7 @@ class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { } if textLayout.hasRTL { - strongSelf.textNode.frame = CGRect(origin: CGPoint(x: resultSize.width - textFrame.size.width - textInsets.left - layoutConstants.text.bubbleInsets.right, y: textFrame.origin.y), size: textFrame.size) + strongSelf.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.frame = textFrame } diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index af4dddf2fa..b44c6e92cf 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -333,7 +333,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { accentTextColor = presentationInterfaceState.theme.chat.inputPanel.panelControlAccentColor baseFontSize = max(17.0, presentationInterfaceState.fontSize.baseDisplaySize) } - textInputNode.attributedText = textAttributedStringForStateText(state.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor) + textInputNode.attributedText = textAttributedStringForStateText(state.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil) textInputNode.selectedRange = NSMakeRange(state.selectionRange.lowerBound, state.selectionRange.count) self.updatingInputState = false self.keepSendButtonEnabled = keepSendButtonEnabled @@ -1571,7 +1571,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { accentTextColor = presentationInterfaceState.theme.chat.inputPanel.panelControlAccentColor baseFontSize = max(17.0, presentationInterfaceState.fontSize.baseDisplaySize) } - let cleanReplacementString = textAttributedStringForStateText(NSAttributedString(string: cleanText), fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor) + let cleanReplacementString = textAttributedStringForStateText(NSAttributedString(string: cleanText), fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil) string.replaceCharacters(in: range, with: cleanReplacementString) self.textInputNode?.attributedText = string self.textInputNode?.selectedRange = NSMakeRange(range.lowerBound + cleanReplacementString.length, 0) diff --git a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift index 33aa43790d..fd01167f40 100644 --- a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift +++ b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift @@ -46,12 +46,17 @@ public struct ChatTextFontAttributes: OptionSet { public static let blockQuote = ChatTextFontAttributes(rawValue: 1 << 3) } -public func textAttributedStringForStateText(_ stateText: NSAttributedString, fontSize: CGFloat, textColor: UIColor, accentTextColor: UIColor) -> NSAttributedString { +public func textAttributedStringForStateText(_ stateText: NSAttributedString, fontSize: CGFloat, textColor: UIColor, accentTextColor: UIColor, writingDirection: NSWritingDirection?) -> NSAttributedString { let result = NSMutableAttributedString(string: stateText.string) let fullRange = NSRange(location: 0, length: result.length) result.addAttribute(NSAttributedString.Key.font, value: Font.regular(fontSize), range: fullRange) result.addAttribute(NSAttributedString.Key.foregroundColor, value: textColor, range: fullRange) + let style = NSMutableParagraphStyle() + if let writingDirection = writingDirection { + style.baseWritingDirection = writingDirection + } + result.addAttribute(NSAttributedString.Key.paragraphStyle, value: style, range: fullRange) stateText.enumerateAttributes(in: fullRange, options: [], using: { attributes, range, _ in var fontAttributes: ChatTextFontAttributes = [] @@ -404,19 +409,112 @@ public func refreshChatTextInputAttributes(_ textNode: ASEditableTextNode, theme return } + var writingDirection: NSWritingDirection? + if let style = initialAttributedText.attribute(NSAttributedString.Key.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle { + writingDirection = style.baseWritingDirection + } + var text: NSString = initialAttributedText.string as NSString var fullRange = NSRange(location: 0, length: initialAttributedText.length) var attributedText = NSMutableAttributedString(attributedString: stateAttributedStringForText(initialAttributedText)) refreshTextMentions(text: text, initialAttributedText: initialAttributedText, attributedText: attributedText, fullRange: fullRange) - var resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor) + var resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection) text = resultAttributedText.string as NSString fullRange = NSRange(location: 0, length: initialAttributedText.length) attributedText = NSMutableAttributedString(attributedString: stateAttributedStringForText(resultAttributedText)) refreshTextUrls(text: text, initialAttributedText: resultAttributedText, attributedText: attributedText, fullRange: fullRange) - resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor) + resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection) + + if !resultAttributedText.isEqual(to: initialAttributedText) { + textNode.textView.textStorage.removeAttribute(NSAttributedString.Key.font, range: fullRange) + textNode.textView.textStorage.removeAttribute(NSAttributedString.Key.foregroundColor, range: fullRange) + textNode.textView.textStorage.removeAttribute(NSAttributedString.Key.underlineStyle, range: fullRange) + textNode.textView.textStorage.removeAttribute(NSAttributedString.Key.strikethroughStyle, range: fullRange) + textNode.textView.textStorage.removeAttribute(ChatTextInputAttributes.textMention, range: fullRange) + textNode.textView.textStorage.removeAttribute(ChatTextInputAttributes.textUrl, range: fullRange) + + textNode.textView.textStorage.addAttribute(NSAttributedString.Key.font, value: Font.regular(baseFontSize), range: fullRange) + textNode.textView.textStorage.addAttribute(NSAttributedString.Key.foregroundColor, value: theme.chat.inputPanel.primaryTextColor, range: fullRange) + + attributedText.enumerateAttributes(in: fullRange, options: [], using: { attributes, range, _ in + var fontAttributes: ChatTextFontAttributes = [] + + for (key, value) in attributes { + if key == ChatTextInputAttributes.textMention || key == ChatTextInputAttributes.textUrl { + textNode.textView.textStorage.addAttribute(key, value: value, range: range) + textNode.textView.textStorage.addAttribute(NSAttributedString.Key.foregroundColor, value: theme.chat.inputPanel.panelControlAccentColor, range: range) + + if theme.chat.inputPanel.panelControlAccentColor.isEqual(theme.chat.inputPanel.primaryTextColor) { + textNode.textView.textStorage.addAttribute(NSAttributedString.Key.underlineStyle, value: NSUnderlineStyle.single.rawValue as NSNumber, range: range) + } + } else if key == ChatTextInputAttributes.bold { + textNode.textView.textStorage.addAttribute(key, value: value, range: range) + fontAttributes.insert(.bold) + } else if key == ChatTextInputAttributes.italic { + textNode.textView.textStorage.addAttribute(key, value: value, range: range) + fontAttributes.insert(.italic) + } else if key == ChatTextInputAttributes.monospace { + textNode.textView.textStorage.addAttribute(key, value: value, range: range) + fontAttributes.insert(.monospace) + } else if key == ChatTextInputAttributes.strikethrough { + textNode.textView.textStorage.addAttribute(key, value: value, range: range) + textNode.textView.textStorage.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue as NSNumber, range: range) + } else if key == ChatTextInputAttributes.underline { + textNode.textView.textStorage.addAttribute(key, value: value, range: range) + textNode.textView.textStorage.addAttribute(NSAttributedString.Key.underlineStyle, value: NSUnderlineStyle.single.rawValue as NSNumber, range: range) + } + } + + if !fontAttributes.isEmpty { + var font: UIFont? + if fontAttributes == [.bold, .italic, .monospace] { + font = Font.semiboldItalicMonospace(baseFontSize) + } else if fontAttributes == [.bold, .italic] { + font = Font.semiboldItalic(baseFontSize) + } else if fontAttributes == [.bold, .monospace] { + font = Font.semiboldMonospace(baseFontSize) + } else if fontAttributes == [.italic, .monospace] { + font = Font.italicMonospace(baseFontSize) + } else if fontAttributes == [.bold] { + font = Font.semibold(baseFontSize) + } else if fontAttributes == [.italic] { + font = Font.italic(baseFontSize) + } else if fontAttributes == [.monospace] { + font = Font.monospace(baseFontSize) + } + + if let font = font { + textNode.textView.textStorage.addAttribute(NSAttributedString.Key.font, value: font, range: range) + } + } + }) + } +} + +public func refreshGenericTextInputAttributes(_ textNode: ASEditableTextNode, theme: PresentationTheme, baseFontSize: CGFloat) { + guard let initialAttributedText = textNode.attributedText, initialAttributedText.length != 0 else { + return + } + + var writingDirection: NSWritingDirection? + if let style = initialAttributedText.attribute(NSAttributedString.Key.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle { + writingDirection = style.baseWritingDirection + } + + var text: NSString = initialAttributedText.string as NSString + var fullRange = NSRange(location: 0, length: initialAttributedText.length) + var attributedText = NSMutableAttributedString(attributedString: stateAttributedStringForText(initialAttributedText)) + var resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection) + + text = resultAttributedText.string as NSString + fullRange = NSRange(location: 0, length: initialAttributedText.length) + attributedText = NSMutableAttributedString(attributedString: stateAttributedStringForText(resultAttributedText)) + refreshTextUrls(text: text, initialAttributedText: resultAttributedText, attributedText: attributedText, fullRange: fullRange) + + resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection) if !resultAttributedText.isEqual(to: initialAttributedText) { textNode.textView.textStorage.removeAttribute(NSAttributedString.Key.font, range: fullRange) @@ -489,6 +587,9 @@ public func refreshChatTextInputTypingAttributes(_ textNode: ASEditableTextNode, NSAttributedString.Key.font: Font.regular(baseFontSize), NSAttributedString.Key.foregroundColor: theme.chat.inputPanel.primaryTextColor ] + let style = NSMutableParagraphStyle() + style.baseWritingDirection = .natural + filteredAttributes[NSAttributedString.Key.paragraphStyle] = style if let attributedText = textNode.attributedText, attributedText.length != 0 { let attributes = attributedText.attributes(at: max(0, min(textNode.selectedRange.location - 1, attributedText.length - 1)), effectiveRange: nil) for (key, value) in attributes { diff --git a/submodules/TooltipUI/Sources/TooltipScreen.swift b/submodules/TooltipUI/Sources/TooltipScreen.swift index 159b59f9dc..0dd75f3fa7 100644 --- a/submodules/TooltipUI/Sources/TooltipScreen.swift +++ b/submodules/TooltipUI/Sources/TooltipScreen.swift @@ -8,6 +8,20 @@ import AppBundle import SyncCore import TelegramCore import TextFormat +import Postbox + +public enum TooltipActiveTextItem { + case url(String) + case mention(PeerId, String) + case textMention(String) + case botCommand(String) + case hashtag(String) +} + +public enum TooltipActiveTextAction { + case tap + case longTap +} private final class TooltipScreenNode: ViewControllerTracingNode { private let icon: TooltipScreen.Icon? @@ -27,7 +41,7 @@ private final class TooltipScreenNode: ViewControllerTracingNode { private var validLayout: ContainerViewLayout? - init(text: String, textEntities: [MessageTextEntity], icon: TooltipScreen.Icon?, location: CGRect, shouldDismissOnTouch: @escaping (CGPoint) -> TooltipScreen.DismissOnTouch, requestDismiss: @escaping () -> Void, openUrl: @escaping (String) -> Void) { + init(text: String, textEntities: [MessageTextEntity], icon: TooltipScreen.Icon?, location: CGRect, shouldDismissOnTouch: @escaping (CGPoint) -> TooltipScreen.DismissOnTouch, requestDismiss: @escaping () -> Void, openActiveTextItem: @escaping (TooltipActiveTextItem, TooltipActiveTextAction) -> Void) { self.icon = icon self.location = location self.shouldDismissOnTouch = shouldDismissOnTouch @@ -93,8 +107,7 @@ private final class TooltipScreenNode: ViewControllerTracingNode { TelegramTextAttributes.PeerMention, TelegramTextAttributes.PeerTextMention, TelegramTextAttributes.BotCommand, - TelegramTextAttributes.Hashtag, - TelegramTextAttributes.Timecode + TelegramTextAttributes.Hashtag ] for attribute in highlightedAttributes { @@ -105,11 +118,36 @@ private final class TooltipScreenNode: ViewControllerTracingNode { return nil } self.textNode.tapAttributeAction = { [weak self] attributes in - guard let strongSelf = self else { + guard let _ = self else { return } if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { - openUrl(url) + openActiveTextItem(.url(url), .tap) + } else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention { + openActiveTextItem(.mention(mention.peerId, mention.mention), .tap) + } else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { + openActiveTextItem(.textMention(mention), .tap) + } else if let command = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String { + openActiveTextItem(.botCommand(command), .tap) + } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? String { + openActiveTextItem(.hashtag(hashtag), .tap) + } + } + + self.textNode.longTapAttributeAction = { [weak self] attributes in + guard let _ = self else { + return + } + if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { + openActiveTextItem(.url(url), .longTap) + } else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention { + openActiveTextItem(.mention(mention.peerId, mention.mention), .longTap) + } else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { + openActiveTextItem(.textMention(mention), .longTap) + } else if let command = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String { + openActiveTextItem(.botCommand(command), .longTap) + } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? String { + openActiveTextItem(.hashtag(hashtag), .longTap) } } } @@ -119,7 +157,7 @@ private final class TooltipScreenNode: ViewControllerTracingNode { self.scrollingContainer.frame = CGRect(origin: CGPoint(), size: layout.size) - let sideInset: CGFloat = 13.0 + let sideInset: CGFloat = 13.0 + layout.safeInsets.left let bottomInset: CGFloat = 10.0 let contentInset: CGFloat = 9.0 let contentVerticalInset: CGFloat = 11.0 @@ -142,7 +180,9 @@ private final class TooltipScreenNode: ViewControllerTracingNode { animationSpacing = 8.0 } - let textSize = self.textNode.updateLayout(CGSize(width: layout.size.width - contentInset * 2.0 - sideInset * 2.0 - animationSize.width - animationSpacing, height: .greatestFiniteMagnitude)) + let containerWidth = max(100.0, min(layout.size.width, 614.0) - (sideInset + layout.safeInsets.left) * 2.0) + + let textSize = self.textNode.updateLayout(CGSize(width: containerWidth - contentInset * 2.0 - animationSize.width - animationSpacing, height: .greatestFiniteMagnitude)) let backgroundWidth = textSize.width + contentInset * 2.0 + animationSize.width + animationSpacing let backgroundHeight = max(animationSize.height, textSize.height) + contentVerticalInset * 2.0 @@ -273,7 +313,7 @@ public final class TooltipScreen: ViewController { private let icon: TooltipScreen.Icon? private let location: CGRect private let shouldDismissOnTouch: (CGPoint) -> TooltipScreen.DismissOnTouch - private let openUrl: (String) -> Void + private let openActiveTextItem: (TooltipActiveTextItem, TooltipActiveTextAction) -> Void private var controllerNode: TooltipScreenNode { return self.displayNode as! TooltipScreenNode @@ -284,13 +324,13 @@ public final class TooltipScreen: ViewController { public var becameDismissed: ((TooltipScreen) -> Void)? - public init(text: String, textEntities: [MessageTextEntity] = [], icon: TooltipScreen.Icon?, location: CGRect, shouldDismissOnTouch: @escaping (CGPoint) -> TooltipScreen.DismissOnTouch, openUrl: @escaping (String) -> Void = { _ in }) { + public init(text: String, textEntities: [MessageTextEntity] = [], icon: TooltipScreen.Icon?, location: CGRect, shouldDismissOnTouch: @escaping (CGPoint) -> TooltipScreen.DismissOnTouch, openActiveTextItem: @escaping (TooltipActiveTextItem, TooltipActiveTextAction) -> Void = { _, _ in }) { self.text = text self.textEntities = textEntities self.icon = icon self.location = location self.shouldDismissOnTouch = shouldDismissOnTouch - self.openUrl = openUrl + self.openActiveTextItem = openActiveTextItem super.init(navigationBarPresentationData: nil) @@ -317,7 +357,7 @@ public final class TooltipScreen: ViewController { return } strongSelf.dismiss() - }, openUrl: self.openUrl) + }, openActiveTextItem: self.openActiveTextItem) self.displayNodeDidLoad() }