diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index c7df317a73..e0856e484f 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -469,7 +469,7 @@ public enum ChatPresentationInputQueryResult: Equatable { case hashtags([String]) case mentions([EnginePeer]) case commands([PeerCommand]) - case emojis([(String, String)], NSRange) + case emojis([(String, TelegramMediaFile?, String)], NSRange) case contextRequestResult(EnginePeer?, ChatContextResultCollection?) public static func ==(lhs: ChatPresentationInputQueryResult, rhs: ChatPresentationInputQueryResult) -> Bool { @@ -513,7 +513,13 @@ public enum ChatPresentationInputQueryResult: Equatable { return false } for i in 0 ..< lhsValue.count { - if lhsValue[i] != rhsValue[i] { + if lhsValue[i].0 != rhsValue[i].0 { + return false + } + if lhsValue[i].1?.fileId != rhsValue[i].1?.fileId { + return false + } + if lhsValue[i].2 != rhsValue[i].2 { return false } } diff --git a/submodules/AttachmentTextInputPanelNode/BUILD b/submodules/AttachmentTextInputPanelNode/BUILD index 0c9fa47d61..f82aeb9a52 100644 --- a/submodules/AttachmentTextInputPanelNode/BUILD +++ b/submodules/AttachmentTextInputPanelNode/BUILD @@ -29,6 +29,11 @@ swift_library( "//submodules/Pasteboard:Pasteboard", "//submodules/ContextUI:ContextUI", "//submodules/TelegramUI/Components/EmojiTextAttachmentView:EmojiTextAttachmentView", + "//submodules/ComponentFlow:ComponentFlow", + "//submodules/Components/LottieAnimationComponent:LottieAnimationComponent", + "//submodules/TelegramUI/Components/AnimationCache:AnimationCache", + "//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer", + "//submodules/TelegramUI/Components/TextNodeWithEntities:TextNodeWithEntities", ], visibility = [ "//visibility:public", diff --git a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift index 55d5d295a4..9d4a7ec421 100644 --- a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift +++ b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift @@ -19,6 +19,11 @@ import TextInputMenu import ChatPresentationInterfaceState import Pasteboard import EmojiTextAttachmentView +import ComponentFlow +import LottieAnimationComponent +import AnimationCache +import MultiAnimationRenderer +import TextNodeWithEntities private let counterFont = Font.with(size: 14.0, design: .regular, traits: [.monospacedNumbers]) private let minInputFontSize: CGFloat = 5.0 @@ -64,7 +69,7 @@ private func calculateTextFieldRealInsets(_ presentationInterfaceState: ChatPres top = 0.0 bottom = 0.0 } - return UIEdgeInsets(top: 4.5 + top, left: 0.0, bottom: 5.5 + bottom, right: 0.0) + return UIEdgeInsets(top: 4.5 + top, left: 0.0, bottom: 5.5 + bottom, right: 32.0) } private var currentTextInputBackgroundImage: (UIColor, UIColor, CGFloat, UIImage)? @@ -118,18 +123,91 @@ private class CaptionEditableTextNode: EditableTextNode { } } +public protocol AttachmentTextInputPanelInputView: UIView { + var insertText: ((NSAttributedString) -> Void)? { get set } + var deleteBackwards: (() -> Void)? { get set } + var switchToKeyboard: (() -> Void)? { get set } + var presentController: ((ViewController) -> Void)? { get set } +} + +final class CustomEmojiContainerView: UIView { + private let emojiViewProvider: (ChatTextInputTextCustomEmojiAttribute) -> UIView? + + private var emojiLayers: [InlineStickerItemLayer.Key: UIView] = [:] + + init(emojiViewProvider: @escaping (ChatTextInputTextCustomEmojiAttribute) -> UIView?) { + self.emojiViewProvider = emojiViewProvider + + super.init(frame: CGRect()) + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + func update(emojiRects: [(CGRect, ChatTextInputTextCustomEmojiAttribute)]) { + var nextIndexById: [Int64: Int] = [:] + + var validKeys = Set() + for (rect, emoji) in emojiRects { + let index: Int + if let nextIndex = nextIndexById[emoji.fileId] { + index = nextIndex + } else { + index = 0 + } + nextIndexById[emoji.fileId] = index + 1 + + let key = InlineStickerItemLayer.Key(id: emoji.fileId, index: index) + + let view: UIView + if let current = self.emojiLayers[key] { + view = current + } else if let newView = self.emojiViewProvider(emoji) { + view = newView + self.addSubview(newView) + self.emojiLayers[key] = view + } else { + continue + } + + let size = CGSize(width: 24.0, height: 24.0) + + view.frame = CGRect(origin: CGPoint(x: floor(rect.midX - size.width / 2.0), y: floor(rect.midY - size.height / 2.0)), size: size) + + validKeys.insert(key) + } + + var removeKeys: [InlineStickerItemLayer.Key] = [] + for (key, view) in self.emojiLayers { + if !validKeys.contains(key) { + removeKeys.append(key) + view.removeFromSuperview() + } + } + for key in removeKeys { + self.emojiLayers.removeValue(forKey: key) + } + } +} + public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, ASEditableTextNodeDelegate { private let context: AccountContext private let isCaption: Bool private let isAttachment: Bool + private let presentController: (ViewController) -> Void + private let makeEntityInputView: () -> AttachmentTextInputPanelInputView? + private var textPlaceholderNode: ImmediateTextNode private let textInputContainerBackgroundNode: ASImageNode private let textInputContainer: ASDisplayNode public var textInputNode: EditableTextNode? private var dustNode: InvisibleInkDustNode? - private var oneLineNode: ImmediateTextNode + private var customEmojiContainerView: CustomEmojiContainerView? + private var oneLineNode: TextNodeWithEntities + private var oneLineNodeAttributedText: NSAttributedString? private var oneLineDustNode: InvisibleInkDustNode? let textInputBackgroundNode: ASDisplayNode @@ -137,6 +215,8 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS private var transparentTextInputBackgroundImage: UIImage? private let actionButtons: AttachmentTextInputActionButtonsNode private let counterTextNode: ImmediateTextNode + + private let inputModeView: ComponentHostView private var validLayout: (CGFloat, CGFloat, CGFloat, UIEdgeInsets, CGFloat, LayoutMetrics, Bool)? @@ -243,14 +323,23 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS private var spoilersRevealed = false private var emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)? + private let animationCache: AnimationCache + private let animationRenderer: MultiAnimationRenderer private var maxCaptionLength: Int32? - public init(context: AccountContext, presentationInterfaceState: ChatPresentationInterfaceState, isCaption: Bool = false, isAttachment: Bool = false, presentController: @escaping (ViewController) -> Void) { + public init(context: AccountContext, presentationInterfaceState: ChatPresentationInterfaceState, isCaption: Bool = false, isAttachment: Bool = false, presentController: @escaping (ViewController) -> Void, makeEntityInputView: @escaping () -> AttachmentTextInputPanelInputView?) { self.context = context self.presentationInterfaceState = presentationInterfaceState self.isCaption = isCaption self.isAttachment = isAttachment + self.presentController = presentController + self.makeEntityInputView = makeEntityInputView + + self.animationCache = AnimationCacheImpl(basePath: context.account.postbox.mediaBox.basePath + "/animation-cache", allocateTempFile: { + return TempBox.shared.tempFile(fileName: "file").path + }) + self.animationRenderer = MultiAnimationRendererImpl() var hasSpoilers = true if presentationInterfaceState.chatLocation.peerId?.namespace == Namespaces.Peer.SecretChat { @@ -266,6 +355,9 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS if !isCaption { self.textInputContainer.addSubnode(self.textInputContainerBackgroundNode) } + + self.inputModeView = ComponentHostView() + self.textInputContainer.view.addSubview(self.inputModeView) self.textInputContainer.clipsToBounds = true self.textInputBackgroundNode = ASDisplayNode() @@ -276,9 +368,8 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS self.textPlaceholderNode.maximumNumberOfLines = 1 self.textPlaceholderNode.isUserInteractionEnabled = false - self.oneLineNode = ImmediateTextNode() - self.oneLineNode.maximumNumberOfLines = 1 - self.oneLineNode.isUserInteractionEnabled = false + self.oneLineNode = TextNodeWithEntities() + self.oneLineNode.textNode.isUserInteractionEnabled = false self.actionButtons = AttachmentTextInputActionButtonsNode(presentationInterfaceState: presentationInterfaceState, presentController: presentController) self.counterTextNode = ImmediateTextNode() @@ -304,7 +395,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS self.addSubnode(self.counterTextNode) if isCaption { - self.addSubnode(self.oneLineNode) + self.addSubnode(self.oneLineNode.textNode) } self.textInputBackgroundImageNode.clipsToBounds = true @@ -316,13 +407,13 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } self.textInputBackgroundNode.view.addGestureRecognizer(recognizer) - /*self.emojiViewProvider = { [weak self] emoji in - guard let strongSelf = self, let file = strongSelf.context.animatedEmojiStickers[emoji]?.first?.file else { + self.emojiViewProvider = { [weak self] emoji in + guard let strongSelf = self, let presentationInterfaceState = strongSelf.presentationInterfaceState else { return UIView() } - return EmojiTextAttachmentView(context: context, file: file) - }*/ + return EmojiTextAttachmentView(context: context, emoji: emoji, file: emoji.file, cache: strongSelf.animationCache, renderer: strongSelf.animationRenderer, placeholderColor: presentationInterfaceState.theme.chat.inputPanel.inputTextColor.withAlphaComponent(0.12), pointSize: CGSize(width: 24.0, height: 24.0)) + } self.updateSendButtonEnabled(isCaption || isAttachment, animated: false) @@ -496,6 +587,14 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS return minimalHeight } + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if !self.inputModeView.isHidden, let result = self.inputModeView.hitTest(self.view.convert(point, to: self.inputModeView), with: event) { + return result + } + + return super.hitTest(point, with: event) + } + public func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, additionalSideInsets: UIEdgeInsets, maxHeight: CGFloat, isSecondary: Bool, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics, isMediaInputExpanded: Bool) -> CGFloat { let hadLayout = self.validLayout != nil let previousAdditionalSideInsets = self.validLayout?.3 @@ -656,7 +755,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS if self.isCaption { if self.isFocused { - self.oneLineNode.alpha = 0.0 + self.oneLineNode.textNode.alpha = 0.0 self.oneLineDustNode?.alpha = 0.0 self.textInputNode?.alpha = 1.0 @@ -666,7 +765,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } else { panelHeight = minimalHeight - transition.updateAlpha(node: self.oneLineNode, alpha: inputHasText ? 1.0 : 0.0) + transition.updateAlpha(node: self.oneLineNode.textNode, alpha: inputHasText ? 1.0 : 0.0) if let oneLineDustNode = self.oneLineDustNode { transition.updateAlpha(node: oneLineDustNode, alpha: inputHasText ? 1.0 : 0.0) } @@ -679,9 +778,34 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS transition.updateAlpha(node: self.textInputBackgroundImageNode, alpha: inputHasText ? 1.0 : 0.0) } - let oneLineSize = self.oneLineNode.updateLayout(CGSize(width: baseWidth - textFieldInsets.left - textFieldInsets.right, height: CGFloat.greatestFiniteMagnitude)) - let oneLineFrame = CGRect(origin: CGPoint(x: leftInset + textFieldInsets.left + self.textInputViewInternalInsets.left, y: textFieldInsets.top + self.textInputViewInternalInsets.top + textInputViewRealInsets.top + UIScreenPixel), size: oneLineSize) - self.oneLineNode.frame = oneLineFrame + let makeOneLineLayout = TextNodeWithEntities.asyncLayout(self.oneLineNode) + let (oneLineLayout, oneLineApply) = makeOneLineLayout(TextNodeLayoutArguments( + attributedString: self.oneLineNodeAttributedText, + backgroundColor: nil, + minimumNumberOfLines: 1, + maximumNumberOfLines: 1, + truncationType: .end, + constrainedSize: CGSize(width: baseWidth - textFieldInsets.left - textFieldInsets.right, height: CGFloat.greatestFiniteMagnitude), + alignment: .left, + verticalAlignment: .top, + lineSpacing: 0.0, + cutout: nil, insets: UIEdgeInsets(), + lineColor: nil, + textShadowColor: nil, + textStroke: nil, + displaySpoilers: false, + displayEmbeddedItemsUnderSpoilers: false + )) + + let oneLineFrame = CGRect(origin: CGPoint(x: leftInset + textFieldInsets.left + self.textInputViewInternalInsets.left, y: textFieldInsets.top + self.textInputViewInternalInsets.top + textInputViewRealInsets.top + UIScreenPixel), size: oneLineLayout.size) + self.oneLineNode.textNode.frame = oneLineFrame + let _ = oneLineApply(TextNodeWithEntities.Arguments( + context: self.context, + cache: self.animationCache, + renderer: self.animationRenderer, + placeholderColor: self.presentationInterfaceState?.theme.chat.inputPanel.inputTextColor.withAlphaComponent(0.12) ?? .lightGray, + attemptSynchronous: false + )) self.updateOneLineSpoiler() } @@ -693,6 +817,9 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS if let textInputNode = self.textInputNode { let textFieldFrame = CGRect(origin: CGPoint(x: self.textInputViewInternalInsets.left, y: self.textInputViewInternalInsets.top), size: CGSize(width: textInputFrame.size.width - (self.textInputViewInternalInsets.left + self.textInputViewInternalInsets.right), height: textInputFrame.size.height - self.textInputViewInternalInsets.top - textInputViewInternalInsets.bottom)) let shouldUpdateLayout = textFieldFrame.size != textInputNode.frame.size + if let presentationInterfaceState = self.presentationInterfaceState { + textInputNode.textContainerInset = calculateTextFieldRealInsets(presentationInterfaceState) + } transition.updateFrame(node: textInputNode, frame: textFieldFrame) if shouldUpdateLayout { textInputNode.layout() @@ -756,6 +883,37 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS var textInputViewRealInsets = UIEdgeInsets() if let presentationInterfaceState = self.presentationInterfaceState { textInputViewRealInsets = calculateTextFieldRealInsets(presentationInterfaceState) + + var colors: [String: UIColor] = [:] + let colorKeys: [String] = [ + "Ellipse 33.Ellipse 33.Stroke 1", + "Ellipse 34.Ellipse 34.Stroke 1", + "Oval.Oval.Fill 1", + "Oval 2.Oval.Fill 1", + "Path 85.Path 85.Stroke 1" + ] + for colorKey in colorKeys { + colors[colorKey] = presentationInterfaceState.theme.chat.inputPanel.inputControlColor + } + let animationComponent = LottieAnimationComponent( + animation: LottieAnimationComponent.AnimationItem( + name: "anim_smiletosticker", + colors: colors, + mode: .animateTransitionFromPrevious + ), + size: CGSize(width: 32.0, height: 32.0) + ) + let inputNodeSize = self.inputModeView.update( + transition: .immediate, + component: AnyComponent(Button( + content: AnyComponent(animationComponent), + action: { [weak self] in + self?.toggleInputMode() + })), + environment: {}, + containerSize: CGSize(width: 32.0, height: 32.0) + ) + transition.updateFrame(view: self.inputModeView, frame: CGRect(origin: CGPoint(x: textInputBackgroundFrame.maxX - inputNodeSize.width - 1.0, y: textInputBackgroundFrame.maxY - inputNodeSize.height - 1.0), size: inputNodeSize)) } let placeholderFrame: CGRect @@ -804,6 +962,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS let textColor = presentationInterfaceState.theme.chat.inputPanel.inputTextColor var rects: [CGRect] = [] + var customEmojiRects: [(CGRect, ChatTextInputTextCustomEmojiAttribute)] = [] if let attributedText = textInputNode.attributedText { let beginning = textInputNode.textView.beginningOfDocument @@ -841,6 +1000,16 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS addSpoiler(startIndex: currentStartIndex, endIndex: endIndex) } } + + if let value = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute { + if let start = textInputNode.textView.position(from: beginning, offset: range.location), let end = textInputNode.textView.position(from: start, offset: range.length), let textRange = textInputNode.textView.textRange(from: start, to: end) { + let textRects = textInputNode.textView.selectionRects(for: textRange) + for textRect in textRects { + customEmojiRects.append((textRect.rect, value)) + break + } + } + } }) } @@ -861,6 +1030,28 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS dustNode.removeFromSupernode() self.dustNode = nil } + + if !customEmojiRects.isEmpty { + let customEmojiContainerView: CustomEmojiContainerView + if let current = self.customEmojiContainerView { + customEmojiContainerView = current + } else { + customEmojiContainerView = CustomEmojiContainerView(emojiViewProvider: { [weak self] emoji in + guard let strongSelf = self, let emojiViewProvider = strongSelf.emojiViewProvider else { + return nil + } + return emojiViewProvider(emoji) + }) + customEmojiContainerView.isUserInteractionEnabled = false + textInputNode.textView.addSubview(customEmojiContainerView) + self.customEmojiContainerView = customEmojiContainerView + } + + customEmojiContainerView.update(emojiRects: customEmojiRects) + } else if let customEmojiContainerView = self.customEmojiContainerView { + customEmojiContainerView.removeFromSuperview() + self.customEmojiContainerView = nil + } } private func updateSpoilersRevealed(animated: Bool = true) { @@ -984,6 +1175,71 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } } + private func toggleInputMode() { + self.loadTextInputNodeIfNeeded() + + guard let textInputNode = self.textInputNode else { + return + } + + var shouldHaveInputView = false + if textInputNode.textView.isFirstResponder { + if textInputNode.textView.inputView == nil { + shouldHaveInputView = true + } + } else { + shouldHaveInputView = true + } + + if shouldHaveInputView { + let inputView = self.makeEntityInputView() + inputView?.insertText = { [weak self] text in + guard let strongSelf = self else { + return + } + + strongSelf.interfaceInteraction?.updateTextInputStateAndMode { textInputState, inputMode in + let inputText = NSMutableAttributedString(attributedString: textInputState.inputText) + + let range = textInputState.selectionRange + inputText.replaceCharacters(in: NSMakeRange(range.lowerBound, range.count), with: text) + + let selectionPosition = range.lowerBound + (text.string as NSString).length + + return (ChatTextInputState(inputText: inputText, selectionRange: selectionPosition ..< selectionPosition), inputMode) + } + } + inputView?.deleteBackwards = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.textInputNode?.textView.deleteBackward() + } + inputView?.switchToKeyboard = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.toggleInputMode() + } + inputView?.presentController = { [weak self] c in + guard let strongSelf = self else { + return + } + strongSelf.presentController(c) + } + + textInputNode.textView.inputView = inputView + } else { + textInputNode.textView.inputView = nil + } + + if textInputNode.textView.isFirstResponder { + textInputNode.textView.reloadInputViews() + } else { + textInputNode.textView.becomeFirstResponder() + } + } + private func updateTextNodeText(animated: Bool) { var inputHasText = false if let textInputNode = self.textInputNode, let attributedText = textInputNode.attributedText, attributedText.length != 0 { @@ -1005,12 +1261,12 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS let trimmedText = NSMutableAttributedString(attributedString: attributedText.attributedSubstring(from: NSMakeRange(0, range.location))) trimmedText.append(NSAttributedString(string: "\u{2026}", font: textFont, textColor: textColor)) - self.oneLineNode.attributedText = trimmedText + self.oneLineNodeAttributedText = trimmedText } else { - self.oneLineNode.attributedText = attributedText + self.oneLineNodeAttributedText = attributedText } } else { - self.oneLineNode.attributedText = nil + self.oneLineNodeAttributedText = nil } let panelHeight = self.updateTextHeight(animated: animated) @@ -1020,15 +1276,15 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } private func updateOneLineSpoiler() { - if let textLayout = self.oneLineNode.cachedLayout, !textLayout.spoilers.isEmpty { + if let textLayout = self.oneLineNode.textNode.cachedLayout, !textLayout.spoilers.isEmpty { if self.oneLineDustNode == nil { let oneLineDustNode = InvisibleInkDustNode(textNode: nil) self.oneLineDustNode = oneLineDustNode - self.oneLineNode.supernode?.insertSubnode(oneLineDustNode, aboveSubnode: self.oneLineNode) + self.oneLineNode.textNode.supernode?.insertSubnode(oneLineDustNode, aboveSubnode: self.oneLineNode.textNode) } if let oneLineDustNode = self.oneLineDustNode { - let textFrame = self.oneLineNode.frame.insetBy(dx: 0.0, dy: -3.0) + let textFrame = self.oneLineNode.textNode.frame.insetBy(dx: 0.0, dy: -3.0) oneLineDustNode.update(size: textFrame.size, color: .white, textColor: .white, rects: textLayout.spoilers.map { $0.1.offsetBy(dx: 0.0, dy: 3.0) }, wordRects: textLayout.spoilerWords.map { $0.1.offsetBy(dx: 0.0, dy: 3.0) }) oneLineDustNode.frame = textFrame diff --git a/submodules/AttachmentUI/Sources/AttachmentController.swift b/submodules/AttachmentUI/Sources/AttachmentController.swift index e8bfdf2ad8..685c651b28 100644 --- a/submodules/AttachmentUI/Sources/AttachmentController.swift +++ b/submodules/AttachmentUI/Sources/AttachmentController.swift @@ -11,6 +11,7 @@ import AccountContext import TelegramStringFormatting import UIKitRuntimeUtils import MediaResources +import AttachmentTextInputPanelNode public enum AttachmentButtonType: Equatable { case gallery @@ -167,6 +168,7 @@ public class AttachmentController: ViewController { private let buttons: [AttachmentButtonType] private let initialButton: AttachmentButtonType private let fromMenu: Bool + private let makeEntityInputView: () -> AttachmentTextInputPanelInputView? public var willDismiss: () -> Void = {} public var didDismiss: () -> Void = {} @@ -190,6 +192,7 @@ public class AttachmentController: ViewController { private let dim: ASDisplayNode private let shadowNode: ASImageNode private let container: AttachmentContainer + private let makeEntityInputView: () -> AttachmentTextInputPanelInputView? let panel: AttachmentPanel private var currentType: AttachmentButtonType? @@ -259,8 +262,9 @@ public class AttachmentController: ViewController { private let wrapperNode: ASDisplayNode - init(controller: AttachmentController) { + init(controller: AttachmentController, makeEntityInputView: @escaping () -> AttachmentTextInputPanelInputView?) { self.controller = controller + self.makeEntityInputView = makeEntityInputView self.dim = ASDisplayNode() self.dim.alpha = 0.0 @@ -274,7 +278,7 @@ public class AttachmentController: ViewController { self.container = AttachmentContainer() self.container.canHaveKeyboardFocus = true - self.panel = AttachmentPanel(context: controller.context, chatLocation: controller.chatLocation, updatedPresentationData: controller.updatedPresentationData) + self.panel = AttachmentPanel(context: controller.context, chatLocation: controller.chatLocation, updatedPresentationData: controller.updatedPresentationData, makeEntityInputView: makeEntityInputView) self.panel.fromMenu = controller.fromMenu self.panel.isStandalone = controller.isStandalone @@ -843,13 +847,14 @@ public class AttachmentController: ViewController { public var getInputContainerNode: () -> (CGFloat, ASDisplayNode, () -> AttachmentController.InputPanelTransition?)? = { return nil } - public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, chatLocation: ChatLocation, buttons: [AttachmentButtonType], initialButton: AttachmentButtonType = .gallery, fromMenu: Bool = false) { + public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, chatLocation: ChatLocation, buttons: [AttachmentButtonType], initialButton: AttachmentButtonType = .gallery, fromMenu: Bool = false, makeEntityInputView: @escaping () -> AttachmentTextInputPanelInputView?) { self.context = context self.updatedPresentationData = updatedPresentationData self.chatLocation = chatLocation self.buttons = buttons self.initialButton = initialButton self.fromMenu = fromMenu + self.makeEntityInputView = makeEntityInputView super.init(navigationBarPresentationData: nil) @@ -877,7 +882,7 @@ public class AttachmentController: ViewController { } open override func loadDisplayNode() { - self.displayNode = Node(controller: self) + self.displayNode = Node(controller: self, makeEntityInputView: self.makeEntityInputView) self.displayNodeDidLoad() } diff --git a/submodules/AttachmentUI/Sources/AttachmentPanel.swift b/submodules/AttachmentUI/Sources/AttachmentPanel.swift index 547c91d193..996242b0a7 100644 --- a/submodules/AttachmentUI/Sources/AttachmentPanel.swift +++ b/submodules/AttachmentUI/Sources/AttachmentPanel.swift @@ -458,6 +458,8 @@ final class AttachmentPanel: ASDisplayNode, UIScrollViewDelegate { private var presentationInterfaceState: ChatPresentationInterfaceState private var interfaceInteraction: ChatPanelInterfaceInteraction? + private let makeEntityInputView: () -> AttachmentTextInputPanelInputView? + private let containerNode: ASDisplayNode private let backgroundNode: NavigationBackgroundNode private let scrollNode: ASScrollNode @@ -496,9 +498,11 @@ final class AttachmentPanel: ASDisplayNode, UIScrollViewDelegate { var mainButtonPressed: () -> Void = { } - init(context: AccountContext, chatLocation: ChatLocation, updatedPresentationData: (initial: PresentationData, signal: Signal)?) { + init(context: AccountContext, chatLocation: ChatLocation, updatedPresentationData: (initial: PresentationData, signal: Signal)?, makeEntityInputView: @escaping () -> AttachmentTextInputPanelInputView?) { self.context = context self.presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } + + self.makeEntityInputView = makeEntityInputView self.presentationInterfaceState = ChatPresentationInterfaceState(chatWallpaper: .builtin(WallpaperSettings()), theme: self.presentationData.theme, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, nameDisplayOrder: self.presentationData.nameDisplayOrder, limitsConfiguration: self.context.currentLimitsConfiguration.with { $0 }, fontSize: self.presentationData.chatFontSize, bubbleCorners: self.presentationData.chatBubbleCorners, accountPeerId: self.context.account.peerId, mode: .standard(previewing: false), chatLocation: chatLocation, subject: nil, peerNearbyData: nil, greetingData: nil, pendingUnpinnedAllMessages: false, activeGroupCallInfo: nil, hasActiveGroupCall: false, importState: nil) @@ -895,7 +899,7 @@ final class AttachmentPanel: ASDisplayNode, UIScrollViewDelegate { if let strongSelf = self { strongSelf.present(c) } - }) + }, makeEntityInputView: self.makeEntityInputView) textInputPanelNode.interfaceInteraction = self.interfaceInteraction textInputPanelNode.sendMessage = { [weak self] mode in if let strongSelf = self { diff --git a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetControllerNode.swift b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetControllerNode.swift index e76b1d04a2..564db9cc8b 100644 --- a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetControllerNode.swift +++ b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetControllerNode.swift @@ -656,6 +656,7 @@ final class ChatSendMessageActionSheetControllerNode: ViewControllerTracingNode, var textFrame = self.textFieldFrame textFrame.origin = CGPoint(x: 13.0, y: 6.0 - UIScreenPixel) textFrame.size.height = self.textInputNode.textView.contentSize.height + textFrame.size.width -= self.textInputNode.textContainerInset.right if self.textInputNode.isRTL { textFrame.origin.x -= messageOriginDelta diff --git a/submodules/Components/PagerComponent/Sources/PagerComponent.swift b/submodules/Components/PagerComponent/Sources/PagerComponent.swift index aa06aec2fa..b3b0ce76ce 100644 --- a/submodules/Components/PagerComponent/Sources/PagerComponent.swift +++ b/submodules/Components/PagerComponent/Sources/PagerComponent.swift @@ -381,9 +381,10 @@ public final class PagerComponent TextNodeLayoutArguments { @@ -198,7 +201,8 @@ public final class TextNodeLayoutArguments { lineColor: self.lineColor, textShadowColor: self.textShadowColor, textStroke: self.textStroke, - displaySpoilers: self.displaySpoilers + displaySpoilers: self.displaySpoilers, + displayEmbeddedItemsUnderSpoilers: self.displayEmbeddedItemsUnderSpoilers ) } } @@ -972,7 +976,7 @@ open class TextNode: ASDisplayNode { } } - static func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool) -> TextNodeLayout { + static func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textStroke: (UIColor, CGFloat)?, displaySpoilers: Bool, displayEmbeddedItemsUnderSpoilers: Bool) -> TextNodeLayout { if let attributedString = attributedString { let stringLength = attributedString.length @@ -1126,10 +1130,6 @@ open class TextNode: ASDisplayNode { rightOffset = ceil(secondaryRightOffset) } - if embeddedItems.count > 25 { - assert(true) - } - embeddedItems.append(TextNodeEmbeddedItem(range: NSMakeRange(startIndex, endIndex - startIndex + 1), frame: CGRect(x: min(leftOffset, rightOffset), y: descent - (ascent + descent), width: abs(rightOffset - leftOffset) + rightInset, height: ascent + descent), item: item)) } @@ -1140,9 +1140,6 @@ open class TextNode: ASDisplayNode { isLastLine = true } if isLastLine { - if attributedString.string.hasPrefix("😀") { - assert(true) - } if first { first = false } else { @@ -1224,12 +1221,6 @@ open class TextNode: ASDisplayNode { } addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) - } else if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) { - var ascent: CGFloat = 0.0 - var descent: CGFloat = 0.0 - CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) - - addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) @@ -1238,6 +1229,16 @@ open class TextNode: ASDisplayNode { } else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle { headIndent = paragraphStyle.headIndent } + + if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) { + if displayEmbeddedItemsUnderSpoilers || (attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] == nil && attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] == nil) { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } + } } } @@ -1311,12 +1312,6 @@ open class TextNode: ASDisplayNode { } addSpoiler(line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) - } else if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) { - var ascent: CGFloat = 0.0 - var descent: CGFloat = 0.0 - CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) - - addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) } else if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) @@ -1325,6 +1320,16 @@ open class TextNode: ASDisplayNode { } else if let paragraphStyle = attributes[NSAttributedString.Key.paragraphStyle] as? NSParagraphStyle { headIndent = paragraphStyle.headIndent } + + if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) { + if displayEmbeddedItemsUnderSpoilers || (attributes[NSAttributedString.Key(rawValue: "TelegramSpoiler")] == nil && attributes[NSAttributedString.Key(rawValue: "Attribute__Spoiler")] == nil) { + var ascent: CGFloat = 0.0 + var descent: CGFloat = 0.0 + CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) + + addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) + } + } } let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine))) @@ -1586,11 +1591,11 @@ open class TextNode: ASDisplayNode { if stringMatch { layout = existingLayout } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers) updated = true } } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers) updated = true } @@ -2231,11 +2236,11 @@ open class TextView: UIView { if stringMatch { layout = existingLayout } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers) updated = true } } else { - layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers) + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textStroke: arguments.textStroke, displaySpoilers: arguments.displaySpoilers, displayEmbeddedItemsUnderSpoilers: arguments.displayEmbeddedItemsUnderSpoilers) updated = true } diff --git a/submodules/GalleryUI/BUILD b/submodules/GalleryUI/BUILD index c398c257d5..180d1e9a21 100644 --- a/submodules/GalleryUI/BUILD +++ b/submodules/GalleryUI/BUILD @@ -41,6 +41,9 @@ swift_library( "//submodules/InvisibleInkDustNode:InvisibleInkDustNode", "//submodules/TranslateUI:TranslateUI", "//submodules/Utils/RangeSet:RangeSet", + "//submodules/TelegramUI/Components/TextNodeWithEntities:TextNodeWithEntities", + "//submodules/TelegramUI/Components/AnimationCache:AnimationCache", + "//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer", ], visibility = [ "//visibility:public", diff --git a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift index bc432e08fd..8cb2ed0cf1 100644 --- a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift +++ b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift @@ -22,6 +22,9 @@ import UndoUI import ManagedAnimationNode import TelegramUniversalVideoContent import InvisibleInkDustNode +import TextNodeWithEntities +import AnimationCache +import MultiAnimationRenderer private let deleteImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/MessageSelectionTrash"), color: .white) private let actionImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/MessageSelectionForward"), color: .white) @@ -133,10 +136,13 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll private let scrollWrapperNode: CaptionScrollWrapperNode private let scrollNode: ASScrollNode - private let textNode: ImmediateTextNode - private var spoilerTextNode: ImmediateTextNode? + private let textNode: ImmediateTextNodeWithEntities + private var spoilerTextNode: ImmediateTextNodeWithEntities? private var dustNode: InvisibleInkDustNode? + private let animationCache: AnimationCache + private let animationRenderer: MultiAnimationRenderer + private let authorNameNode: ASTextNode private let dateNode: ASTextNode private let backwardButton: PlaybackButtonNode @@ -319,7 +325,7 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll self.maskNode = ASDisplayNode() - self.textNode = ImmediateTextNode() + self.textNode = ImmediateTextNodeWithEntities() self.textNode.maximumNumberOfLines = 0 self.textNode.linkHighlightColor = UIColor(rgb: 0x5ac8fa, alpha: 0.2) @@ -350,6 +356,11 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll self.statusNode = RadialStatusNode(backgroundNodeColor: .clear) self.statusNode.isUserInteractionEnabled = false + self.animationCache = AnimationCacheImpl(basePath: context.account.postbox.mediaBox.basePath + "/animation-cache", allocateTempFile: { + return TempBox.shared.tempFile(fileName: "file").path + }) + self.animationRenderer = MultiAnimationRendererImpl() + super.init() self.addSubnode(self.contentNode) @@ -380,6 +391,15 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll } } + self.textNode.arguments = TextNodeWithEntities.Arguments( + context: self.context, + cache: self.animationCache, + renderer: self.animationRenderer, + placeholderColor: defaultDarkPresentationTheme.list.mediaPlaceholderColor, + attemptSynchronous: false + ) + self.textNode.visibility = true + self.contentNode.view.addSubview(self.deleteButton) self.contentNode.view.addSubview(self.fullscreenButton) self.contentNode.view.addSubview(self.actionButton) @@ -723,7 +743,7 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll private func updateSpoilers(textFrame: CGRect) { if let textLayout = self.textNode.cachedLayout, !textLayout.spoilers.isEmpty { if self.spoilerTextNode == nil { - let spoilerTextNode = ImmediateTextNode() + let spoilerTextNode = ImmediateTextNodeWithEntities() spoilerTextNode.attributedText = textNode.attributedText spoilerTextNode.maximumNumberOfLines = 0 spoilerTextNode.linkHighlightColor = UIColor(rgb: 0x5ac8fa, alpha: 0.2) diff --git a/submodules/MtProtoKit/Sources/MTDatacenterAuthAction.m b/submodules/MtProtoKit/Sources/MTDatacenterAuthAction.m index fca53580cf..acf6af66c9 100644 --- a/submodules/MtProtoKit/Sources/MTDatacenterAuthAction.m +++ b/submodules/MtProtoKit/Sources/MTDatacenterAuthAction.m @@ -87,6 +87,8 @@ MTDatacenterAuthMessageService *authService = [[MTDatacenterAuthMessageService alloc] initWithContext:context tempAuth:tempAuth]; authService.delegate = self; [_authMtProto addMessageService:authService]; + + [_authMtProto resume]; } } else diff --git a/submodules/MtProtoKit/Sources/MTDatacenterTransferAuthAction.m b/submodules/MtProtoKit/Sources/MTDatacenterTransferAuthAction.m index cf1740f7af..54e2de78ad 100644 --- a/submodules/MtProtoKit/Sources/MTDatacenterTransferAuthAction.m +++ b/submodules/MtProtoKit/Sources/MTDatacenterTransferAuthAction.m @@ -91,6 +91,8 @@ requestService.forceBackgroundRequests = true; [_sourceDatacenterMtProto addMessageService:requestService]; + [_sourceDatacenterMtProto resume]; + MTRequest *request = [[MTRequest alloc] init]; NSData *exportAuthRequestData = nil; @@ -130,6 +132,8 @@ requestService.forceBackgroundRequests = true; [_destinationDatacenterMtProto addMessageService:requestService]; + [_destinationDatacenterMtProto resume]; + MTRequest *request = [[MTRequest alloc] init]; NSData *importAuthRequestData = [_context.serialization importAuthorization:dataId bytes:authData]; diff --git a/submodules/MtProtoKit/Sources/MTDiscoverDatacenterAddressAction.m b/submodules/MtProtoKit/Sources/MTDiscoverDatacenterAddressAction.m index 45b10c9bed..8cd1ed0a6f 100644 --- a/submodules/MtProtoKit/Sources/MTDiscoverDatacenterAddressAction.m +++ b/submodules/MtProtoKit/Sources/MTDiscoverDatacenterAddressAction.m @@ -96,6 +96,8 @@ _requestService.forceBackgroundRequests = true; [_mtProto addMessageService:_requestService]; + [_mtProto resume]; + MTRequest *request = [[MTRequest alloc] init]; NSData *getConfigData = nil; diff --git a/submodules/MtProtoKit/Sources/MTProto.m b/submodules/MtProtoKit/Sources/MTProto.m index dc89d6b73c..5d892618ae 100644 --- a/submodules/MtProtoKit/Sources/MTProto.m +++ b/submodules/MtProtoKit/Sources/MTProto.m @@ -171,9 +171,11 @@ static const NSUInteger MTMaxUnacknowledgedMessageCount = 64; _sessionInfo = [[MTSessionInfo alloc] initWithRandomSessionIdAndContext:_context]; - - _shouldStayConnected = true; + + _mtState |= MTProtoStatePaused; + + [self setMtState:_mtState | MTProtoStatePaused]; } return self; } diff --git a/submodules/Pasteboard/Sources/Pasteboard.swift b/submodules/Pasteboard/Sources/Pasteboard.swift index fa4f09165b..b25ab49f55 100644 --- a/submodules/Pasteboard/Sources/Pasteboard.swift +++ b/submodules/Pasteboard/Sources/Pasteboard.swift @@ -9,13 +9,14 @@ private func rtfStringWithAppliedEntities(_ text: String, entities: [MessageText let sourceString = stringWithAppliedEntities(text, entities: entities, baseColor: .black, linkColor: .black, baseFont: Font.regular(14.0), linkFont: Font.regular(14.0), boldFont: Font.semibold(14.0), italicFont: Font.italic(14.0), boldItalicFont: Font.semiboldItalic(14.0), fixedFont: Font.monospace(14.0), blockQuoteFont: Font.regular(14.0), underlineLinks: false, external: true, message: nil) let test = NSMutableAttributedString(attributedString: sourceString) - if #available(iOS 15.0, *) { - test.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: NSRange(location: 0, length: sourceString.length), using: { value, range, _ in - if let value = value as? ChatTextInputTextCustomEmojiAttribute { - test.addAttribute(NSAttributedString.Key.link, value: URL(string: "tg://emoji?\(value.fileId)")!, range: range) - } - }) - } + var index = 0 + test.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: NSRange(location: 0, length: sourceString.length), using: { value, range, _ in + if let value = value as? ChatTextInputTextCustomEmojiAttribute { + test.addAttribute(NSAttributedString.Key.link, value: URL(string: "tg://emoji?id=\(value.fileId)&t=\(index)")!, range: range) + index += 1 + } + }) + test.removeAttribute(ChatTextInputAttributes.customEmoji, range: NSRange(location: 0, length: test.length)) if let data = try? test.data(from: NSRange(location: 0, length: test.length), documentAttributes: [NSAttributedString.DocumentAttributeKey.documentType: NSAttributedString.DocumentType.rtf]) { if var rtf = String(data: data, encoding: .windowsCP1252) { @@ -71,10 +72,18 @@ public func chatInputStateStringFromRTF(_ data: Data, type: NSAttributedString.D if let attributedString = try? NSAttributedString(data: data, options: [NSAttributedString.DocumentReadingOptionKey.documentType: type], documentAttributes: nil) { let updatedString = NSMutableAttributedString(attributedString: attributedString) updatedString.enumerateAttribute(NSAttributedString.Key.link, in: NSRange(location: 0, length: attributedString.length), using: { value, range, _ in - if let url = value as? URL, url.scheme == "tg", url.host == "emoji", let query = url.query { - if let fileId = Int64(query) { + if let url = value as? URL, url.scheme == "tg", url.host == "emoji" { + var emojiId: Int64? + if let queryItems = URLComponents(string: url.absoluteString)?.queryItems { + for item in queryItems { + if item.name == "id" { + emojiId = item.value.flatMap(Int64.init) + } + } + } + if let emojiId = emojiId { updatedString.removeAttribute(NSAttributedString.Key.link, range: range) - updatedString.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(stickerPack: nil, fileId: fileId, file: nil), range: range) + updatedString.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(stickerPack: nil, fileId: emojiId, file: nil), range: range) } } }) diff --git a/submodules/PhotoResources/Sources/PhotoResources.swift b/submodules/PhotoResources/Sources/PhotoResources.swift index 9335c9ee04..78ea869c99 100644 --- a/submodules/PhotoResources/Sources/PhotoResources.swift +++ b/submodules/PhotoResources/Sources/PhotoResources.swift @@ -532,12 +532,25 @@ private func chatMessageVideoDatas(postbox: Postbox, fileReference: FileMediaRef return thumbnail |> mapToSignal { thumbnailData in - return combineLatest(fullSizeDataAndPath, reducedSizeDataAndPath) - |> map { fullSize, reducedSize in - if !fullSize._1 && reducedSize._1 { - return Tuple(thumbnailData, reducedSize._0, false) + if synchronousLoad, let thumbnailData = thumbnailData { + return .single(Tuple(thumbnailData, nil, false)) + |> then( + combineLatest(fullSizeDataAndPath, reducedSizeDataAndPath) + |> map { fullSize, reducedSize in + if !fullSize._1 && reducedSize._1 { + return Tuple(thumbnailData, reducedSize._0, false) + } + return Tuple(thumbnailData, fullSize._0, fullSize._1) + } + ) + } else { + return combineLatest(fullSizeDataAndPath, reducedSizeDataAndPath) + |> map { fullSize, reducedSize in + if !fullSize._1 && reducedSize._1 { + return Tuple(thumbnailData, reducedSize._0, false) + } + return Tuple(thumbnailData, fullSize._0, fullSize._1) } - return Tuple(thumbnailData, fullSize._0, fullSize._1) } } } diff --git a/submodules/Postbox/Sources/TimeBasedCleanup.swift b/submodules/Postbox/Sources/TimeBasedCleanup.swift index 2510fad261..685dbb6e09 100644 --- a/submodules/Postbox/Sources/TimeBasedCleanup.swift +++ b/submodules/Postbox/Sources/TimeBasedCleanup.swift @@ -13,7 +13,7 @@ private struct ScanFilesResult { var totalSize: UInt64 = 0 } -private func printOpenFiles() { +public func printOpenFiles() { var flags: Int32 = 0 var fd: Int32 = 0 var buf = Data(count: Int(MAXPATHLEN) + 1) diff --git a/submodules/SSignalKit/SwiftSignalKit/Source/QueueLocalObject.swift b/submodules/SSignalKit/SwiftSignalKit/Source/QueueLocalObject.swift index 9265f752ba..95d5f65081 100644 --- a/submodules/SSignalKit/SwiftSignalKit/Source/QueueLocalObject.swift +++ b/submodules/SSignalKit/SwiftSignalKit/Source/QueueLocalObject.swift @@ -20,6 +20,11 @@ public final class QueueLocalObject { } } + public func unsafeGet() -> T? { + assert(self.queue.isCurrent()) + return self.valueRef?.takeUnretainedValue() + } + public func with(_ f: @escaping (T) -> Void) { self.queue.async { if let valueRef = self.valueRef { diff --git a/submodules/StickerPackPreviewUI/Sources/StickerPackEmojisItem.swift b/submodules/StickerPackPreviewUI/Sources/StickerPackEmojisItem.swift index fa5c8827b4..19d4e7d17c 100644 --- a/submodules/StickerPackPreviewUI/Sources/StickerPackEmojisItem.swift +++ b/submodules/StickerPackPreviewUI/Sources/StickerPackEmojisItem.swift @@ -253,7 +253,6 @@ final class StickerPackEmojisItemNode: GridItemNode { itemLayer = EmojiPagerContentComponent.View.ItemLayer( item: EmojiPagerContentComponent.Item(file: item.file, staticEmoji: nil, subgroupId: nil), context: context, - groupId: "pack-\(Int(nativeItemSize))", attemptSynchronousLoad: attemptSynchronousLoads, file: item.file, staticEmoji: nil, @@ -263,7 +262,7 @@ final class StickerPackEmojisItemNode: GridItemNode { blurredBadgeColor: theme.chat.inputPanel.panelBackgroundColor.withMultipliedAlpha(0.5), displayPremiumBadgeIfAvailable: false, pointSize: itemNativeFitSize, - onUpdateDisplayPlaceholder: { [weak self] displayPlaceholder in + onUpdateDisplayPlaceholder: { [weak self] displayPlaceholder, _ in guard let strongSelf = self else { return } @@ -320,7 +319,7 @@ final class StickerPackEmojisItemNode: GridItemNode { } } else if updateItemLayerPlaceholder { if itemLayer.displayPlaceholder { - itemLayer.onUpdateDisplayPlaceholder(true) + itemLayer.onUpdateDisplayPlaceholder(true, 0.0) } } diff --git a/submodules/TabBarUI/Sources/TabBarController.swift b/submodules/TabBarUI/Sources/TabBarController.swift index c91d0e70b2..af41c83362 100644 --- a/submodules/TabBarUI/Sources/TabBarController.swift +++ b/submodules/TabBarUI/Sources/TabBarController.swift @@ -197,24 +197,23 @@ open class TabBarControllerImpl: ViewController, TabBarController { return } - if strongSelf.selectedIndex == index { - let timestamp = CACurrentMediaTime() - if strongSelf.debugTapCounter.0 < timestamp - 0.4 { - strongSelf.debugTapCounter.0 = timestamp - strongSelf.debugTapCounter.1 = 0 - } - - if strongSelf.debugTapCounter.0 >= timestamp - 0.4 { - strongSelf.debugTapCounter.0 = timestamp - strongSelf.debugTapCounter.1 += 1 - } - - if strongSelf.debugTapCounter.1 >= 10 { - strongSelf.debugTapCounter.1 = 0 - - strongSelf.controllers[index].tabBarItemDebugTapAction?() - } + let timestamp = CACurrentMediaTime() + if strongSelf.debugTapCounter.0 < timestamp - 0.4 { + strongSelf.debugTapCounter.0 = timestamp + strongSelf.debugTapCounter.1 = 0 } + + if strongSelf.debugTapCounter.0 >= timestamp - 0.4 { + strongSelf.debugTapCounter.0 = timestamp + strongSelf.debugTapCounter.1 += 1 + } + + if strongSelf.debugTapCounter.1 >= 10 { + strongSelf.debugTapCounter.1 = 0 + + strongSelf.controllers[index].tabBarItemDebugTapAction?() + } + if let validLayout = strongSelf.validLayout { var updatedLayout = validLayout diff --git a/submodules/TelegramCore/Sources/Network/MultipartFetch.swift b/submodules/TelegramCore/Sources/Network/MultipartFetch.swift index ebe9762f3f..52387c90db 100644 --- a/submodules/TelegramCore/Sources/Network/MultipartFetch.swift +++ b/submodules/TelegramCore/Sources/Network/MultipartFetch.swift @@ -591,6 +591,10 @@ private final class MultipartFetchManager { if totalTime > 0.0 { let speed = Double(totalByteCount) / totalTime Logger.shared.log("MultipartFetch", "\(self.resource.id.stringRepresentation) \(speed) bytes/s") + + #if DEBUG + self.checkState() + #endif } } } diff --git a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/PublicHeaders/ImageDCT/YuvConversion.h b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/PublicHeaders/ImageDCT/YuvConversion.h index a7ceddecb9..81a3ee6569 100644 --- a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/PublicHeaders/ImageDCT/YuvConversion.h +++ b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/PublicHeaders/ImageDCT/YuvConversion.h @@ -5,5 +5,6 @@ void splitRGBAIntoYUVAPlanes(uint8_t const *argb, uint8_t *outY, uint8_t *outU, uint8_t *outV, uint8_t *outA, int width, int height, int bytesPerRow); void combineYUVAPlanesIntoARBB(uint8_t *argb, uint8_t const *inY, uint8_t const *inU, uint8_t const *inV, uint8_t const *inA, int width, int height, int bytesPerRow); +void scaleImagePlane(uint8_t *outPlane, int outWidth, int outHeight, int outBytesPerRow, uint8_t const *inPlane, int inWidth, int inHeight, int inBytesPerRow); #endif /* YuvConversion_h */ diff --git a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/YuvConversion.m b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/YuvConversion.m index 41bd589754..0851a92c91 100644 --- a/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/YuvConversion.m +++ b/submodules/TelegramUI/Components/AnimationCache/ImageDCT/Sources/YuvConversion.m @@ -97,3 +97,19 @@ void combineYUVAPlanesIntoARBB(uint8_t *argb, uint8_t const *inY, uint8_t const error = vImageOverwriteChannels_ARGB8888(&srcA, &destArgb, &destArgb, 1 << 0, kvImageDoNotTile); } + +void scaleImagePlane(uint8_t *outPlane, int outWidth, int outHeight, int outBytesPerRow, uint8_t const *inPlane, int inWidth, int inHeight, int inBytesPerRow) { + vImage_Buffer src; + src.data = (void *)inPlane; + src.width = inWidth; + src.height = inHeight; + src.rowBytes = inBytesPerRow; + + vImage_Buffer dst; + dst.data = (void *)outPlane; + dst.width = outWidth; + dst.height = outHeight; + dst.rowBytes = outBytesPerRow; + + vImageScale_Planar8(&src, &dst, nil, kvImageDoNotTile); +} diff --git a/submodules/TelegramUI/Components/AnimationCache/Sources/AnimationCache.swift b/submodules/TelegramUI/Components/AnimationCache/Sources/AnimationCache.swift index f92e1ef1f4..09c9af7a1c 100644 --- a/submodules/TelegramUI/Components/AnimationCache/Sources/AnimationCache.swift +++ b/submodules/TelegramUI/Components/AnimationCache/Sources/AnimationCache.swift @@ -5,19 +5,42 @@ import CryptoUtils import ManagedFile import Compression +private func alignUp(size: Int, align: Int) -> Int { + precondition(((align - 1) & align) == 0, "Align must be a power of two") + + let alignmentMask = align - 1 + return (size + alignmentMask) & ~alignmentMask +} + public final class AnimationCacheItemFrame { - public enum Format { - case rgba(width: Int, height: Int, bytesPerRow: Int) + public enum RequestedFormat { + case rgba + case yuva(rowAlignment: Int) + } + + public final class Plane { + public let data: Data + public let width: Int + public let height: Int + public let bytesPerRow: Int + + public init(data: Data, width: Int, height: Int, bytesPerRow: Int) { + self.data = data + self.width = width + self.height = height + self.bytesPerRow = bytesPerRow + } + } + + public enum Format { + case rgba(data: Data, width: Int, height: Int, bytesPerRow: Int) + case yuva(y: Plane, u: Plane, v: Plane, a: Plane) } - public let data: Data - public let range: Range public let format: Format public let duration: Double - public init(data: Data, range: Range, format: Format, duration: Double) { - self.data = data - self.range = range + public init(format: Format, duration: Double) { self.format = format self.duration = duration } @@ -25,22 +48,28 @@ public final class AnimationCacheItemFrame { public final class AnimationCacheItem { public let numFrames: Int - private let getFrameImpl: (Int) -> AnimationCacheItemFrame? + private let getFrameImpl: (Int, AnimationCacheItemFrame.RequestedFormat) -> AnimationCacheItemFrame? private let getFrameIndexImpl: (Double) -> Int + private let getFrameDurationImpl: (Int) -> Double? - public init(numFrames: Int, getFrame: @escaping (Int) -> AnimationCacheItemFrame?, getFrameIndexImpl: @escaping (Double) -> Int) { + public init(numFrames: Int, getFrame: @escaping (Int, AnimationCacheItemFrame.RequestedFormat) -> AnimationCacheItemFrame?, getFrameIndexImpl: @escaping (Double) -> Int, getFrameDurationImpl: @escaping (Int) -> Double?) { self.numFrames = numFrames self.getFrameImpl = getFrame self.getFrameIndexImpl = getFrameIndexImpl + self.getFrameDurationImpl = getFrameDurationImpl } - public func getFrame(index: Int) -> AnimationCacheItemFrame? { - return self.getFrameImpl(index) + public func getFrameDuration(index: Int) -> Double? { + return self.getFrameDurationImpl(index) } - public func getFrame(at duration: Double) -> AnimationCacheItemFrame? { + public func getFrame(index: Int, requestedFormat: AnimationCacheItemFrame.RequestedFormat) -> AnimationCacheItemFrame? { + return self.getFrameImpl(index, requestedFormat) + } + + public func getFrame(at duration: Double, requestedFormat: AnimationCacheItemFrame.RequestedFormat) -> AnimationCacheItemFrame? { let index = self.getFrameIndexImpl(duration) - return self.getFrameImpl(index) + return self.getFrameImpl(index, requestedFormat) } } @@ -100,7 +129,7 @@ private func md5Hash(_ string: String) -> String { } } -private func itemSubpath(hashString: String) -> (directory: String, fileName: String) { +private func itemSubpath(hashString: String, width: Int, height: Int) -> (directory: String, fileName: String) { assert(hashString.count == 32) var directory = "" @@ -111,7 +140,7 @@ private func itemSubpath(hashString: String) -> (directory: String, fileName: St directory.append(String(hashString[hashString.index(hashString.startIndex, offsetBy: i * 2) ..< hashString.index(hashString.startIndex, offsetBy: (i + 1) * 2)])) } - return (directory, hashString) + return (directory, "\(hashString)_\(width)x\(height)") } private func roundUp(_ numToRound: Int, multiple: Int) -> Int { @@ -186,6 +215,213 @@ private func decompressData(data: Data, range: Range, decompressedSize: Int return decompressedFrameData } +private final class AnimationCacheItemWriterInternal { + struct CompressedResult { + var path: String + } + + private struct FrameMetadata { + var offset: Int + var length: Int + var duration: Double + } + + var isCancelled: Bool = false + + private let decompressedPath: String + private let compressedPath: String + private var file: ManagedFile? + + private var currentYUVASurface: ImageYUVA420? + private var currentDctData: DctData? + private var currentDctCoefficients: DctCoefficientsYUVA420? + private var contentLengthOffset: Int? + private var isFailed: Bool = false + private var isFinished: Bool = false + + private var frames: [FrameMetadata] = [] + private var contentLength: Int = 0 + + private let dctQuality: Int + + init?(allocateTempFile: @escaping () -> String) { + self.dctQuality = 70 + + self.decompressedPath = allocateTempFile() + self.compressedPath = allocateTempFile() + + guard let file = ManagedFile(queue: nil, path: self.decompressedPath, mode: .readwrite) else { + return nil + } + self.file = file + } + + func add(with drawingBlock: (ImageYUVA420) -> Void, proposedWidth: Int, proposedHeight: Int, duration: Double) { + if self.isFailed || self.isFinished { + return + } + + guard !self.isFailed, !self.isFinished, let file = self.file else { + return + } + + let width = roundUp(proposedWidth, multiple: 16) + let height = roundUp(proposedWidth, multiple: 16) + + var isFirstFrame = false + + let yuvaSurface: ImageYUVA420 + if let current = self.currentYUVASurface { + if current.yPlane.width == width && current.yPlane.height == height { + yuvaSurface = current + } else { + self.isFailed = true + return + } + } else { + isFirstFrame = true + yuvaSurface = ImageYUVA420(width: width, height: height, rowAlignment: nil) + self.currentYUVASurface = yuvaSurface + } + + let dctCoefficients: DctCoefficientsYUVA420 + if let current = self.currentDctCoefficients { + if current.yPlane.width == width && current.yPlane.height == height { + dctCoefficients = current + } else { + self.isFailed = true + return + } + } else { + dctCoefficients = DctCoefficientsYUVA420(width: width, height: height) + self.currentDctCoefficients = dctCoefficients + } + + let dctData: DctData + if let current = self.currentDctData, current.quality == self.dctQuality { + dctData = current + } else { + dctData = DctData(quality: self.dctQuality) + self.currentDctData = dctData + } + + drawingBlock(yuvaSurface) + + yuvaSurface.dct(dctData: dctData, target: dctCoefficients) + + if isFirstFrame { + file.write(2 as UInt32) + + file.write(UInt32(dctCoefficients.yPlane.width)) + file.write(UInt32(dctCoefficients.yPlane.height)) + file.write(UInt32(dctData.quality)) + + self.contentLengthOffset = Int(file.position()) + file.write(0 as UInt32) + } + + let framePosition = Int(file.position()) + assert(framePosition >= 0) + var frameLength = 0 + + for i in 0 ..< 4 { + let dctPlane: DctCoefficientPlane + switch i { + case 0: + dctPlane = dctCoefficients.yPlane + case 1: + dctPlane = dctCoefficients.uPlane + case 2: + dctPlane = dctCoefficients.vPlane + case 3: + dctPlane = dctCoefficients.aPlane + default: + preconditionFailure() + } + + dctPlane.data.withUnsafeBytes { bytes in + let _ = file.write(bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), count: bytes.count) + } + frameLength += dctPlane.data.count + } + + self.frames.append(FrameMetadata(offset: framePosition, length: frameLength, duration: duration)) + + self.contentLength += frameLength + } + + func finish() -> CompressedResult? { + var shouldComplete = false + + outer: for _ in 0 ..< 1 { + if !self.isFinished { + self.isFinished = true + shouldComplete = true + + guard let contentLengthOffset = self.contentLengthOffset, let file = self.file else { + self.isFailed = true + break outer + } + assert(contentLengthOffset >= 0) + + let metadataPosition = file.position() + file.seek(position: Int64(contentLengthOffset)) + file.write(UInt32(self.contentLength)) + + file.seek(position: metadataPosition) + file.write(UInt32(self.frames.count)) + for frame in self.frames { + file.write(UInt32(frame.offset)) + file.write(UInt32(frame.length)) + file.write(Float32(frame.duration)) + } + + if !self.frames.isEmpty { + } else { + self.isFailed = true + break outer + } + + if !self.isFailed { + self.file = nil + + file._unsafeClose() + + guard let uncompressedData = try? Data(contentsOf: URL(fileURLWithPath: self.decompressedPath), options: .alwaysMapped) else { + self.isFailed = true + break outer + } + guard let compressedData = compressData(data: uncompressedData) else { + self.isFailed = true + break outer + } + guard let compressedFile = ManagedFile(queue: nil, path: self.compressedPath, mode: .readwrite) else { + self.isFailed = true + break outer + } + compressedFile.write(Int32(uncompressedData.count)) + let _ = compressedFile.write(compressedData) + compressedFile._unsafeClose() + } + } + } + + if shouldComplete { + let _ = try? FileManager.default.removeItem(atPath: self.decompressedPath) + + if !self.isFailed { + return CompressedResult(path: self.compressedPath) + } else { + let _ = try? FileManager.default.removeItem(atPath: self.compressedPath) + + return nil + } + } else { + return nil + } + } +} + private final class AnimationCacheItemWriterImpl: AnimationCacheItemWriter { struct CompressedResult { var animationPath: String @@ -223,7 +459,7 @@ private final class AnimationCacheItemWriterImpl: AnimationCacheItemWriter { private let lock = Lock() init?(queue: Queue, allocateTempFile: @escaping () -> String, completion: @escaping (CompressedResult?) -> Void) { - self.dctQuality = 67 + self.dctQuality = 70 self.queue = queue self.decompressedPath = allocateTempFile() @@ -263,7 +499,7 @@ private final class AnimationCacheItemWriterImpl: AnimationCacheItemWriter { } else { isFirstFrame = true - surface = ImageARGB(width: width, height: height) + surface = ImageARGB(width: width, height: height, rowAlignment: 32) self.currentSurface = surface } @@ -276,7 +512,7 @@ private final class AnimationCacheItemWriterImpl: AnimationCacheItemWriter { return } } else { - yuvaSurface = ImageYUVA420(width: width, height: height) + yuvaSurface = ImageYUVA420(width: width, height: height, rowAlignment: nil) self.currentYUVASurface = yuvaSurface } @@ -468,7 +704,7 @@ private final class AnimationCacheItemAccessor { private let durationMapping: [Double] private let totalDuration: Double - private var currentYUVASurface: ImageYUVA420 + private var currentYUVASurface: ImageYUVA420? private var currentDctData: DctData private var currentDctCoefficients: DctCoefficientsYUVA420 @@ -490,18 +726,15 @@ private final class AnimationCacheItemAccessor { self.durationMapping = durationMapping self.totalDuration = totalDuration - self.currentYUVASurface = ImageYUVA420(width: width, height: height) self.currentDctData = DctData(quality: dctQuality) self.currentDctCoefficients = DctCoefficientsYUVA420(width: width, height: height) } - func getFrame(index: Int) -> AnimationCacheItemFrame? { + func getFrame(index: Int, requestedFormat: AnimationCacheItemFrame.RequestedFormat) -> AnimationCacheItemFrame? { guard let frameInfo = self.frameMapping[index] else { return nil } - let currentSurface = ImageARGB(width: self.currentYUVASurface.yPlane.width, height: self.currentYUVASurface.yPlane.height) - var frameDataOffset = 0 let frameLength = frameInfo.range.upperBound - frameInfo.range.lowerBound for i in 0 ..< 4 { @@ -530,10 +763,58 @@ private final class AnimationCacheItemAccessor { frameDataOffset += dctPlane.data.count } - self.currentDctCoefficients.idct(dctData: self.currentDctData, target: self.currentYUVASurface) - self.currentYUVASurface.toARGB(target: currentSurface) + let yuvaSurface: ImageYUVA420 + switch requestedFormat { + case .rgba: + if let currentYUVASurface = self.currentYUVASurface { + yuvaSurface = currentYUVASurface + } else { + yuvaSurface = ImageYUVA420(width: self.currentDctCoefficients.yPlane.width, height: self.currentDctCoefficients.yPlane.height, rowAlignment: nil) + } + case let .yuva(preferredRowAlignment): + yuvaSurface = ImageYUVA420(width: self.currentDctCoefficients.yPlane.width, height: self.currentDctCoefficients.yPlane.height, rowAlignment: preferredRowAlignment) + } - return AnimationCacheItemFrame(data: currentSurface.argbPlane.data, range: 0 ..< currentSurface.argbPlane.data.count, format: .rgba(width: currentSurface.argbPlane.width, height: currentSurface.argbPlane.height, bytesPerRow: currentSurface.argbPlane.bytesPerRow), duration: frameInfo.duration) + self.currentDctCoefficients.idct(dctData: self.currentDctData, target: yuvaSurface) + + switch requestedFormat { + case .rgba: + let currentSurface = ImageARGB(width: yuvaSurface.yPlane.width, height: yuvaSurface.yPlane.height, rowAlignment: 32) + yuvaSurface.toARGB(target: currentSurface) + self.currentYUVASurface = yuvaSurface + + return AnimationCacheItemFrame(format: .rgba(data: currentSurface.argbPlane.data, width: currentSurface.argbPlane.width, height: currentSurface.argbPlane.height, bytesPerRow: currentSurface.argbPlane.bytesPerRow), duration: frameInfo.duration) + case .yuva: + return AnimationCacheItemFrame( + format: .yuva( + y: AnimationCacheItemFrame.Plane( + data: yuvaSurface.yPlane.data, + width: yuvaSurface.yPlane.width, + height: yuvaSurface.yPlane.height, + bytesPerRow: yuvaSurface.yPlane.bytesPerRow + ), + u: AnimationCacheItemFrame.Plane( + data: yuvaSurface.uPlane.data, + width: yuvaSurface.uPlane.width, + height: yuvaSurface.uPlane.height, + bytesPerRow: yuvaSurface.uPlane.bytesPerRow + ), + v: AnimationCacheItemFrame.Plane( + data: yuvaSurface.vPlane.data, + width: yuvaSurface.vPlane.width, + height: yuvaSurface.vPlane.height, + bytesPerRow: yuvaSurface.vPlane.bytesPerRow + ), + a: AnimationCacheItemFrame.Plane( + data: yuvaSurface.aPlane.data, + width: yuvaSurface.aPlane.width, + height: yuvaSurface.aPlane.height, + bytesPerRow: yuvaSurface.aPlane.bytesPerRow + ) + ), + duration: frameInfo.duration + ) + } } func getFrameIndex(duration: Double) -> Int { @@ -551,6 +832,14 @@ private final class AnimationCacheItemAccessor { } return self.durationMapping.count - 1 } + + func getFrameDuration(index: Int) -> Double? { + if index < self.durationMapping.count { + return self.durationMapping[index] + } else { + return nil + } + } } private func readUInt32(data: Data, offset: Int) -> UInt32 { @@ -675,13 +964,104 @@ private func loadItem(path: String) -> AnimationCacheItem? { let itemAccessor = AnimationCacheItemAccessor(data: data, frameMapping: frameMapping, width: Int(width), height: Int(height), dctQuality: Int(dctQuality)) - return AnimationCacheItem(numFrames: Int(numFrames), getFrame: { index in - return itemAccessor.getFrame(index: index) + return AnimationCacheItem(numFrames: Int(numFrames), getFrame: { index, requestedFormat in + return itemAccessor.getFrame(index: index, requestedFormat: requestedFormat) }, getFrameIndexImpl: { duration in return itemAccessor.getFrameIndex(duration: duration) + }, getFrameDurationImpl: { index in + return itemAccessor.getFrameDuration(index: index) }) } +private func adaptItemFromHigherResolution(itemPath: String, width: Int, height: Int, itemDirectoryPath: String, higherResolutionPath: String, allocateTempFile: @escaping () -> String) -> AnimationCacheItem? { + guard let higherResolutionItem = loadItem(path: higherResolutionPath) else { + return nil + } + guard let writer = AnimationCacheItemWriterInternal(allocateTempFile: allocateTempFile) else { + return nil + } + + for i in 0 ..< higherResolutionItem.numFrames { + guard let duration = higherResolutionItem.getFrameDuration(index: i) else { + break + } + writer.add(with: { yuva in + guard let frame = higherResolutionItem.getFrame(index: i, requestedFormat: .yuva(rowAlignment: yuva.yPlane.rowAlignment)) else { + return + } + switch frame.format { + case .rgba: + return + case let .yuva(y, u, v, a): + yuva.yPlane.copyScaled(fromPlane: y) + yuva.uPlane.copyScaled(fromPlane: u) + yuva.vPlane.copyScaled(fromPlane: v) + yuva.aPlane.copyScaled(fromPlane: a) + } + }, proposedWidth: width, proposedHeight: height, duration: duration) + } + + guard let result = writer.finish() else { + return nil + } + guard let _ = try? FileManager.default.createDirectory(at: URL(fileURLWithPath: itemDirectoryPath), withIntermediateDirectories: true, attributes: nil) else { + return nil + } + let _ = try? FileManager.default.removeItem(atPath: itemPath) + guard let _ = try? FileManager.default.moveItem(atPath: result.path, toPath: itemPath) else { + return nil + } + guard let item = loadItem(path: itemPath) else { + return nil + } + return item +} + +private func findHigherResolutionFileForAdaptation(itemDirectoryPath: String, baseName: String, baseSuffix: String, width: Int, height: Int) -> String? { + var candidates: [(path: String, width: Int, height: Int)] = [] + if let enumerator = FileManager.default.enumerator(at: URL(fileURLWithPath: itemDirectoryPath), includingPropertiesForKeys: nil, options: .skipsSubdirectoryDescendants, errorHandler: nil) { + for url in enumerator { + guard let url = url as? URL else { + continue + } + let fileName = url.lastPathComponent + if fileName.hasPrefix(baseName) { + let scanner = Scanner(string: fileName) + guard scanner.scanString(baseName, into: nil) else { + continue + } + var itemWidth: Int = 0 + guard scanner.scanInt(&itemWidth) else { + continue + } + guard scanner.scanString("x", into: nil) else { + continue + } + var itemHeight: Int = 0 + guard scanner.scanInt(&itemHeight) else { + continue + } + if !baseSuffix.isEmpty { + guard scanner.scanString(baseSuffix, into: nil) else { + continue + } + } + guard scanner.isAtEnd else { + continue + } + if itemWidth > width && itemHeight > height { + candidates.append((url.path, itemWidth, itemHeight)) + } + } + } + } + if !candidates.isEmpty { + candidates.sort(by: { $0.width < $1.width }) + return candidates[0].path + } + return nil +} + public final class AnimationCacheImpl: AnimationCache { private final class Impl { private final class ItemContext { @@ -721,7 +1101,7 @@ public final class AnimationCacheImpl: AnimationCache { } func get(sourceId: String, size: CGSize, fetch: @escaping (CGSize, AnimationCacheItemWriter) -> Disposable, updateResult: @escaping (AnimationCacheItemResult) -> Void) -> Disposable { - let sourceIdPath = itemSubpath(hashString: md5Hash(sourceId + "-\(Int(size.width))x\(Int(size.height))")) + let sourceIdPath = itemSubpath(hashString: md5Hash(sourceId), width: Int(size.width), height: Int(size.height)) let itemDirectoryPath = "\(self.basePath)/\(sourceIdPath.directory)" let itemPath = "\(itemDirectoryPath)/\(sourceIdPath.fileName)" let itemFirstFramePath = "\(itemDirectoryPath)/\(sourceIdPath.fileName)-f" @@ -810,43 +1190,58 @@ public final class AnimationCacheImpl: AnimationCache { } } - static func getFirstFrameSynchronously(basePath: String, sourceId: String, size: CGSize) -> AnimationCacheItem? { - let sourceIdPath = itemSubpath(hashString: md5Hash(sourceId + "-\(Int(size.width))x\(Int(size.height))")) + static func getFirstFrameSynchronously(basePath: String, sourceId: String, size: CGSize, allocateTempFile: @escaping () -> String) -> AnimationCacheItem? { + let hashString = md5Hash(sourceId) + let sourceIdPath = itemSubpath(hashString: hashString, width: Int(size.width), height: Int(size.height)) let itemDirectoryPath = "\(basePath)/\(sourceIdPath.directory)" let itemFirstFramePath = "\(itemDirectoryPath)/\(sourceIdPath.fileName)-f" if FileManager.default.fileExists(atPath: itemFirstFramePath) { return loadItem(path: itemFirstFramePath) - } else { - return nil } + + if let adaptationItemPath = findHigherResolutionFileForAdaptation(itemDirectoryPath: itemDirectoryPath, baseName: "\(hashString)_", baseSuffix: "-f", width: Int(size.width), height: Int(size.height)) { + if let adaptedItem = adaptItemFromHigherResolution(itemPath: itemFirstFramePath, width: Int(size.width), height: Int(size.height), itemDirectoryPath: itemDirectoryPath, higherResolutionPath: adaptationItemPath, allocateTempFile: allocateTempFile) { + return adaptedItem + } + } + + return nil } - static func getFirstFrame(basePath: String, sourceId: String, size: CGSize, completion: @escaping (AnimationCacheItem?) -> Void) -> Disposable { - let sourceIdPath = itemSubpath(hashString: md5Hash(sourceId + "-\(Int(size.width))x\(Int(size.height))")) + static func getFirstFrame(basePath: String, sourceId: String, size: CGSize, allocateTempFile: @escaping () -> String, completion: @escaping (AnimationCacheItem?) -> Void) -> Disposable { + let hashString = md5Hash(sourceId) + let sourceIdPath = itemSubpath(hashString: hashString, width: Int(size.width), height: Int(size.height)) let itemDirectoryPath = "\(basePath)/\(sourceIdPath.directory)" let itemFirstFramePath = "\(itemDirectoryPath)/\(sourceIdPath.fileName)-f" if FileManager.default.fileExists(atPath: itemFirstFramePath), let item = loadItem(path: itemFirstFramePath) { completion(item) - - return EmptyDisposable - } else { - completion(nil) - return EmptyDisposable } + + if let adaptationItemPath = findHigherResolutionFileForAdaptation(itemDirectoryPath: itemDirectoryPath, baseName: "\(hashString)_", baseSuffix: "-f", width: Int(size.width), height: Int(size.height)) { + if let adaptedItem = adaptItemFromHigherResolution(itemPath: itemFirstFramePath, width: Int(size.width), height: Int(size.height), itemDirectoryPath: itemDirectoryPath, higherResolutionPath: adaptationItemPath, allocateTempFile: allocateTempFile) { + completion(adaptedItem) + return EmptyDisposable + } + } + + completion(nil) + return EmptyDisposable } } private let queue: Queue private let basePath: String private let impl: QueueLocalObject + private let allocateTempFile: () -> String public init(basePath: String, allocateTempFile: @escaping () -> String) { let queue = Queue() self.queue = queue self.basePath = basePath + self.allocateTempFile = allocateTempFile self.impl = QueueLocalObject(queue: queue, generate: { return Impl(queue: queue, basePath: basePath, allocateTempFile: allocateTempFile) }) @@ -871,15 +1266,16 @@ public final class AnimationCacheImpl: AnimationCache { } public func getFirstFrameSynchronously(sourceId: String, size: CGSize) -> AnimationCacheItem? { - return Impl.getFirstFrameSynchronously(basePath: self.basePath, sourceId: sourceId, size: size) + return Impl.getFirstFrameSynchronously(basePath: self.basePath, sourceId: sourceId, size: size, allocateTempFile: self.allocateTempFile) } public func getFirstFrame(queue: Queue, sourceId: String, size: CGSize, completion: @escaping (AnimationCacheItem?) -> Void) -> Disposable { let disposable = MetaDisposable() let basePath = self.basePath + let allocateTempFile = self.allocateTempFile queue.async { - disposable.set(Impl.getFirstFrame(basePath: basePath, sourceId: sourceId, size: size, completion: completion)) + disposable.set(Impl.getFirstFrame(basePath: basePath, sourceId: sourceId, size: size, allocateTempFile: allocateTempFile, completion: completion)) } return disposable diff --git a/submodules/TelegramUI/Components/AnimationCache/Sources/ImageData.swift b/submodules/TelegramUI/Components/AnimationCache/Sources/ImageData.swift index 284ef6aff2..6a31392a13 100644 --- a/submodules/TelegramUI/Components/AnimationCache/Sources/ImageData.swift +++ b/submodules/TelegramUI/Components/AnimationCache/Sources/ImageData.swift @@ -2,27 +2,46 @@ import Foundation import UIKit import ImageDCT +private func alignUp(size: Int, align: Int) -> Int { + precondition(((align - 1) & align) == 0, "Align must be a power of two") + + let alignmentMask = align - 1 + return (size + alignmentMask) & ~alignmentMask +} + final class ImagePlane { let width: Int let height: Int let bytesPerRow: Int + let rowAlignment: Int let components: Int var data: Data - init(width: Int, height: Int, components: Int) { + init(width: Int, height: Int, components: Int, rowAlignment: Int?) { self.width = width self.height = height - self.bytesPerRow = width * components + self.rowAlignment = rowAlignment ?? 1 + self.bytesPerRow = alignUp(size: width * components, align: self.rowAlignment) self.components = components - self.data = Data(count: width * components * height) + self.data = Data(count: self.bytesPerRow * height) + } +} + +extension ImagePlane { + func copyScaled(fromPlane plane: AnimationCacheItemFrame.Plane) { + self.data.withUnsafeMutableBytes { destBytes in + plane.data.withUnsafeBytes { srcBytes in + scaleImagePlane(destBytes.baseAddress!.assumingMemoryBound(to: UInt8.self), Int32(self.width), Int32(self.height), Int32(self.bytesPerRow), srcBytes.baseAddress!.assumingMemoryBound(to: UInt8.self), Int32(plane.width), Int32(plane.height), Int32(plane.bytesPerRow)) + } + } } } final class ImageARGB { let argbPlane: ImagePlane - init(width: Int, height: Int) { - self.argbPlane = ImagePlane(width: width, height: height, components: 4) + init(width: Int, height: Int, rowAlignment: Int?) { + self.argbPlane = ImagePlane(width: width, height: height, components: 4, rowAlignment: rowAlignment) } } @@ -32,11 +51,11 @@ final class ImageYUVA420 { let vPlane: ImagePlane let aPlane: ImagePlane - init(width: Int, height: Int) { - self.yPlane = ImagePlane(width: width, height: height, components: 1) - self.uPlane = ImagePlane(width: width / 2, height: height / 2, components: 1) - self.vPlane = ImagePlane(width: width / 2, height: height / 2, components: 1) - self.aPlane = ImagePlane(width: width, height: height, components: 1) + init(width: Int, height: Int, rowAlignment: Int?) { + self.yPlane = ImagePlane(width: width, height: height, components: 1, rowAlignment: rowAlignment) + self.uPlane = ImagePlane(width: width / 2, height: height / 2, components: 1, rowAlignment: rowAlignment) + self.vPlane = ImagePlane(width: width / 2, height: height / 2, components: 1, rowAlignment: rowAlignment) + self.aPlane = ImagePlane(width: width, height: height, components: 1, rowAlignment: rowAlignment) } } @@ -92,8 +111,8 @@ extension ImageARGB { } } - func toYUVA420() -> ImageYUVA420 { - let resultImage = ImageYUVA420(width: self.argbPlane.width, height: self.argbPlane.height) + func toYUVA420(rowAlignment: Int?) -> ImageYUVA420 { + let resultImage = ImageYUVA420(width: self.argbPlane.width, height: self.argbPlane.height, rowAlignment: rowAlignment) self.toYUVA420(target: resultImage) return resultImage } @@ -125,8 +144,8 @@ extension ImageYUVA420 { } } - func toARGB() -> ImageARGB { - let resultImage = ImageARGB(width: self.yPlane.width, height: self.yPlane.height) + func toARGB(rowAlignment: Int?) -> ImageARGB { + let resultImage = ImageARGB(width: self.yPlane.width, height: self.yPlane.height, rowAlignment: rowAlignment) self.toARGB(target: resultImage) return resultImage } @@ -215,14 +234,14 @@ extension DctCoefficientsYUVA420 { targetPlane.data.withUnsafeMutableBytes { bytes in let pixels = bytes.baseAddress!.assumingMemoryBound(to: UInt8.self) - dctData.dct.inverse(withCoefficients: coefficients, pixels: pixels, width: sourcePlane.width, height: sourcePlane.height, coefficientsPerRow: targetPlane.width, bytesPerRow: targetPlane.width) + dctData.dct.inverse(withCoefficients: coefficients, pixels: pixels, width: sourcePlane.width, height: sourcePlane.height, coefficientsPerRow: targetPlane.width, bytesPerRow: targetPlane.bytesPerRow) } } } } - func idct(dctData: DctData) -> ImageYUVA420 { - let resultImage = ImageYUVA420(width: self.yPlane.width, height: self.yPlane.height) + func idct(dctData: DctData, rowAlignment: Int?) -> ImageYUVA420 { + let resultImage = ImageYUVA420(width: self.yPlane.width, height: self.yPlane.height, rowAlignment: rowAlignment) self.idct(dctData: dctData, target: resultImage) return resultImage } diff --git a/submodules/TelegramUI/Components/EmojiTextAttachmentView/Sources/EmojiTextAttachmentView.swift b/submodules/TelegramUI/Components/EmojiTextAttachmentView/Sources/EmojiTextAttachmentView.swift index ccf739598f..b1ec5fa256 100644 --- a/submodules/TelegramUI/Components/EmojiTextAttachmentView/Sources/EmojiTextAttachmentView.swift +++ b/submodules/TelegramUI/Components/EmojiTextAttachmentView/Sources/EmojiTextAttachmentView.swift @@ -30,7 +30,6 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget { } private let context: AccountContext - private let groupId: String private let emoji: ChatTextInputTextCustomEmojiAttribute private let cache: AnimationCache private let renderer: MultiAnimationRenderer @@ -39,6 +38,8 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget { private let pointSize: CGSize private let pixelSize: CGSize + private var isDisplayingPlaceholder: Bool = false + private var file: TelegramMediaFile? private var infoDisposable: Disposable? private var disposable: Disposable? @@ -54,9 +55,8 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget { } } - public init(context: AccountContext, groupId: String, attemptSynchronousLoad: Bool, emoji: ChatTextInputTextCustomEmojiAttribute, file: TelegramMediaFile?, cache: AnimationCache, renderer: MultiAnimationRenderer, placeholderColor: UIColor, pointSize: CGSize) { + public init(context: AccountContext, attemptSynchronousLoad: Bool, emoji: ChatTextInputTextCustomEmojiAttribute, file: TelegramMediaFile?, cache: AnimationCache, renderer: MultiAnimationRenderer, placeholderColor: UIColor, pointSize: CGSize) { self.context = context - self.groupId = groupId self.emoji = emoji self.cache = cache self.renderer = renderer @@ -101,10 +101,11 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget { override public func action(forKey event: String) -> CAAction? { if event == kCAOnOrderIn { self.isInHierarchyValue = true + self.updatePlayback() } else if event == kCAOnOrderOut { self.isInHierarchyValue = false + self.updatePlayback() } - self.updatePlayback() return nullAction } @@ -122,9 +123,10 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget { self.file = file if attemptSynchronousLoad { - if !self.renderer.loadFirstFrameSynchronously(groupId: self.groupId, target: self, cache: self.cache, itemId: file.resource.id.stringRepresentation, size: self.pixelSize) { + if !self.renderer.loadFirstFrameSynchronously(target: self, cache: self.cache, itemId: file.resource.id.stringRepresentation, size: self.pixelSize) { if let image = generateStickerPlaceholderImage(data: file.immediateThumbnailData, size: self.pointSize, imageSize: file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0), backgroundColor: nil, foregroundColor: self.placeholderColor) { self.contents = image.cgImage + self.isDisplayingPlaceholder = true } } @@ -132,10 +134,10 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget { } else { let pointSize = self.pointSize let placeholderColor = self.placeholderColor - self.loadDisposable = self.renderer.loadFirstFrame(groupId: self.groupId, target: self, cache: self.cache, itemId: file.resource.id.stringRepresentation, size: self.pixelSize, completion: { [weak self] result in + self.loadDisposable = self.renderer.loadFirstFrame(target: self, cache: self.cache, itemId: file.resource.id.stringRepresentation, size: self.pixelSize, completion: { [weak self] result in if !result { MultiAnimationRendererImpl.firstFrameQueue.async { - let image = generateStickerPlaceholderImage(data: file.immediateThumbnailData, size: pointSize, imageSize: file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0), backgroundColor: nil, foregroundColor: placeholderColor) + let image = generateStickerPlaceholderImage(data: file.immediateThumbnailData, size: pointSize, scale: min(2.0, UIScreenScale), imageSize: file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0), backgroundColor: nil, foregroundColor: placeholderColor) DispatchQueue.main.async { guard let strongSelf = self else { @@ -143,6 +145,7 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget { } if let image = image { strongSelf.contents = image.cgImage + strongSelf.isDisplayingPlaceholder = true } strongSelf.loadAnimation() } @@ -164,7 +167,7 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget { let context = self.context if file.isAnimatedSticker || file.isVideoEmoji { - self.disposable = renderer.add(groupId: self.groupId, target: self, cache: self.cache, itemId: file.resource.id.stringRepresentation, size: self.pixelSize, fetch: { size, writer in + self.disposable = renderer.add(target: self, cache: self.cache, itemId: file.resource.id.stringRepresentation, size: self.pixelSize, fetch: { size, writer in let source = AnimatedStickerResourceSource(account: context.account, resource: file.resource, fitzModifier: nil, isVideo: false) let dataDisposable = source.directDataPath(attemptSynchronously: false).start(next: { result in @@ -191,7 +194,7 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget { } }) } else { - self.disposable = renderer.add(groupId: self.groupId, target: self, cache: self.cache, itemId: file.resource.id.stringRepresentation, size: self.pixelSize, fetch: { size, writer in + self.disposable = renderer.add(target: self, cache: self.cache, itemId: file.resource.id.stringRepresentation, size: self.pixelSize, fetch: { size, writer in let dataDisposable = context.account.postbox.mediaBox.resourceData(file.resource).start(next: { result in guard result.complete else { return @@ -209,13 +212,45 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget { }) } } + + override public func updateDisplayPlaceholder(displayPlaceholder: Bool) { + if self.isDisplayingPlaceholder == displayPlaceholder { + return + } + self.isDisplayingPlaceholder = displayPlaceholder + } + + override public func transitionToContents(_ contents: AnyObject) { + if self.isDisplayingPlaceholder { + self.isDisplayingPlaceholder = false + + if let current = self.contents { + let previousLayer = SimpleLayer() + previousLayer.contents = current + previousLayer.frame = self.frame + self.superlayer?.insertSublayer(previousLayer, below: self) + previousLayer.opacity = 0.0 + previousLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak previousLayer] _ in + previousLayer?.removeFromSuperlayer() + }) + + self.contents = contents + self.animateAlpha(from: 0.0, to: 1.0, duration: 0.18) + } else { + self.contents = contents + self.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } else { + self.contents = contents + } + } } public final class EmojiTextAttachmentView: UIView { private let contentLayer: InlineStickerItemLayer - public init(context: AccountContext, emoji: ChatTextInputTextCustomEmojiAttribute, file: TelegramMediaFile?, cache: AnimationCache, renderer: MultiAnimationRenderer, placeholderColor: UIColor) { - self.contentLayer = InlineStickerItemLayer(context: context, groupId: "textInputView", attemptSynchronousLoad: true, emoji: emoji, file: file, cache: cache, renderer: renderer, placeholderColor: placeholderColor, pointSize: CGSize(width: 24.0, height: 24.0)) + public init(context: AccountContext, emoji: ChatTextInputTextCustomEmojiAttribute, file: TelegramMediaFile?, cache: AnimationCache, renderer: MultiAnimationRenderer, placeholderColor: UIColor, pointSize: CGSize) { + self.contentLayer = InlineStickerItemLayer(context: context, attemptSynchronousLoad: true, emoji: emoji, file: file, cache: cache, renderer: renderer, placeholderColor: placeholderColor, pointSize: pointSize) super.init(frame: CGRect()) diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift index 02c56aa043..7783c596da 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift @@ -350,7 +350,7 @@ public final class EmojiPagerContentComponent: Component { var premiumButtonInset: CGFloat var premiumButtonHeight: CGFloat - init(width: CGFloat, containerInsets: UIEdgeInsets, itemGroups: [ItemGroupDescription], itemLayoutType: ItemLayoutType, expandedPremiumGroups: Set) { + init(width: CGFloat, containerInsets: UIEdgeInsets, itemGroups: [ItemGroupDescription], itemLayoutType: ItemLayoutType) { self.width = width self.containerInsets = containerInsets @@ -392,7 +392,7 @@ public final class EmojiPagerContentComponent: Component { let numRowsInGroup = (itemGroup.itemCount + (self.itemsPerRow - 1)) / self.itemsPerRow var groupContentSize = CGSize(width: width, height: itemTopOffset + CGFloat(numRowsInGroup) * self.visibleItemSize + CGFloat(max(0, numRowsInGroup - 1)) * self.verticalSpacing) - if itemGroup.isPremium && expandedPremiumGroups.contains(itemGroup.groupId) { + if itemGroup.isPremium { groupContentSize.height += self.premiumButtonInset + self.premiumButtonHeight } self.itemGroupLayouts.append(ItemGroupLayout( @@ -547,12 +547,11 @@ public final class EmojiPagerContentComponent: Component { } } public private(set) var displayPlaceholder: Bool = false - public let onUpdateDisplayPlaceholder: (Bool) -> Void - + public let onUpdateDisplayPlaceholder: (Bool, Double) -> Void + public init( item: Item, context: AccountContext, - groupId: String, attemptSynchronousLoad: Bool, file: TelegramMediaFile?, staticEmoji: String?, @@ -562,7 +561,7 @@ public final class EmojiPagerContentComponent: Component { blurredBadgeColor: UIColor, displayPremiumBadgeIfAvailable: Bool, pointSize: CGSize, - onUpdateDisplayPlaceholder: @escaping (Bool) -> Void + onUpdateDisplayPlaceholder: @escaping (Bool, Double) -> Void ) { self.item = item self.file = file @@ -583,7 +582,7 @@ public final class EmojiPagerContentComponent: Component { return } - strongSelf.disposable = renderer.add(groupId: groupId, target: strongSelf, cache: cache, itemId: file.resource.id.stringRepresentation, size: pixelSize, fetch: { size, writer in + strongSelf.disposable = renderer.add(target: strongSelf, cache: cache, itemId: file.resource.id.stringRepresentation, size: pixelSize, fetch: { size, writer in let source = AnimatedStickerResourceSource(account: context.account, resource: file.resource, fitzModifier: nil, isVideo: false) let dataDisposable = source.directDataPath(attemptSynchronously: false).start(next: { result in @@ -614,13 +613,13 @@ public final class EmojiPagerContentComponent: Component { } if attemptSynchronousLoad { - if !renderer.loadFirstFrameSynchronously(groupId: groupId, target: self, cache: cache, itemId: file.resource.id.stringRepresentation, size: pixelSize) { + if !renderer.loadFirstFrameSynchronously(target: self, cache: cache, itemId: file.resource.id.stringRepresentation, size: pixelSize) { self.updateDisplayPlaceholder(displayPlaceholder: true) } loadAnimation() } else { - let _ = renderer.loadFirstFrame(groupId: groupId, target: self, cache: cache, itemId: file.resource.id.stringRepresentation, size: pixelSize, completion: { [weak self] success in + let _ = renderer.loadFirstFrame(target: self, cache: cache, itemId: file.resource.id.stringRepresentation, size: pixelSize, completion: { [weak self] success in loadAnimation() if !success { @@ -692,7 +691,7 @@ public final class EmojiPagerContentComponent: Component { self.placeholderColor = layer.placeholderColor self.size = layer.size - self.onUpdateDisplayPlaceholder = { _ in } + self.onUpdateDisplayPlaceholder = { _, _ in } super.init(layer: layer) } @@ -728,47 +727,17 @@ public final class EmojiPagerContentComponent: Component { } self.displayPlaceholder = displayPlaceholder - self.onUpdateDisplayPlaceholder(displayPlaceholder) + self.onUpdateDisplayPlaceholder(displayPlaceholder, 0.0) + } + + public override func transitionToContents(_ contents: AnyObject) { + self.contents = contents - /*if displayPlaceholder { - if self.placeholderView == nil { - self.placeholderView = PortalView() - if let placeholderView = self.placeholderView, let shimmerView = self.shimmerView { - self.addSublayer(placeholderView.view.layer) - placeholderView.view.frame = self.bounds - shimmerView.addPortal(view: placeholderView) - } - } - if self.placeholderMaskLayer == nil { - self.placeholderMaskLayer = SimpleLayer() - self.placeholderView?.view.layer.mask = self.placeholderMaskLayer - } - let file = self.file - let size = self.size - //let placeholderColor = self.placeholderColor - - Queue.concurrentDefaultQueue().async { [weak self] in - if let image = generateStickerPlaceholderImage(data: file.immediateThumbnailData, size: size, imageSize: file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0), backgroundColor: nil, foregroundColor: .black) { - Queue.mainQueue().async { - guard let strongSelf = self else { - return - } - - if strongSelf.displayPlaceholder { - strongSelf.placeholderMaskLayer?.contents = image.cgImage - } - } - } - } - } else { - if let placeholderView = self.placeholderView { - self.placeholderView = nil - placeholderView.view.layer.removeFromSuperlayer() - } - if let _ = self.placeholderMaskLayer { - self.placeholderMaskLayer = nil - } - }*/ + if self.displayPlaceholder { + self.displayPlaceholder = false + self.onUpdateDisplayPlaceholder(false, 0.2) + self.animateAlpha(from: 0.0, to: 1.0, duration: 0.18) + } } } @@ -800,12 +769,12 @@ public final class EmojiPagerContentComponent: Component { private let boundsChangeTrackerLayer = SimpleLayer() private var effectiveVisibleSize: CGSize = CGSize() + private let placeholdersContainerView: UIView private var visibleItemPlaceholderViews: [ItemLayer.Key: ItemPlaceholderView] = [:] private var visibleItemLayers: [ItemLayer.Key: ItemLayer] = [:] private var visibleGroupHeaders: [AnyHashable: GroupHeaderLayer] = [:] private var visibleGroupBorders: [AnyHashable: GroupBorderLayer] = [:] private var visibleGroupPremiumButtons: [AnyHashable: ComponentView] = [:] - private var expandedPremiumGroups: Set = Set() private var ignoreScrolling: Bool = false private var keepTopPanelVisibleUntilScrollingInput: Bool = false @@ -822,12 +791,13 @@ public final class EmojiPagerContentComponent: Component { override init(frame: CGRect) { self.shimmerHostView = PortalSourceView() - self.standaloneShimmerEffect = StandaloneShimmerEffect() self.scrollView = ContentScrollView() self.scrollView.layer.anchorPoint = CGPoint() + self.placeholdersContainerView = UIView() + super.init(frame: frame) self.shimmerHostView.alpha = 0.0 @@ -852,6 +822,8 @@ public final class EmojiPagerContentComponent: Component { self.scrollView.clipsToBounds = false self.addSubview(self.scrollView) + self.scrollView.addSubview(self.placeholdersContainerView) + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) let peekRecognizer = PeekControllerGestureRecognizer(contentAtPoint: { [weak self] point in @@ -1071,7 +1043,8 @@ public final class EmojiPagerContentComponent: Component { let locationInScrollView = recognizer.location(in: self.scrollView) outer: for (id, groupHeader) in self.visibleGroupHeaders { if groupHeader.frame.insetBy(dx: -10.0, dy: -6.0).contains(locationInScrollView) { - for group in component.itemGroups { + let _ = id + /*for group in component.itemGroups { if group.groupId == id { if group.isPremium && !self.expandedPremiumGroups.contains(id) { if self.expandedPremiumGroups.contains(id) { @@ -1096,7 +1069,7 @@ public final class EmojiPagerContentComponent: Component { break outer } } - } + }*/ } } @@ -1325,7 +1298,7 @@ public final class EmojiPagerContentComponent: Component { } groupBorderTransition.setFrame(layer: groupBorderLayer, frame: groupBorderFrame) - if self.expandedPremiumGroups.contains(itemGroup.groupId) { + if itemGroup.isPremium { validGroupPremiumButtonIds.insert(itemGroup.groupId) let groupPremiumButton: ComponentView @@ -1414,7 +1387,6 @@ public final class EmojiPagerContentComponent: Component { itemLayer = ItemLayer( item: item, context: component.context, - groupId: "keyboard-\(Int(itemLayout.nativeItemSize))", attemptSynchronousLoad: attemptSynchronousLoads, file: item.file, staticEmoji: item.staticEmoji, @@ -1424,7 +1396,7 @@ public final class EmojiPagerContentComponent: Component { blurredBadgeColor: theme.chat.inputPanel.panelBackgroundColor.withMultipliedAlpha(0.5), displayPremiumBadgeIfAvailable: itemGroup.displayPremiumBadges, pointSize: itemNativeFitSize, - onUpdateDisplayPlaceholder: { [weak self] displayPlaceholder in + onUpdateDisplayPlaceholder: { [weak self] displayPlaceholder, duration in guard let strongSelf = self else { return } @@ -1442,7 +1414,7 @@ public final class EmojiPagerContentComponent: Component { size: itemNativeFitSize ) strongSelf.visibleItemPlaceholderViews[itemId] = placeholderView - strongSelf.scrollView.insertSubview(placeholderView, at: 0) + strongSelf.placeholdersContainerView.addSubview(placeholderView) } placeholderView.frame = itemLayer.frame placeholderView.update(size: placeholderView.bounds.size) @@ -1452,9 +1424,20 @@ public final class EmojiPagerContentComponent: Component { } else { if let placeholderView = strongSelf.visibleItemPlaceholderViews[itemId] { strongSelf.visibleItemPlaceholderViews.removeValue(forKey: itemId) - placeholderView.removeFromSuperview() - strongSelf.updateShimmerIfNeeded() + if duration > 0.0 { + placeholderView.layer.opacity = 0.0 + placeholderView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, completion: { [weak self, weak placeholderView] _ in + guard let strongSelf = self else { + return + } + placeholderView?.removeFromSuperview() + strongSelf.updateShimmerIfNeeded() + }) + } else { + placeholderView.removeFromSuperview() + strongSelf.updateShimmerIfNeeded() + } } } } @@ -1481,7 +1464,7 @@ public final class EmojiPagerContentComponent: Component { } } else if updateItemLayerPlaceholder { if itemLayer.displayPlaceholder { - itemLayer.onUpdateDisplayPlaceholder(true) + itemLayer.onUpdateDisplayPlaceholder(true, 0.0) } } @@ -1489,6 +1472,7 @@ public final class EmojiPagerContentComponent: Component { } } + var removedPlaceholerViews = false var removedIds: [ItemLayer.Key] = [] for (id, itemLayer) in self.visibleItemLayers { if !validIds.contains(id) { @@ -1501,6 +1485,7 @@ public final class EmojiPagerContentComponent: Component { if let view = self.visibleItemPlaceholderViews.removeValue(forKey: id) { view.removeFromSuperview() + removedPlaceholerViews = true } } @@ -1537,13 +1522,17 @@ public final class EmojiPagerContentComponent: Component { self.visibleGroupPremiumButtons.removeValue(forKey: id) } + if removedPlaceholerViews { + self.updateShimmerIfNeeded() + } + if let topVisibleGroupId = topVisibleGroupId { self.activeItemUpdated?.invoke((topVisibleGroupId, .immediate)) } } private func updateShimmerIfNeeded() { - if self.visibleItemPlaceholderViews.isEmpty { + if self.placeholdersContainerView.subviews.isEmpty { self.standaloneShimmerEffect.layer = nil } else { self.standaloneShimmerEffect.layer = self.shimmerHostView.layer @@ -1583,7 +1572,7 @@ public final class EmojiPagerContentComponent: Component { var itemTransition = transition - let itemLayout = ItemLayout(width: availableSize.width, containerInsets: UIEdgeInsets(top: pagerEnvironment.containerInsets.top + 9.0, left: pagerEnvironment.containerInsets.left + 12.0, bottom: 9.0 + pagerEnvironment.containerInsets.bottom, right: pagerEnvironment.containerInsets.right + 12.0), itemGroups: itemGroups, itemLayoutType: component.itemLayoutType, expandedPremiumGroups: expandedPremiumGroups) + let itemLayout = ItemLayout(width: availableSize.width, containerInsets: UIEdgeInsets(top: pagerEnvironment.containerInsets.top + 9.0, left: pagerEnvironment.containerInsets.left + 12.0, bottom: 9.0 + pagerEnvironment.containerInsets.bottom, right: pagerEnvironment.containerInsets.right + 12.0), itemGroups: itemGroups, itemLayoutType: component.itemLayoutType) if let previousItemLayout = self.itemLayout { if previousItemLayout.width != itemLayout.width { itemTransition = .immediate diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift index 33c1f5cbfc..75011510bb 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboard.swift @@ -76,8 +76,8 @@ public final class EntityKeyboardComponent: Component { public let theme: PresentationTheme public let bottomInset: CGFloat public let emojiContent: EmojiPagerContentComponent - public let stickerContent: EmojiPagerContentComponent - public let gifContent: GifPagerContentComponent + public let stickerContent: EmojiPagerContentComponent? + public let gifContent: GifPagerContentComponent? public let availableGifSearchEmojies: [GifSearchEmoji] public let defaultToEmojiTab: Bool public let externalTopPanelContainer: PagerExternalTopPanelContainer? @@ -85,7 +85,7 @@ public final class EntityKeyboardComponent: Component { public let hideInputUpdated: (Bool, Bool, Transition) -> Void public let switchToTextInput: () -> Void public let switchToGifSubject: (GifPagerContentComponent.Subject) -> Void - public let makeSearchContainerNode: (EntitySearchContentType) -> EntitySearchContainerNode + public let makeSearchContainerNode: (EntitySearchContentType) -> EntitySearchContainerNode? public let deviceMetrics: DeviceMetrics public let hiddenInputHeight: CGFloat public let isExpanded: Bool @@ -94,8 +94,8 @@ public final class EntityKeyboardComponent: Component { theme: PresentationTheme, bottomInset: CGFloat, emojiContent: EmojiPagerContentComponent, - stickerContent: EmojiPagerContentComponent, - gifContent: GifPagerContentComponent, + stickerContent: EmojiPagerContentComponent?, + gifContent: GifPagerContentComponent?, availableGifSearchEmojies: [GifSearchEmoji], defaultToEmojiTab: Bool, externalTopPanelContainer: PagerExternalTopPanelContainer?, @@ -103,7 +103,7 @@ public final class EntityKeyboardComponent: Component { hideInputUpdated: @escaping (Bool, Bool, Transition) -> Void, switchToTextInput: @escaping () -> Void, switchToGifSubject: @escaping (GifPagerContentComponent.Subject) -> Void, - makeSearchContainerNode: @escaping (EntitySearchContentType) -> EntitySearchContainerNode, + makeSearchContainerNode: @escaping (EntitySearchContentType) -> EntitySearchContainerNode?, deviceMetrics: DeviceMetrics, hiddenInputHeight: CGFloat, isExpanded: Bool @@ -201,170 +201,179 @@ public final class EntityKeyboardComponent: Component { var contentAccessoryRightButtons: [AnyComponentWithIdentity] = [] let gifsContentItemIdUpdated = ActionSlot<(AnyHashable, Transition)>() - contents.append(AnyComponentWithIdentity(id: "gifs", component: AnyComponent(component.gifContent))) - var topGifItems: [EntityKeyboardTopPanelComponent.Item] = [] - //TODO:localize - topGifItems.append(EntityKeyboardTopPanelComponent.Item( - id: "recent", - isReorderable: false, - content: AnyComponent(EntityKeyboardIconTopPanelComponent( - imageName: "Chat/Input/Media/RecentTabIcon", - theme: component.theme, - title: "Recent", - pressed: { [weak self] in - self?.component?.switchToGifSubject(.recent) - } - )) - )) - topGifItems.append(EntityKeyboardTopPanelComponent.Item( - id: "trending", - isReorderable: false, - content: AnyComponent(EntityKeyboardIconTopPanelComponent( - imageName: "Chat/Input/Media/TrendingGifs", - theme: component.theme, - title: "Trending", - pressed: { [weak self] in - self?.component?.switchToGifSubject(.trending) - } - )) - )) - for emoji in component.availableGifSearchEmojies { + let stickersContentItemIdUpdated = ActionSlot<(AnyHashable, Transition)>() + + if transition.userData(MarkInputCollapsed.self) != nil { + self.searchComponent = nil + } + + if let gifContent = component.gifContent { + contents.append(AnyComponentWithIdentity(id: "gifs", component: AnyComponent(gifContent))) + var topGifItems: [EntityKeyboardTopPanelComponent.Item] = [] + //TODO:localize topGifItems.append(EntityKeyboardTopPanelComponent.Item( - id: emoji.emoji, + id: "recent", isReorderable: false, - content: AnyComponent(EntityKeyboardAnimationTopPanelComponent( - context: component.stickerContent.context, - file: emoji.file, - animationCache: component.stickerContent.animationCache, - animationRenderer: component.stickerContent.animationRenderer, + content: AnyComponent(EntityKeyboardIconTopPanelComponent( + imageName: "Chat/Input/Media/RecentTabIcon", theme: component.theme, - title: emoji.title, + title: "Recent", pressed: { [weak self] in - self?.component?.switchToGifSubject(.emojiSearch(emoji.emoji)) + self?.component?.switchToGifSubject(.recent) } )) )) - } - let defaultActiveGifItemId: AnyHashable - switch component.gifContent.subject { - case .recent: - defaultActiveGifItemId = "recent" - case .trending: - defaultActiveGifItemId = "trending" - case let .emojiSearch(value): - defaultActiveGifItemId = AnyHashable(value) - } - contentTopPanels.append(AnyComponentWithIdentity(id: "gifs", component: AnyComponent(EntityKeyboardTopPanelComponent( - theme: component.theme, - items: topGifItems, - defaultActiveItemId: defaultActiveGifItemId, - activeContentItemIdUpdated: gifsContentItemIdUpdated, - reorderItems: { _ in + topGifItems.append(EntityKeyboardTopPanelComponent.Item( + id: "trending", + isReorderable: false, + content: AnyComponent(EntityKeyboardIconTopPanelComponent( + imageName: "Chat/Input/Media/TrendingGifs", + theme: component.theme, + title: "Trending", + pressed: { [weak self] in + self?.component?.switchToGifSubject(.trending) + } + )) + )) + for emoji in component.availableGifSearchEmojies { + topGifItems.append(EntityKeyboardTopPanelComponent.Item( + id: emoji.emoji, + isReorderable: false, + content: AnyComponent(EntityKeyboardAnimationTopPanelComponent( + context: component.emojiContent.context, + file: emoji.file, + animationCache: component.emojiContent.animationCache, + animationRenderer: component.emojiContent.animationRenderer, + theme: component.theme, + title: emoji.title, + pressed: { [weak self] in + self?.component?.switchToGifSubject(.emojiSearch(emoji.emoji)) + } + )) + )) } - )))) - contentIcons.append(AnyComponentWithIdentity(id: "gifs", component: AnyComponent(BundleIconComponent( - name: "Chat/Input/Media/EntityInputGifsIcon", - tintColor: component.theme.chat.inputMediaPanel.panelIconColor, - maxSize: nil - )))) - contentAccessoryLeftButtons.append(AnyComponentWithIdentity(id: "gifs", component: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Media/EntityInputSearchIcon", + let defaultActiveGifItemId: AnyHashable + switch gifContent.subject { + case .recent: + defaultActiveGifItemId = "recent" + case .trending: + defaultActiveGifItemId = "trending" + case let .emojiSearch(value): + defaultActiveGifItemId = AnyHashable(value) + } + contentTopPanels.append(AnyComponentWithIdentity(id: "gifs", component: AnyComponent(EntityKeyboardTopPanelComponent( + theme: component.theme, + items: topGifItems, + defaultActiveItemId: defaultActiveGifItemId, + activeContentItemIdUpdated: gifsContentItemIdUpdated, + reorderItems: { _ in + } + )))) + contentIcons.append(AnyComponentWithIdentity(id: "gifs", component: AnyComponent(BundleIconComponent( + name: "Chat/Input/Media/EntityInputGifsIcon", tintColor: component.theme.chat.inputMediaPanel.panelIconColor, maxSize: nil - )), - action: { [weak self] in - self?.openSearch() - } - ).minSize(CGSize(width: 38.0, height: 38.0))))) - - var topStickerItems: [EntityKeyboardTopPanelComponent.Item] = [] - for itemGroup in component.stickerContent.itemGroups { - if let id = itemGroup.supergroupId.base as? String { - let iconMapping: [String: String] = [ - "saved": "Chat/Input/Media/SavedStickersTabIcon", - "recent": "Chat/Input/Media/RecentTabIcon", - "premium": "Chat/Input/Media/PremiumIcon" - ] - let titleMapping: [String: String] = [ - "saved": "Saved", - "recent": "Recent", - "premium": "Premium" - ] - if let iconName = iconMapping[id], let title = titleMapping[id] { - topStickerItems.append(EntityKeyboardTopPanelComponent.Item( - id: itemGroup.supergroupId, - isReorderable: false, - content: AnyComponent(EntityKeyboardIconTopPanelComponent( - imageName: iconName, - theme: component.theme, - title: title, - pressed: { [weak self] in - self?.scrollToItemGroup(contentId: "stickers", groupId: itemGroup.supergroupId, subgroupId: nil) - } - )) - )) + )))) + contentAccessoryLeftButtons.append(AnyComponentWithIdentity(id: "gifs", component: AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Media/EntityInputSearchIcon", + tintColor: component.theme.chat.inputMediaPanel.panelIconColor, + maxSize: nil + )), + action: { [weak self] in + self?.openSearch() } - } else { - if !itemGroup.items.isEmpty { - if let file = itemGroup.items[0].file { + ).minSize(CGSize(width: 38.0, height: 38.0))))) + } + + if let stickerContent = component.stickerContent { + var topStickerItems: [EntityKeyboardTopPanelComponent.Item] = [] + for itemGroup in stickerContent.itemGroups { + if let id = itemGroup.supergroupId.base as? String { + let iconMapping: [String: String] = [ + "saved": "Chat/Input/Media/SavedStickersTabIcon", + "recent": "Chat/Input/Media/RecentTabIcon", + "premium": "Chat/Input/Media/PremiumIcon" + ] + let titleMapping: [String: String] = [ + "saved": "Saved", + "recent": "Recent", + "premium": "Premium" + ] + if let iconName = iconMapping[id], let title = titleMapping[id] { topStickerItems.append(EntityKeyboardTopPanelComponent.Item( id: itemGroup.supergroupId, - isReorderable: true, - content: AnyComponent(EntityKeyboardAnimationTopPanelComponent( - context: component.stickerContent.context, - file: file, - animationCache: component.stickerContent.animationCache, - animationRenderer: component.stickerContent.animationRenderer, + isReorderable: false, + content: AnyComponent(EntityKeyboardIconTopPanelComponent( + imageName: iconName, theme: component.theme, - title: itemGroup.title ?? "", + title: title, pressed: { [weak self] in self?.scrollToItemGroup(contentId: "stickers", groupId: itemGroup.supergroupId, subgroupId: nil) } )) )) } + } else { + if !itemGroup.items.isEmpty { + if let file = itemGroup.items[0].file { + topStickerItems.append(EntityKeyboardTopPanelComponent.Item( + id: itemGroup.supergroupId, + isReorderable: true, + content: AnyComponent(EntityKeyboardAnimationTopPanelComponent( + context: stickerContent.context, + file: file, + animationCache: stickerContent.animationCache, + animationRenderer: stickerContent.animationRenderer, + theme: component.theme, + title: itemGroup.title ?? "", + pressed: { [weak self] in + self?.scrollToItemGroup(contentId: "stickers", groupId: itemGroup.supergroupId, subgroupId: nil) + } + )) + )) + } + } } } + contents.append(AnyComponentWithIdentity(id: "stickers", component: AnyComponent(stickerContent))) + contentTopPanels.append(AnyComponentWithIdentity(id: "stickers", component: AnyComponent(EntityKeyboardTopPanelComponent( + theme: component.theme, + items: topStickerItems, + activeContentItemIdUpdated: stickersContentItemIdUpdated, + reorderItems: { [weak self] items in + guard let strongSelf = self else { + return + } + strongSelf.reorderPacks(category: .stickers, items: items) + } + )))) + contentIcons.append(AnyComponentWithIdentity(id: "stickers", component: AnyComponent(BundleIconComponent( + name: "Chat/Input/Media/EntityInputStickersIcon", + tintColor: component.theme.chat.inputMediaPanel.panelIconColor, + maxSize: nil + )))) + contentAccessoryLeftButtons.append(AnyComponentWithIdentity(id: "stickers", component: AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Media/EntityInputSearchIcon", + tintColor: component.theme.chat.inputMediaPanel.panelIconColor, + maxSize: nil + )), + action: { [weak self] in + self?.openSearch() + } + ).minSize(CGSize(width: 38.0, height: 38.0))))) + contentAccessoryRightButtons.append(AnyComponentWithIdentity(id: "stickers", component: AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Media/EntityInputSettingsIcon", + tintColor: component.theme.chat.inputMediaPanel.panelIconColor, + maxSize: nil + )), + action: { + stickerContent.inputInteraction.openStickerSettings() + } + ).minSize(CGSize(width: 38.0, height: 38.0))))) } - let stickersContentItemIdUpdated = ActionSlot<(AnyHashable, Transition)>() - contents.append(AnyComponentWithIdentity(id: "stickers", component: AnyComponent(component.stickerContent))) - contentTopPanels.append(AnyComponentWithIdentity(id: "stickers", component: AnyComponent(EntityKeyboardTopPanelComponent( - theme: component.theme, - items: topStickerItems, - activeContentItemIdUpdated: stickersContentItemIdUpdated, - reorderItems: { [weak self] items in - guard let strongSelf = self else { - return - } - strongSelf.reorderPacks(category: .stickers, items: items) - } - )))) - contentIcons.append(AnyComponentWithIdentity(id: "stickers", component: AnyComponent(BundleIconComponent( - name: "Chat/Input/Media/EntityInputStickersIcon", - tintColor: component.theme.chat.inputMediaPanel.panelIconColor, - maxSize: nil - )))) - contentAccessoryLeftButtons.append(AnyComponentWithIdentity(id: "stickers", component: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Media/EntityInputSearchIcon", - tintColor: component.theme.chat.inputMediaPanel.panelIconColor, - maxSize: nil - )), - action: { [weak self] in - self?.openSearch() - } - ).minSize(CGSize(width: 38.0, height: 38.0))))) - contentAccessoryRightButtons.append(AnyComponentWithIdentity(id: "stickers", component: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Media/EntityInputSettingsIcon", - tintColor: component.theme.chat.inputMediaPanel.panelIconColor, - maxSize: nil - )), - action: { - component.stickerContent.inputInteraction.openStickerSettings() - } - ).minSize(CGSize(width: 38.0, height: 38.0))))) let emojiContentItemIdUpdated = ActionSlot<(AnyHashable, Transition)>() contents.append(AnyComponentWithIdentity(id: "emoji", component: AnyComponent(component.emojiContent))) @@ -477,7 +486,8 @@ public final class EntityKeyboardComponent: Component { )), topPanel: AnyComponent(EntityKeyboardTopContainerPanelComponent( theme: component.theme, - overflowHeight: component.hiddenInputHeight + overflowHeight: component.hiddenInputHeight, + displayBackground: component.externalTopPanelContainer == nil )), externalTopPanelContainer: component.externalTopPanelContainer, bottomPanel: AnyComponent(EntityKeyboardBottomPanelComponent( @@ -521,10 +531,6 @@ public final class EntityKeyboardComponent: Component { ) transition.setFrame(view: self.pagerView, frame: CGRect(origin: CGPoint(), size: pagerSize)) - if transition.userData(MarkInputCollapsed.self) != nil { - self.searchComponent = nil - } - if let searchComponent = self.searchComponent { var animateIn = false let searchView: ComponentHostView @@ -546,7 +552,7 @@ public final class EntityKeyboardComponent: Component { component: AnyComponent(searchComponent), environment: { EntitySearchContentEnvironment( - context: component.stickerContent.context, + context: component.emojiContent.context, theme: component.theme, deviceMetrics: component.deviceMetrics ) @@ -669,7 +675,7 @@ public final class EntityKeyboardComponent: Component { case .emoji: namespace = Namespaces.ItemCollection.CloudEmojiPacks } - let _ = (component.stickerContent.context.engine.stickers.reorderStickerPacks(namespace: namespace, itemIds: currentIds) + let _ = (component.emojiContent.context.engine.stickers.reorderStickerPacks(namespace: namespace, itemIds: currentIds) |> deliverOnMainQueue).start(completed: { [weak self] in guard let strongSelf = self else { return diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardBottomPanelComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardBottomPanelComponent.swift index ff57b95ec9..a1925f41d8 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardBottomPanelComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardBottomPanelComponent.swift @@ -278,56 +278,64 @@ final class EntityKeyboardBottomPanelComponent: Component { let navigateToContentId = panelEnvironment.navigateToContentId - for icon in panelEnvironment.contentIcons { - validIconIds.append(icon.id) - - var iconTransition = transition - let iconView: ComponentHostView - if let current = self.iconViews[icon.id] { - iconView = current - } else { - iconTransition = .immediate - iconView = ComponentHostView() - self.iconViews[icon.id] = iconView - self.addSubview(iconView) + if panelEnvironment.contentIcons.count > 1 { + for icon in panelEnvironment.contentIcons { + validIconIds.append(icon.id) + + var iconTransition = transition + let iconView: ComponentHostView + if let current = self.iconViews[icon.id] { + iconView = current + } else { + iconTransition = .immediate + iconView = ComponentHostView() + self.iconViews[icon.id] = iconView + self.addSubview(iconView) + } + + let iconSize = iconView.update( + transition: iconTransition, + component: AnyComponent(BottomPanelIconComponent( + content: icon.component, + action: { + navigateToContentId(icon.id) + } + )), + environment: {}, + containerSize: CGSize(width: 32.0, height: 32.0) + ) + + iconInfos[icon.id] = (size: iconSize, transition: iconTransition) + + if !iconTotalSize.width.isZero { + iconTotalSize.width += iconSpacing + } + iconTotalSize.width += iconSize.width + iconTotalSize.height = max(iconTotalSize.height, iconSize.height) } - - let iconSize = iconView.update( - transition: iconTransition, - component: AnyComponent(BottomPanelIconComponent( - content: icon.component, - action: { - navigateToContentId(icon.id) - } - )), - environment: {}, - containerSize: CGSize(width: 32.0, height: 32.0) - ) - - iconInfos[icon.id] = (size: iconSize, transition: iconTransition) - - if !iconTotalSize.width.isZero { - iconTotalSize.width += iconSpacing - } - iconTotalSize.width += iconSize.width - iconTotalSize.height = max(iconTotalSize.height, iconSize.height) } - var nextIconOrigin = CGPoint(x: floor((availableSize.width - iconTotalSize.width) / 2.0), y: floor((intrinsicHeight - iconTotalSize.height) / 2.0) + 2.0) - for icon in panelEnvironment.contentIcons { - guard let iconInfo = iconInfos[icon.id], let iconView = self.iconViews[icon.id] else { - continue + var nextIconOrigin = CGPoint(x: floor((availableSize.width - iconTotalSize.width) / 2.0), y: floor((intrinsicHeight - iconTotalSize.height) / 2.0)) + if component.bottomInset > 0.0 { + nextIconOrigin.y += 2.0 + } + + if panelEnvironment.contentIcons.count > 1 { + for icon in panelEnvironment.contentIcons { + guard let iconInfo = iconInfos[icon.id], let iconView = self.iconViews[icon.id] else { + continue + } + + let iconFrame = CGRect(origin: nextIconOrigin, size: iconInfo.size) + iconInfo.transition.setFrame(view: iconView, frame: iconFrame, completion: nil) + + if let activeContentId = activeContentId, activeContentId == icon.id { + self.highlightedIconBackgroundView.isHidden = false + transition.setFrame(view: self.highlightedIconBackgroundView, frame: iconFrame) + } + + nextIconOrigin.x += iconInfo.size.width + iconSpacing } - - let iconFrame = CGRect(origin: nextIconOrigin, size: iconInfo.size) - iconInfo.transition.setFrame(view: iconView, frame: iconFrame, completion: nil) - - if let activeContentId = activeContentId, activeContentId == icon.id { - self.highlightedIconBackgroundView.isHidden = false - transition.setFrame(view: self.highlightedIconBackgroundView, frame: iconFrame) - } - - nextIconOrigin.x += iconInfo.size.width + iconSpacing } if activeContentId == nil { diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopContainerPanelComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopContainerPanelComponent.swift index ba093de32b..54bacc37aa 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopContainerPanelComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopContainerPanelComponent.swift @@ -32,13 +32,16 @@ final class EntityKeyboardTopContainerPanelComponent: Component { let theme: PresentationTheme let overflowHeight: CGFloat + let displayBackground: Bool init( theme: PresentationTheme, - overflowHeight: CGFloat + overflowHeight: CGFloat, + displayBackground: Bool ) { self.theme = theme self.overflowHeight = overflowHeight + self.displayBackground = displayBackground } static func ==(lhs: EntityKeyboardTopContainerPanelComponent, rhs: EntityKeyboardTopContainerPanelComponent) -> Bool { @@ -48,6 +51,9 @@ final class EntityKeyboardTopContainerPanelComponent: Component { if lhs.overflowHeight != rhs.overflowHeight { return false } + if lhs.displayBackground != rhs.displayBackground { + return false + } return true } @@ -59,6 +65,9 @@ final class EntityKeyboardTopContainerPanelComponent: Component { } final class View: UIView { + private var backgroundView: BlurredBackgroundView? + private var backgroundSeparatorView: UIView? + private var panelViews: [AnyHashable: PanelView] = [:] private var component: EntityKeyboardTopContainerPanelComponent? @@ -183,6 +192,40 @@ final class EntityKeyboardTopContainerPanelComponent: Component { strongSelf.updateVisibilityFraction(value: fraction, transition: transition) } + if component.displayBackground { + let backgroundView: BlurredBackgroundView + if let current = self.backgroundView { + backgroundView = current + } else { + backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) + self.insertSubview(backgroundView, at: 0) + } + + let backgroundSeparatorView: UIView + if let current = self.backgroundSeparatorView { + backgroundSeparatorView = current + } else { + backgroundSeparatorView = UIView() + self.insertSubview(backgroundSeparatorView, aboveSubview: backgroundView) + } + + backgroundView.updateColor(color: component.theme.chat.inputPanel.panelBackgroundColor.withMultipliedAlpha(1.0), transition: .immediate) + backgroundView.update(size: CGSize(width: availableSize.width, height: height), transition: transition.containedViewLayoutTransition) + transition.setFrame(view: backgroundView, frame: CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: height))) + + backgroundSeparatorView.backgroundColor = component.theme.chat.inputPanel.panelSeparatorColor + transition.setFrame(view: backgroundSeparatorView, frame: CGRect(origin: CGPoint(x: 0.0, y: height), size: CGSize(width: availableSize.width, height: UIScreenPixel))) + } else { + if let backgroundView = self.backgroundView { + self.backgroundView = nil + backgroundView.removeFromSuperview() + } + if let backgroundSeparatorView = self.backgroundSeparatorView { + self.backgroundSeparatorView = nil + backgroundSeparatorView.removeFromSuperview() + } + } + return CGSize(width: availableSize.width, height: height) } diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift index c8ac47cfe5..7e2fe8382a 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntityKeyboardTopPanelComponent.swift @@ -100,7 +100,6 @@ final class EntityKeyboardAnimationTopPanelComponent: Component { subgroupId: nil ), context: component.context, - groupId: "topPanel", attemptSynchronousLoad: false, file: component.file, staticEmoji: nil, @@ -110,18 +109,18 @@ final class EntityKeyboardAnimationTopPanelComponent: Component { blurredBadgeColor: .clear, displayPremiumBadgeIfAvailable: false, pointSize: CGSize(width: 44.0, height: 44.0), - onUpdateDisplayPlaceholder: { [weak self] displayPlaceholder in + onUpdateDisplayPlaceholder: { [weak self] displayPlaceholder, duration in guard let strongSelf = self else { return } - strongSelf.updateDisplayPlaceholder(displayPlaceholder: displayPlaceholder) + strongSelf.updateDisplayPlaceholder(displayPlaceholder: displayPlaceholder, duration: duration) } ) self.itemLayer = itemLayer self.layer.addSublayer(itemLayer) if itemLayer.displayPlaceholder { - self.updateDisplayPlaceholder(displayPlaceholder: true) + self.updateDisplayPlaceholder(displayPlaceholder: true, duration: 0.0) } } @@ -170,7 +169,7 @@ final class EntityKeyboardAnimationTopPanelComponent: Component { return availableSize } - private func updateDisplayPlaceholder(displayPlaceholder: Bool) { + private func updateDisplayPlaceholder(displayPlaceholder: Bool, duration: Double) { if displayPlaceholder { if self.placeholderView == nil, let component = self.component { let placeholderView = EmojiPagerContentComponent.View.ItemPlaceholderView( @@ -188,7 +187,15 @@ final class EntityKeyboardAnimationTopPanelComponent: Component { } else { if let placeholderView = self.placeholderView { self.placeholderView = nil - placeholderView.removeFromSuperview() + + if duration > 0.0 { + placeholderView.alpha = 0.0 + placeholderView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, completion: { [weak placeholderView] _ in + placeholderView?.removeFromSuperview() + }) + } else { + placeholderView.removeFromSuperview() + } } } } diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntitySearchContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntitySearchContentComponent.swift index a6febf912f..6424d92c18 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntitySearchContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EntitySearchContentComponent.swift @@ -51,11 +51,11 @@ final class EntitySearchContentEnvironment: Equatable { final class EntitySearchContentComponent: Component { typealias EnvironmentType = EntitySearchContentEnvironment - let makeContainerNode: () -> EntitySearchContainerNode + let makeContainerNode: () -> EntitySearchContainerNode? let dismissSearch: () -> Void init( - makeContainerNode: @escaping () -> EntitySearchContainerNode, + makeContainerNode: @escaping () -> EntitySearchContainerNode?, dismissSearch: @escaping () -> Void ) { self.makeContainerNode = makeContainerNode @@ -78,30 +78,34 @@ final class EntitySearchContentComponent: Component { } func update(component: EntitySearchContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - let containerNode: EntitySearchContainerNode + let containerNode: EntitySearchContainerNode? if let current = self.containerNode { containerNode = current } else { containerNode = component.makeContainerNode() - self.containerNode = containerNode - self.addSubnode(containerNode) + if let containerNode = containerNode { + self.containerNode = containerNode + self.addSubnode(containerNode) + } } + if let containerNode = containerNode { + let environmentValue = environment[EntitySearchContentEnvironment.self].value - - transition.setFrame(view: containerNode.view, frame: CGRect(origin: CGPoint(), size: availableSize)) - containerNode.updateLayout( - size: availableSize, - leftInset: 0.0, - rightInset: 0.0, - bottomInset: 0.0, - inputHeight: 0.0, - deviceMetrics: environmentValue.deviceMetrics, - transition: transition.containedViewLayoutTransition - ) - - containerNode.onCancel = { - component.dismissSearch() + transition.setFrame(view: containerNode.view, frame: CGRect(origin: CGPoint(), size: availableSize)) + containerNode.updateLayout( + size: availableSize, + leftInset: 0.0, + rightInset: 0.0, + bottomInset: 0.0, + inputHeight: 0.0, + deviceMetrics: environmentValue.deviceMetrics, + transition: transition.containedViewLayoutTransition + ) + + containerNode.onCancel = { + component.dismissSearch() + } } return availableSize diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift index b18ac64c11..d0caa97fcf 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift @@ -19,10 +19,11 @@ import SoftwareVideo import AVFoundation import PhotoResources import ContextUI +import ShimmerEffect private class GifVideoLayer: AVSampleBufferDisplayLayer { private let context: AccountContext - private let file: TelegramMediaFile + private let file: TelegramMediaFile? private var frameManager: SoftwareVideoLayerFrameManager? @@ -56,7 +57,7 @@ private class GifVideoLayer: AVSampleBufferDisplayLayer { } } - init(context: AccountContext, file: TelegramMediaFile, synchronousLoad: Bool) { + init(context: AccountContext, file: TelegramMediaFile?, synchronousLoad: Bool) { self.context = context self.file = file @@ -64,29 +65,31 @@ private class GifVideoLayer: AVSampleBufferDisplayLayer { self.videoGravity = .resizeAspectFill - if let dimensions = file.dimensions { - self.thumbnailDisposable = (mediaGridMessageVideo(postbox: context.account.postbox, videoReference: .savedGif(media: self.file), synchronousLoad: synchronousLoad, nilForEmptyResult: true) - |> deliverOnMainQueue).start(next: { [weak self] transform in - guard let strongSelf = self else { - return - } - let boundingSize = CGSize(width: 93.0, height: 93.0) - let imageSize = dimensions.cgSize.aspectFilled(boundingSize) - - if let image = transform(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets(), resizeMode: .fill(.clear)))?.generateImage() { - Queue.mainQueue().async { - if let strongSelf = self { - strongSelf.contents = image.cgImage - strongSelf.setupVideo() - strongSelf.started?() - } + if let file = self.file { + if let dimensions = file.dimensions { + self.thumbnailDisposable = (mediaGridMessageVideo(postbox: context.account.postbox, videoReference: .savedGif(media: file), synchronousLoad: synchronousLoad, nilForEmptyResult: true) + |> deliverOnMainQueue).start(next: { [weak self] transform in + guard let strongSelf = self else { + return } - } else { - strongSelf.setupVideo() - } - }) - } else { - self.setupVideo() + let boundingSize = CGSize(width: 93.0, height: 93.0) + let imageSize = dimensions.cgSize.aspectFilled(boundingSize) + + if let image = transform(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets(), resizeMode: .fill(.clear)))?.generateImage() { + Queue.mainQueue().async { + if let strongSelf = self { + strongSelf.contents = image.cgImage + strongSelf.setupVideo() + strongSelf.started?() + } + } + } else { + strongSelf.setupVideo() + } + }) + } else { + self.setupVideo() + } } } @@ -103,7 +106,10 @@ private class GifVideoLayer: AVSampleBufferDisplayLayer { } private func setupVideo() { - let frameManager = SoftwareVideoLayerFrameManager(account: self.context.account, fileReference: .savedGif(media: self.file), layerHolder: nil, layer: self) + guard let file = self.file else { + return + } + let frameManager = SoftwareVideoLayerFrameManager(account: self.context.account, fileReference: .savedGif(media: file), layerHolder: nil, layer: self) self.frameManager = frameManager frameManager.started = { [weak self] in guard let strongSelf = self else { @@ -127,13 +133,16 @@ public final class GifPagerContentComponent: Component { public final class InputInteraction { public let performItemAction: (Item, UIView, CGRect) -> Void public let openGifContextMenu: (TelegramMediaFile, UIView, CGRect, ContextGesture, Bool) -> Void + public let loadMore: (String) -> Void public init( performItemAction: @escaping (Item, UIView, CGRect) -> Void, - openGifContextMenu: @escaping (TelegramMediaFile, UIView, CGRect, ContextGesture, Bool) -> Void + openGifContextMenu: @escaping (TelegramMediaFile, UIView, CGRect, ContextGesture, Bool) -> Void, + loadMore: @escaping (String) -> Void ) { self.performItemAction = performItemAction self.openGifContextMenu = openGifContextMenu + self.loadMore = loadMore } } @@ -160,17 +169,23 @@ public final class GifPagerContentComponent: Component { public let inputInteraction: InputInteraction public let subject: Subject public let items: [Item] + public let isLoading: Bool + public let loadMoreToken: String? public init( context: AccountContext, inputInteraction: InputInteraction, subject: Subject, - items: [Item] + items: [Item], + isLoading: Bool, + loadMoreToken: String? ) { self.context = context self.inputInteraction = inputInteraction self.subject = subject self.items = items + self.isLoading = isLoading + self.loadMoreToken = loadMoreToken } public static func ==(lhs: GifPagerContentComponent, rhs: GifPagerContentComponent) -> Bool { @@ -186,6 +201,12 @@ public final class GifPagerContentComponent: Component { if lhs.items != rhs.items { return false } + if lhs.isLoading != rhs.isLoading { + return false + } + if lhs.loadMoreToken != rhs.loadMoreToken { + return false + } return true } @@ -256,7 +277,7 @@ public final class GifPagerContentComponent: Component { let maxVisibleRow = Int(ceil((offsetRect.maxY - self.verticalSpacing) / (self.itemSize + self.verticalSpacing))) let minVisibleIndex = minVisibleRow * self.itemsPerRow - let maxVisibleIndex = min(self.itemCount - 1, (maxVisibleRow + 1) * self.itemsPerRow - 1) + let maxVisibleIndex = (maxVisibleRow + 1) * self.itemsPerRow - 1 if maxVisibleIndex >= minVisibleIndex { return minVisibleIndex ..< (maxVisibleIndex + 1) @@ -266,11 +287,14 @@ public final class GifPagerContentComponent: Component { } } + fileprivate enum ItemKey: Hashable { + case media(MediaId) + case placeholder(Int) + } + fileprivate final class ItemLayer: GifVideoLayer { - let item: Item + let item: Item? - private let file: TelegramMediaFile - private let placeholderColor: UIColor private var disposable: Disposable? private var fetchDisposable: Disposable? @@ -282,60 +306,29 @@ public final class GifPagerContentComponent: Component { } } } - private var displayPlaceholder: Bool = false + private(set) var displayPlaceholder: Bool = false + let onUpdateDisplayPlaceholder: (Bool, Double) -> Void init( - item: Item, + item: Item?, context: AccountContext, groupId: String, attemptSynchronousLoad: Bool, - file: TelegramMediaFile, - placeholderColor: UIColor + onUpdateDisplayPlaceholder: @escaping (Bool, Double) -> Void ) { self.item = item - self.file = file - self.placeholderColor = placeholderColor + self.onUpdateDisplayPlaceholder = onUpdateDisplayPlaceholder - super.init(context: context, file: file, synchronousLoad: attemptSynchronousLoad) + super.init(context: context, file: item?.file, synchronousLoad: attemptSynchronousLoad) - self.updateDisplayPlaceholder(displayPlaceholder: true) + if item == nil { + self.updateDisplayPlaceholder(displayPlaceholder: true, duration: 0.0) + } self.started = { [weak self] in - self?.updateDisplayPlaceholder(displayPlaceholder: false) + let _ = self + //self?.updateDisplayPlaceholder(displayPlaceholder: false, duration: 0.2) } - - /*if attemptSynchronousLoad { - if !renderer.loadFirstFrameSynchronously(groupId: groupId, target: self, cache: cache, itemId: file.resource.id.stringRepresentation, size: pixelSize) { - self.displayPlaceholder = true - - if let image = generateStickerPlaceholderImage(data: file.immediateThumbnailData, size: self.size, imageSize: file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0), backgroundColor: nil, foregroundColor: placeholderColor) { - self.contents = image.cgImage - } - } - } - - self.disposable = renderer.add(groupId: groupId, target: self, cache: cache, itemId: file.resource.id.stringRepresentation, size: pixelSize, fetch: { size, writer in - let source = AnimatedStickerResourceSource(account: context.account, resource: file.resource, fitzModifier: nil, isVideo: false) - - let dataDisposable = source.directDataPath(attemptSynchronously: false).start(next: { result in - guard let result = result else { - return - } - - guard let data = try? Data(contentsOf: URL(fileURLWithPath: result)) else { - writer.finish() - return - } - cacheLottieAnimation(data: data, width: Int(size.width), height: Int(size.height), writer: writer) - }) - - let fetchDisposable = freeMediaFileInteractiveFetched(account: context.account, fileReference: .standalone(media: file)).start() - - return ActionDisposable { - dataDisposable.dispose() - fetchDisposable.dispose() - } - })*/ } required init?(coder: NSCoder) { @@ -363,18 +356,41 @@ public final class GifPagerContentComponent: Component { self.shouldBeAnimating = shouldBePlaying } - func updateDisplayPlaceholder(displayPlaceholder: Bool) { + func updateDisplayPlaceholder(displayPlaceholder: Bool, duration: Double) { if self.displayPlaceholder == displayPlaceholder { return } - self.displayPlaceholder = displayPlaceholder + self.onUpdateDisplayPlaceholder(displayPlaceholder, duration) + } + } + + final class ItemPlaceholderView: UIView { + private let shimmerView: PortalSourceView? + private var placeholderView: PortalView? + + init(shimmerView: PortalSourceView?) { + self.shimmerView = shimmerView + self.placeholderView = PortalView() - if displayPlaceholder { - let placeholderColor = self.placeholderColor - self.backgroundColor = placeholderColor.cgColor - } else { - self.backgroundColor = nil + super.init(frame: CGRect()) + + self.clipsToBounds = true + + if let placeholderView = self.placeholderView, let shimmerView = self.shimmerView { + placeholderView.view.clipsToBounds = true + self.addSubview(placeholderView.view) + shimmerView.addPortal(view: placeholderView) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(size: CGSize) { + if let placeholderView = self.placeholderView { + placeholderView.view.frame = CGRect(origin: CGPoint(), size: size) } } } @@ -382,9 +398,14 @@ public final class GifPagerContentComponent: Component { private final class ContentScrollView: UIScrollView, PagerExpandableScrollView { } + private let shimmerHostView: PortalSourceView + private let standaloneShimmerEffect: StandaloneShimmerEffect + private let scrollView: ContentScrollView - private var visibleItemLayers: [MediaId: ItemLayer] = [:] + private let placeholdersContainerView: UIView + private var visibleItemPlaceholderViews: [ItemKey: ItemPlaceholderView] = [:] + private var visibleItemLayers: [ItemKey: ItemLayer] = [:] private var ignoreScrolling: Bool = false private var component: GifPagerContentComponent? @@ -392,11 +413,21 @@ public final class GifPagerContentComponent: Component { private var theme: PresentationTheme? private var itemLayout: ItemLayout? + private var currentLoadMoreToken: String? + override init(frame: CGRect) { + self.shimmerHostView = PortalSourceView() + self.standaloneShimmerEffect = StandaloneShimmerEffect() + + self.placeholdersContainerView = UIView() + self.scrollView = ContentScrollView() super.init(frame: frame) + self.shimmerHostView.alpha = 0.0 + self.addSubview(self.shimmerHostView) + self.scrollView.delaysContentTouches = false if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { self.scrollView.contentInsetAdjustmentBehavior = .never @@ -409,6 +440,8 @@ public final class GifPagerContentComponent: Component { self.scrollView.delegate = self self.addSubview(self.scrollView) + self.scrollView.addSubview(self.placeholdersContainerView) + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) self.useSublayerTransformForActivation = false @@ -450,7 +483,7 @@ public final class GifPagerContentComponent: Component { @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { - if let component = self.component, let item = self.item(atPoint: recognizer.location(in: self)), let itemView = self.visibleItemLayers[item.file.fileId] { + if let component = self.component, let item = self.item(atPoint: recognizer.location(in: self)), let itemView = self.visibleItemLayers[.media(item.file.fileId)] { component.inputInteraction.performItemAction(item, self, self.scrollView.convert(itemView.frame, to: self)) } } @@ -473,7 +506,11 @@ public final class GifPagerContentComponent: Component { for (_, itemLayer) in self.visibleItemLayers { if itemLayer.frame.contains(localPoint) { - return (itemLayer.item, itemLayer) + if let item = itemLayer.item { + return (item, itemLayer) + } else { + return nil + } } } @@ -497,6 +534,13 @@ public final class GifPagerContentComponent: Component { self.updateVisibleItems(attemptSynchronousLoads: false) self.updateScrollingOffset(transition: .immediate) + + if scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.bounds.height - 100.0 { + if let component = self.component, let loadMoreToken = component.loadMoreToken, self.currentLoadMoreToken != loadMoreToken { + self.currentLoadMoreToken = loadMoreToken + component.inputInteraction.loadMore(loadMoreToken) + } + } } public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { @@ -564,44 +608,125 @@ public final class GifPagerContentComponent: Component { } private func updateVisibleItems(attemptSynchronousLoads: Bool) { - guard let component = self.component, let theme = self.theme, let itemLayout = self.itemLayout else { + guard let component = self.component, let itemLayout = self.itemLayout else { return } - var validIds = Set() + var validIds = Set() if let itemRange = itemLayout.visibleItems(for: self.scrollView.bounds) { for index in itemRange.lowerBound ..< itemRange.upperBound { - let item = component.items[index] - let itemId = item.file.fileId + var item: Item? + let itemId: ItemKey + if index < component.items.count { + item = component.items[index] + itemId = .media(component.items[index].file.fileId) + } else if component.isLoading || component.loadMoreToken != nil { + itemId = .placeholder(index) + } else { + continue + } + + if !component.isLoading { + if let placeholderView = self.visibleItemPlaceholderViews.removeValue(forKey: .placeholder(index)) { + self.visibleItemPlaceholderViews[itemId] = placeholderView + } + } + validIds.insert(itemId) + let itemFrame = itemLayout.frame(at: index) + + let itemTransition: Transition = .immediate + var updateItemLayerPlaceholder = false + let itemLayer: ItemLayer if let current = self.visibleItemLayers[itemId] { itemLayer = current } else { + updateItemLayerPlaceholder = true + itemLayer = ItemLayer( item: item, context: component.context, groupId: "savedGif", attemptSynchronousLoad: attemptSynchronousLoads, - file: item.file, - placeholderColor: theme.chat.inputMediaPanel.stickersBackgroundColor + onUpdateDisplayPlaceholder: { [weak self] displayPlaceholder, duration in + guard let strongSelf = self else { + return + } + if displayPlaceholder { + if let itemLayer = strongSelf.visibleItemLayers[itemId] { + let placeholderView: ItemPlaceholderView + if let current = strongSelf.visibleItemPlaceholderViews[itemId] { + placeholderView = current + } else { + placeholderView = ItemPlaceholderView(shimmerView: strongSelf.shimmerHostView) + strongSelf.visibleItemPlaceholderViews[itemId] = placeholderView + strongSelf.placeholdersContainerView.addSubview(placeholderView) + } + placeholderView.frame = itemLayer.frame + placeholderView.update(size: placeholderView.bounds.size) + + strongSelf.updateShimmerIfNeeded() + } + } else { + if let placeholderView = strongSelf.visibleItemPlaceholderViews[itemId] { + strongSelf.visibleItemPlaceholderViews.removeValue(forKey: itemId) + if duration > 0.0 { + if let itemLayer = strongSelf.visibleItemLayers[itemId] { + itemLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18) + } + + placeholderView.alpha = 0.0 + placeholderView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, completion: { [weak self, weak placeholderView] _ in + placeholderView?.removeFromSuperview() + self?.updateShimmerIfNeeded() + }) + } else { + placeholderView.removeFromSuperview() + strongSelf.updateShimmerIfNeeded() + } + } + } + } ) self.scrollView.layer.addSublayer(itemLayer) self.visibleItemLayers[itemId] = itemLayer } - itemLayer.frame = itemLayout.frame(at: index) + let itemPosition = CGPoint(x: itemFrame.midX, y: itemFrame.midY) + let itemBounds = CGRect(origin: CGPoint(), size: itemFrame.size) + + itemTransition.setFrame(layer: itemLayer, frame: itemFrame) itemLayer.isVisibleForAnimations = true + + if let placeholderView = self.visibleItemPlaceholderViews[itemId] { + if placeholderView.layer.position != itemPosition || placeholderView.layer.bounds != itemBounds { + itemTransition.setFrame(view: placeholderView, frame: itemFrame) + placeholderView.update(size: itemFrame.size) + } + } + + if updateItemLayerPlaceholder { + if itemLayer.displayPlaceholder { + itemLayer.onUpdateDisplayPlaceholder(true, 0.0) + } else { + itemLayer.onUpdateDisplayPlaceholder(false, 0.2) + } + } } } - var removedIds: [MediaId] = [] + var removedIds: [ItemKey] = [] for (id, itemLayer) in self.visibleItemLayers { if !validIds.contains(id) { removedIds.append(id) itemLayer.removeFromSuperlayer() + + if let view = self.visibleItemPlaceholderViews.removeValue(forKey: id) { + view.removeFromSuperview() + } } } for id in removedIds { @@ -609,13 +734,35 @@ public final class GifPagerContentComponent: Component { } } + private func updateShimmerIfNeeded() { + if self.placeholdersContainerView.subviews.isEmpty { + self.standaloneShimmerEffect.layer = nil + } else { + self.standaloneShimmerEffect.layer = self.shimmerHostView.layer + } + } + func update(component: GifPagerContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + var contentReset = false + if let previousComponent = self.component, previousComponent.subject != component.subject { + contentReset = true + self.currentLoadMoreToken = nil + } + + let keyboardChildEnvironment = environment[EntityKeyboardChildEnvironment.self].value + self.component = component - self.theme = environment[EntityKeyboardChildEnvironment.self].value.theme + self.theme = keyboardChildEnvironment.theme let pagerEnvironment = environment[PagerComponentChildEnvironment.self].value self.pagerEnvironment = pagerEnvironment + transition.setFrame(view: self.shimmerHostView, frame: CGRect(origin: CGPoint(), size: availableSize)) + + let shimmerBackgroundColor = keyboardChildEnvironment.theme.chat.inputPanel.primaryTextColor.withMultipliedAlpha(0.08) + let shimmerForegroundColor = keyboardChildEnvironment.theme.list.itemBlocksBackgroundColor.withMultipliedAlpha(0.15) + self.standaloneShimmerEffect.update(background: shimmerBackgroundColor, foreground: shimmerForegroundColor) + let itemLayout = ItemLayout( width: availableSize.width, containerInsets: UIEdgeInsets(top: pagerEnvironment.containerInsets.top, left: pagerEnvironment.containerInsets.left, bottom: pagerEnvironment.containerInsets.bottom, right: pagerEnvironment.containerInsets.right), @@ -631,6 +778,11 @@ public final class GifPagerContentComponent: Component { if self.scrollView.scrollIndicatorInsets != pagerEnvironment.containerInsets { self.scrollView.scrollIndicatorInsets = pagerEnvironment.containerInsets } + + if contentReset { + self.scrollView.setContentOffset(CGPoint(), animated: false) + } + self.previousScrollingOffset = self.scrollView.contentOffset.y self.ignoreScrolling = false diff --git a/submodules/TelegramUI/Components/MultiAnimationRenderer/Resources/MultiAnimationRendererShaders.metal b/submodules/TelegramUI/Components/MultiAnimationRenderer/Resources/MultiAnimationRendererShaders.metal index 35a1806d35..8343f753f0 100644 --- a/submodules/TelegramUI/Components/MultiAnimationRenderer/Resources/MultiAnimationRendererShaders.metal +++ b/submodules/TelegramUI/Components/MultiAnimationRenderer/Resources/MultiAnimationRendererShaders.metal @@ -21,8 +21,6 @@ vertex Varyings multiAnimationVertex( Varyings out; constant Vertex &v = verticies[vid]; - - out.position = float4(float2(v.position), 0.0, 1.0); out.texCoord = v.texCoord; @@ -31,8 +29,18 @@ vertex Varyings multiAnimationVertex( fragment half4 multiAnimationFragment( Varyings in[[stage_in]], - texture2d texture[[texture(0)]] + texture2d textureY[[texture(0)]], + texture2d textureU[[texture(1)]], + texture2d textureV[[texture(2)]], + texture2d textureA[[texture(3)]] ) { constexpr sampler s(address::clamp_to_edge, filter::linear); - return half4(texture.sample(s, in.texCoord)); + + half y = textureY.sample(s, in.texCoord).r; + half u = textureU.sample(s, in.texCoord).r - 0.5; + half v = textureV.sample(s, in.texCoord).r - 0.5; + half a = textureA.sample(s, in.texCoord).r; + + half4 out = half4(1.5748 * v + y, -0.1873 * v + y, 1.8556 * u + y, a); + return half4(out.b, out.g, out.r, out.a); } diff --git a/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationMetalRenderer.swift b/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationMetalRenderer.swift index 2a3646512d..2a1c71affb 100644 --- a/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationMetalRenderer.swift +++ b/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationMetalRenderer.swift @@ -62,37 +62,84 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { } private final class TextureStoragePool { - let width: Int - let height: Int + struct Parameters { + let width: Int + let height: Int + let format: TextureStorage.Content.Format + } + let parameters: Parameters private var items: [TextureStorage.Content] = [] + private var cleanupTimer: Foundation.Timer? + private var lastTakeTimestamp: Double = 0.0 - init(width: Int, height: Int) { - self.width = width - self.height = height + init(width: Int, height: Int, format: TextureStorage.Content.Format) { + self.parameters = Parameters(width: width, height: height, format: format) + + let cleanupTimer = Foundation.Timer(timeInterval: 2.0, repeats: true, block: { [weak self] _ in + guard let strongSelf = self else { + return + } + strongSelf.collect() + }) + self.cleanupTimer = cleanupTimer + RunLoop.main.add(cleanupTimer, forMode: .common) + } + + deinit { + self.cleanupTimer?.invalidate() + } + + private func collect() { + let timestamp = CFAbsoluteTimeGetCurrent() + if timestamp - self.lastTakeTimestamp < 1.0 { + return + } + if self.items.count > 32 { + autoreleasepool { + var remainingItems: [Unmanaged] = [] + while self.items.count > 32 { + let item = self.items.removeLast() + remainingItems.append(Unmanaged.passRetained(item)) + } + DispatchQueue.global().async { + autoreleasepool { + for item in remainingItems { + item.release() + } + } + } + } + } } func recycle(content: TextureStorage.Content) { - if self.items.count < 4 { - self.items.append(content) - } else { - print("Warning: over-recycling texture storage") - } + self.items.append(content) } - func take(device: MTLDevice) -> TextureStorage.Content? { + func take() -> TextureStorage? { if self.items.isEmpty { - guard let content = TextureStorage.Content(device: device, width: self.width, height: self.height) else { - return nil - } - return content + self.lastTakeTimestamp = CFAbsoluteTimeGetCurrent() + return nil } - return self.items.removeLast() + return TextureStorage(pool: self, content: self.items.removeLast()) + } + + static func takeNew(device: MTLDevice, parameters: Parameters, pool: TextureStoragePool) -> TextureStorage? { + guard let content = TextureStorage.Content(device: device, width: parameters.width, height: parameters.height, format: parameters.format) else { + return nil + } + return TextureStorage(pool: pool, content: content) } } private final class TextureStorage { final class Content { + enum Format { + case bgra + case r + } + let buffer: MTLBuffer? let width: Int @@ -100,9 +147,29 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { let bytesPerRow: Int let texture: MTLTexture - init?(device: MTLDevice, width: Int, height: Int) { - let bytesPerPixel = 4 - let pixelRowAlignment = device.minimumLinearTextureAlignment(for: .bgra8Unorm) + static func rowAlignment(device: MTLDevice, format: Format) -> Int { + let pixelFormat: MTLPixelFormat + switch format { + case .bgra: + pixelFormat = .bgra8Unorm + case .r: + pixelFormat = .r8Unorm + } + return device.minimumLinearTextureAlignment(for: pixelFormat) + } + + init?(device: MTLDevice, width: Int, height: Int, format: Format) { + let bytesPerPixel: Int + let pixelFormat: MTLPixelFormat + switch format { + case .bgra: + bytesPerPixel = 4 + pixelFormat = .bgra8Unorm + case .r: + bytesPerPixel = 1 + pixelFormat = .r8Unorm + } + let pixelRowAlignment = Content.rowAlignment(device: device, format: format) let bytesPerRow = alignUp(size: width * bytesPerPixel, align: pixelRowAlignment) self.width = width @@ -112,10 +179,10 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { #if targetEnvironment(simulator) let textureDescriptor = MTLTextureDescriptor() textureDescriptor.textureType = .type2D - textureDescriptor.pixelFormat = .bgra8Unorm + textureDescriptor.pixelFormat = pixelFormat textureDescriptor.width = width textureDescriptor.height = height - textureDescriptor.usage = [.renderTarget] + textureDescriptor.usage = [.shaderRead] textureDescriptor.storageMode = .shared guard let texture = device.makeTexture(descriptor: textureDescriptor) else { @@ -130,10 +197,10 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { let textureDescriptor = MTLTextureDescriptor() textureDescriptor.textureType = .type2D - textureDescriptor.pixelFormat = .bgra8Unorm + textureDescriptor.pixelFormat = pixelFormat textureDescriptor.width = width textureDescriptor.height = height - textureDescriptor.usage = [.renderTarget] + textureDescriptor.usage = [.shaderRead] textureDescriptor.storageMode = buffer.storageMode guard let texture = buffer.makeTexture(descriptor: textureDescriptor, offset: 0, bytesPerRow: bytesPerRow) else { @@ -144,7 +211,7 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { self.texture = texture } - func replace(rgbaData: Data, range: Range, width: Int, height: Int, bytesPerRow: Int) { + func replace(rgbaData: Data, width: Int, height: Int, bytesPerRow: Int) { if width != self.width || height != self.height { assert(false, "Image size does not match") return @@ -152,12 +219,14 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { let region = MTLRegion(origin: MTLOrigin(x: 0, y: 0, z: 0), size: MTLSize(width: width, height: height, depth: 1)) if let buffer = self.buffer, self.bytesPerRow == bytesPerRow { + assert(bytesPerRow * height <= rgbaData.count) + rgbaData.withUnsafeBytes { bytes in - let _ = memcpy(buffer.contents(), bytes.baseAddress!.advanced(by: range.lowerBound), bytesPerRow * height) + let _ = memcpy(buffer.contents(), bytes.baseAddress!, bytesPerRow * height) } } else { rgbaData.withUnsafeBytes { bytes in - self.texture.replace(region: region, mipmapLevel: 0, withBytes: bytes.baseAddress!.advanced(by: range.lowerBound), bytesPerRow: bytesPerRow) + self.texture.replace(region: region, mipmapLevel: 0, withBytes: bytes.baseAddress!, bytesPerRow: bytesPerRow) } } } @@ -177,67 +246,30 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { self.pool?.recycle(content: self.content) } } - - /*func createCGImage() -> CGImage? { - if self.isInvalidated { - return nil - } - self.isInvalidated = true - - #if targetEnvironment(simulator) - guard let data = NSMutableData(capacity: self.content.bytesPerRow * self.content.height) else { - return nil - } - data.length = self.content.bytesPerRow * self.content.height - self.content.texture.getBytes(data.mutableBytes, bytesPerRow: self.content.bytesPerRow, bytesPerImage: self.content.bytesPerRow * self.content.height, from: MTLRegion(origin: MTLOrigin(), size: MTLSize(width: self.content.width, height: self.content.height, depth: 1)), mipmapLevel: 0, slice: 0) - - guard let dataProvider = CGDataProvider(data: data as CFData) else { - return nil - } - #else - let content = self.content - let pool = self.pool - guard let dataProvider = CGDataProvider(data: Data(bytesNoCopy: self.content.buffer.contents(), count: self.content.buffer.length, deallocator: .custom { [weak pool] _, _ in - guard let pool = pool else { - return - } - pool.recycle(content: content) - }) as CFData) else { - return nil - } - #endif - - guard let image = CGImage( - width: Int(self.content.width), - height: Int(self.content.height), - bitsPerComponent: 8, - bitsPerPixel: 8 * 4, - bytesPerRow: self.content.bytesPerRow, - space: DeviceGraphicsContextSettings.shared.colorSpace, - bitmapInfo: DeviceGraphicsContextSettings.shared.transparentBitmapInfo, - provider: dataProvider, - decode: nil, - shouldInterpolate: true, - intent: .defaultIntent - ) else { - return nil - } - - return image - }*/ } private final class Frame { let timestamp: Double - let texture: TextureStorage.Content + let textureY: TextureStorage + let textureU: TextureStorage + let textureV: TextureStorage + let textureA: TextureStorage - init(device: MTLDevice, texture: TextureStorage.Content, data: AnimationCacheItemFrame, timestamp: Double) { + init?(device: MTLDevice, textureY: TextureStorage, textureU: TextureStorage, textureV: TextureStorage, textureA: TextureStorage, data: AnimationCacheItemFrame, timestamp: Double) { self.timestamp = timestamp - self.texture = texture + self.textureY = textureY + self.textureU = textureU + self.textureV = textureV + self.textureA = textureA switch data.format { - case let .rgba(width, height, bytesPerRow): - texture.replace(rgbaData: data.data, range: data.range, width: width, height: height, bytesPerRow: bytesPerRow) + case .rgba: + return nil + case let .yuva(y, u, v, a): + self.textureY.content.replace(rgbaData: y.data, width: y.width, height: y.height, bytesPerRow: y.bytesPerRow) + self.textureU.content.replace(rgbaData: u.data, width: u.width, height: u.height, bytesPerRow: u.bytesPerRow) + self.textureV.content.replace(rgbaData: v.data, width: v.width, height: v.height, bytesPerRow: v.bytesPerRow) + self.textureA.content.replace(rgbaData: a.data, width: a.width, height: a.height, bytesPerRow: a.bytesPerRow) } } } @@ -265,9 +297,11 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { var targets: [TargetReference] = [] var slotIndex: Int + private let preferredRowAlignment: Int - init(slotIndex: Int, cache: AnimationCache, itemId: String, size: CGSize, fetch: @escaping (CGSize, AnimationCacheItemWriter) -> Disposable, stateUpdated: @escaping () -> Void) { + init(slotIndex: Int, preferredRowAlignment: Int, cache: AnimationCache, itemId: String, size: CGSize, fetch: @escaping (CGSize, AnimationCacheItemWriter) -> Disposable, stateUpdated: @escaping () -> Void) { self.slotIndex = slotIndex + self.preferredRowAlignment = preferredRowAlignment self.cache = cache self.stateUpdated = stateUpdated @@ -316,11 +350,11 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { self.isPlaying = isPlaying } - func animationTick(device: MTLDevice, texturePool: TextureStoragePool, advanceTimestamp: Double) -> LoadFrameTask? { - return self.update(device: device, texturePool: texturePool, advanceTimestamp: advanceTimestamp) + func animationTick(device: MTLDevice, texturePoolFullPlane: TextureStoragePool, texturePoolHalfPlane: TextureStoragePool, advanceTimestamp: Double) -> LoadFrameTask? { + return self.update(device: device, texturePoolFullPlane: texturePoolFullPlane, texturePoolHalfPlane: texturePoolHalfPlane, advanceTimestamp: advanceTimestamp) } - private func update(device: MTLDevice, texturePool: TextureStoragePool, advanceTimestamp: Double?) -> LoadFrameTask? { + private func update(device: MTLDevice, texturePoolFullPlane: TextureStoragePool, texturePoolHalfPlane: TextureStoragePool, advanceTimestamp: Double?) -> LoadFrameTask? { guard let item = self.item else { return nil } @@ -334,20 +368,33 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { } else if !self.isLoadingFrame { self.isLoadingFrame = true + let fullParameters = texturePoolFullPlane.parameters + let halfParameters = texturePoolHalfPlane.parameters + + let readyTextureY = texturePoolFullPlane.take() + let readyTextureU = texturePoolHalfPlane.take() + let readyTextureV = texturePoolHalfPlane.take() + let readyTextureA = texturePoolFullPlane.take() + let preferredRowAlignment = self.preferredRowAlignment + return LoadFrameTask(task: { [weak self] in - let frame = item.getFrame(at: timestamp) + let frame = item.getFrame(at: timestamp, requestedFormat: .yuva(rowAlignment: preferredRowAlignment)) + + let textureY = readyTextureY ?? TextureStoragePool.takeNew(device: device, parameters: fullParameters, pool: texturePoolFullPlane) + let textureU = readyTextureU ?? TextureStoragePool.takeNew(device: device, parameters: halfParameters, pool: texturePoolHalfPlane) + let textureV = readyTextureV ?? TextureStoragePool.takeNew(device: device, parameters: halfParameters, pool: texturePoolHalfPlane) + let textureA = readyTextureA ?? TextureStoragePool.takeNew(device: device, parameters: fullParameters, pool: texturePoolFullPlane) + + var currentFrame: Frame? + if let frame = frame, let textureY = textureY, let textureU = textureU, let textureV = textureV, let textureA = textureA { + currentFrame = Frame(device: device, textureY: textureY, textureU: textureU, textureV: textureV, textureA: textureA, data: frame, timestamp: timestamp) + } return { guard let strongSelf = self else { return } - var currentFrame: Frame? - let texture = texturePool.take(device: device) - if let frame = frame, let texture = texture { - currentFrame = Frame(device: device, texture: texture, data: frame, timestamp: timestamp) - } - strongSelf.isLoadingFrame = false if let currentFrame = currentFrame { @@ -369,7 +416,10 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { private let commandQueue: MTLCommandQueue private let renderPipelineState: MTLRenderPipelineState - private let texturePool: TextureStoragePool + private let texturePoolFullPlane: TextureStoragePool + private let texturePoolHalfPlane: TextureStoragePool + + private let preferredRowAlignment: Int private let slotCount: Int private let slotsX: Int @@ -389,8 +439,10 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { self.cellSize = cellSize self.stateUpdated = stateUpdated - self.slotsX = 16 - self.slotsY = 16 + let resolutionX = max(1, (1024 / Int(cellSize.width))) * Int(cellSize.width) + let resolutionY = max(1, (1024 / Int(cellSize.height))) * Int(cellSize.height) + self.slotsX = resolutionX / Int(cellSize.width) + self.slotsY = resolutionY / Int(cellSize.height) let drawableSize = CGSize(width: cellSize.width * CGFloat(self.slotsX), height: cellSize.height * CGFloat(self.slotsY)) self.slotCount = (Int(drawableSize.width) / Int(cellSize.width)) * (Int(drawableSize.height) / Int(cellSize.height)) @@ -413,7 +465,10 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { self.renderPipelineState = makePipelineState(device: self.metalDevice, library: defaultLibrary, vertexProgram: "multiAnimationVertex", fragmentProgram: "multiAnimationFragment")! - self.texturePool = TextureStoragePool(width: Int(self.cellSize.width), height: Int(self.cellSize.height)) + self.texturePoolFullPlane = TextureStoragePool(width: Int(self.cellSize.width), height: Int(self.cellSize.height), format: .r) + self.texturePoolHalfPlane = TextureStoragePool(width: Int(self.cellSize.width) / 2, height: Int(self.cellSize.height) / 2, format: .r) + + self.preferredRowAlignment = TextureStorage.Content.rowAlignment(device: self.metalDevice, format: .r) super.init() @@ -427,6 +482,7 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { self.pixelFormat = .bgra8Unorm self.framebufferOnly = true self.allowsNextDrawableTimeout = true + self.isOpaque = false } override public init(layer: Any) { @@ -452,7 +508,7 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { for i in 0 ..< self.slotCount { if self.slotToItemId[i] == nil { self.slotToItemId[i] = itemId - self.itemContexts[itemId] = ItemContext(slotIndex: i, cache: cache, itemId: itemId, size: size, fetch: fetch, stateUpdated: { [weak self] in + self.itemContexts[itemId] = ItemContext(slotIndex: i, preferredRowAlignment: self.preferredRowAlignment, cache: cache, itemId: itemId, size: size, fetch: fetch, stateUpdated: { [weak self] in guard let strongSelf = self else { return } @@ -465,6 +521,23 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { if let itemContext = self.itemContexts[itemId] { itemContext.targets.append(TargetReference(target)) + + let deinitIndex = target.deinitCallbacks.add { [weak self, weak itemContext] in + Queue.mainQueue().async { + guard let strongSelf = self, let currentItemContext = strongSelf.itemContexts[itemId], currentItemContext === itemContext else { + return + } + strongSelf.removeTargetFromItemContext(itemId: itemId, itemContext: currentItemContext, targetId: targetId) + } + } + + let updateStateIndex = target.updateStateCallbacks.add { [weak itemContext] in + guard let itemContext = itemContext else { + return + } + itemContext.updateIsPlaying() + } + target.contents = self.contents let slotX = itemContext.slotIndex % self.slotsX @@ -476,22 +549,18 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { self.isPlaying = true - return ActionDisposable { [weak self, weak itemContext] in + return ActionDisposable { [weak self, weak target, weak itemContext] in Queue.mainQueue().async { guard let strongSelf = self, let currentItemContext = strongSelf.itemContexts[itemId], currentItemContext === itemContext else { return } - if let index = currentItemContext.targets.firstIndex(where: { $0.id == targetId }) { - currentItemContext.targets.remove(at: index) - if currentItemContext.targets.isEmpty { - strongSelf.slotToItemId[currentItemContext.slotIndex] = nil - strongSelf.itemContexts.removeValue(forKey: itemId) - - if strongSelf.itemContexts.isEmpty { - strongSelf.isPlaying = false - } - } + + if let target = target { + target.deinitCallbacks.remove(deinitIndex) + target.updateStateCallbacks.remove(updateStateIndex) } + + strongSelf.removeTargetFromItemContext(itemId: itemId, itemContext: currentItemContext, targetId: targetId) } } } else { @@ -499,6 +568,21 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { } } + private func removeTargetFromItemContext(itemId: String, itemContext: ItemContext, targetId: Int64) { + if let index = itemContext.targets.firstIndex(where: { $0.id == targetId }) { + itemContext.targets.remove(at: index) + + if itemContext.targets.isEmpty { + self.slotToItemId[itemContext.slotIndex] = nil + self.itemContexts.removeValue(forKey: itemId) + + if self.itemContexts.isEmpty { + self.isPlaying = false + } + } + } + } + private func updateIsPlaying() { var isPlaying = false for (_, itemContext) in self.itemContexts { @@ -515,7 +599,7 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { var tasks: [LoadFrameTask] = [] for (_, itemContext) in self.itemContexts { if itemContext.isPlaying { - if let task = itemContext.animationTick(device: self.metalDevice, texturePool: self.texturePool, advanceTimestamp: advanceTimestamp) { + if let task = itemContext.animationTick(device: self.metalDevice, texturePoolFullPlane: self.texturePoolFullPlane, texturePoolHalfPlane: self.texturePoolHalfPlane, advanceTimestamp: advanceTimestamp) { tasks.append(task) } } @@ -525,10 +609,15 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { } func redraw() { - guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { + guard let drawable = self.nextDrawable() else { return } - guard let drawable = self.nextDrawable() else { + + let commandQueue = self.commandQueue + let renderPipelineState = self.renderPipelineState + let cellSize = self.cellSize + + guard let commandBuffer = commandQueue.makeCommandBuffer() else { return } @@ -551,7 +640,7 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { return } - var usedTextures: [MultiAnimationMetalRendererImpl.TextureStorage.Content] = [] + var usedTextures: [Unmanaged] = [] var vertices: [Float] = [ -1.0, -1.0, 0.0, 0.0, @@ -560,12 +649,12 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { 1.0, 1.0, 1.0, 1.0 ] - renderEncoder.setRenderPipelineState(self.renderPipelineState) + renderEncoder.setRenderPipelineState(renderPipelineState) var resolution = simd_uint2(UInt32(drawable.texture.width), UInt32(drawable.texture.height)) renderEncoder.setVertexBytes(&resolution, length: MemoryLayout.size * 2, index: 1) - var slotSize = simd_uint2(UInt32(self.cellSize.width), UInt32(self.cellSize.height)) + var slotSize = simd_uint2(UInt32(cellSize.width), UInt32(cellSize.height)) renderEncoder.setVertexBytes(&slotSize, length: MemoryLayout.size * 2, index: 2) for (_, itemContext) in self.itemContexts { @@ -580,25 +669,31 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { let contentsRect = CGRect(origin: CGPoint(x: (CGFloat(slotX) * self.cellSize.width) / totalX, y: (CGFloat(slotY) * self.cellSize.height) / totalY), size: CGSize(width: self.cellSize.width / totalX, height: self.cellSize.height / totalY)) - vertices[4 * 0 + 0] = Float(contentsRect.minX).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) - vertices[4 * 0 + 1] = Float(contentsRect.minY).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) - - vertices[4 * 1 + 0] = Float(contentsRect.maxX).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) - vertices[4 * 1 + 1] = Float(contentsRect.minY).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) - vertices[4 * 2 + 0] = Float(contentsRect.minX).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) - vertices[4 * 2 + 1] = Float(contentsRect.maxY).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) + vertices[4 * 2 + 1] = Float(contentsRect.minY).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) vertices[4 * 3 + 0] = Float(contentsRect.maxX).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) - vertices[4 * 3 + 1] = Float(contentsRect.maxY).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) + vertices[4 * 3 + 1] = Float(contentsRect.minY).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) + + vertices[4 * 0 + 0] = Float(contentsRect.minX).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) + vertices[4 * 0 + 1] = Float(contentsRect.maxY).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) + + vertices[4 * 1 + 0] = Float(contentsRect.maxX).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) + vertices[4 * 1 + 1] = Float(contentsRect.maxY).remap(fromLow: 0.0, fromHigh: 1.0, toLow: -1.0, toHigh: 1.0) renderEncoder.setVertexBytes(&vertices, length: 4 * vertices.count, index: 0) var slotPosition = simd_uint2(UInt32(itemContext.slotIndex % self.slotsX), UInt32(itemContext.slotIndex % self.slotsY)) renderEncoder.setVertexBytes(&slotPosition, length: MemoryLayout.size * 2, index: 3) - usedTextures.append(frame.texture) - renderEncoder.setFragmentTexture(frame.texture.texture, index: 0) + usedTextures.append(Unmanaged.passRetained(frame.textureY)) + usedTextures.append(Unmanaged.passRetained(frame.textureU)) + usedTextures.append(Unmanaged.passRetained(frame.textureV)) + usedTextures.append(Unmanaged.passRetained(frame.textureA)) + renderEncoder.setFragmentTexture(frame.textureY.content.texture, index: 0) + renderEncoder.setFragmentTexture(frame.textureU.content.texture, index: 1) + renderEncoder.setFragmentTexture(frame.textureV.content.texture, index: 2) + renderEncoder.setFragmentTexture(frame.textureA.content.texture, index: 3) renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4, instanceCount: 1) } @@ -623,7 +718,8 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { } commandBuffer.addCompletedHandler { _ in DispatchQueue.main.async { - for _ in usedTextures { + for texture in usedTextures { + texture.release() } } } @@ -682,7 +778,7 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { self.isPlaying = isPlaying } - public func add(groupId: String, target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, fetch: @escaping (CGSize, AnimationCacheItemWriter) -> Disposable) -> Disposable { + public func add(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, fetch: @escaping (CGSize, AnimationCacheItemWriter) -> Disposable) -> Disposable { assert(Thread.isMainThread) let alignedSize = CGSize(width: CGFloat(alignUp(size: Int(size.width), align: 16)), height: CGFloat(alignUp(size: Int(size.height), align: 16))) @@ -709,11 +805,11 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { } } - public func loadFirstFrameSynchronously(groupId: String, target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize) -> Bool { + public func loadFirstFrameSynchronously(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize) -> Bool { return false } - public func loadFirstFrame(groupId: String, target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, completion: @escaping (Bool) -> Void) -> Disposable { + public func loadFirstFrame(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, completion: @escaping (Bool) -> Void) -> Disposable { completion(false) return EmptyDisposable @@ -751,13 +847,13 @@ public final class MultiAnimationMetalRendererImpl: MultiAnimationRenderer { for completion in completions { completion() } - } - } - - if let strongSelf = self { - for index in surfaceLayersWithTasks { - if let surfaceLayer = strongSelf.surfaceLayers[index] { - surfaceLayer.redraw() + + if let strongSelf = self { + for index in surfaceLayersWithTasks { + if let surfaceLayer = strongSelf.surfaceLayers[index] { + surfaceLayer.redraw() + } + } } } } diff --git a/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationRenderer.swift b/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationRenderer.swift index 6b354caa28..08a52b35db 100644 --- a/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationRenderer.swift +++ b/submodules/TelegramUI/Components/MultiAnimationRenderer/Sources/MultiAnimationRenderer.swift @@ -6,9 +6,9 @@ import AnimationCache import Accelerate public protocol MultiAnimationRenderer: AnyObject { - func add(groupId: String, target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, fetch: @escaping (CGSize, AnimationCacheItemWriter) -> Disposable) -> Disposable - func loadFirstFrameSynchronously(groupId: String, target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize) -> Bool - func loadFirstFrame(groupId: String, target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, completion: @escaping (Bool) -> Void) -> Disposable + func add(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, fetch: @escaping (CGSize, AnimationCacheItemWriter) -> Disposable) -> Disposable + func loadFirstFrameSynchronously(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize) -> Bool + func loadFirstFrame(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, completion: @escaping (Bool) -> Void) -> Disposable } private var nextRenderTargetId: Int64 = 1 @@ -16,8 +16,8 @@ private var nextRenderTargetId: Int64 = 1 open class MultiAnimationRenderTarget: SimpleLayer { public let id: Int64 - fileprivate let deinitCallbacks = Bag<() -> Void>() - fileprivate let updateStateCallbacks = Bag<() -> Void>() + let deinitCallbacks = Bag<() -> Void>() + let updateStateCallbacks = Bag<() -> Void>() public final var shouldBeAnimating: Bool = false { didSet { @@ -60,6 +60,9 @@ open class MultiAnimationRenderTarget: SimpleLayer { open func updateDisplayPlaceholder(displayPlaceholder: Bool) { } + + open func transitionToContents(_ contents: AnyObject) { + } } private final class FrameGroup { @@ -69,16 +72,16 @@ private final class FrameGroup { let timestamp: Double init?(item: AnimationCacheItem, timestamp: Double) { - guard let firstFrame = item.getFrame(at: timestamp) else { + guard let firstFrame = item.getFrame(at: timestamp, requestedFormat: .rgba) else { return nil } switch firstFrame.format { - case let .rgba(width, height, bytesPerRow): + case let .rgba(data, width, height, bytesPerRow): let context = DrawingContext(size: CGSize(width: CGFloat(width), height: CGFloat(height)), scale: 1.0, opaque: false, bytesPerRow: bytesPerRow) - firstFrame.data.withUnsafeBytes { bytes -> Void in - memcpy(context.bytes, bytes.baseAddress!.advanced(by: firstFrame.range.lowerBound), height * bytesPerRow) + data.withUnsafeBytes { bytes -> Void in + memcpy(context.bytes, bytes.baseAddress!, height * bytesPerRow) /*var sourceBuffer = vImage_Buffer() sourceBuffer.width = UInt(width) @@ -110,6 +113,8 @@ private final class FrameGroup { self.size = CGSize(width: CGFloat(width), height: CGFloat(height)) self.timestamp = timestamp self.badgeImage = nil + default: + return nil } } } @@ -176,8 +181,9 @@ private final class ItemAnimationContext { func updateAddedTarget(target: MultiAnimationRenderTarget) { if let currentFrameGroup = self.currentFrameGroup { - target.updateDisplayPlaceholder(displayPlaceholder: false) - target.contents = currentFrameGroup.image.cgImage + if let cgImage = currentFrameGroup.image.cgImage { + target.transitionToContents(cgImage) + } } self.updateIsPlaying() @@ -237,8 +243,7 @@ private final class ItemAnimationContext { strongSelf.currentFrameGroup = currentFrameGroup for target in strongSelf.targets.copyItems() { if let target = target.value { - target.contents = currentFrameGroup.image.cgImage - target.updateDisplayPlaceholder(displayPlaceholder: false) + target.transitionToContents(currentFrameGroup.image.cgImage!) } } } @@ -398,7 +403,7 @@ public final class MultiAnimationRendererImpl: MultiAnimationRenderer { public static let firstFrameQueue = Queue(name: "MultiAnimationRenderer-FirstFrame", qos: .userInteractive) - private var groupContexts: [String: GroupContext] = [:] + private var groupContext: GroupContext? private var frameSkip: Int private var displayLink: ConstantDisplayLinkAnimator? @@ -434,9 +439,9 @@ public final class MultiAnimationRendererImpl: MultiAnimationRenderer { } } - public func add(groupId: String, target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, fetch: @escaping (CGSize, AnimationCacheItemWriter) -> Disposable) -> Disposable { + public func add(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, fetch: @escaping (CGSize, AnimationCacheItemWriter) -> Disposable) -> Disposable { let groupContext: GroupContext - if let current = self.groupContexts[groupId] { + if let current = self.groupContext { groupContext = current } else { groupContext = GroupContext(firstFrameQueue: MultiAnimationRendererImpl.firstFrameQueue, stateUpdated: { [weak self] in @@ -445,7 +450,7 @@ public final class MultiAnimationRendererImpl: MultiAnimationRenderer { } strongSelf.updateIsPlaying() }) - self.groupContexts[groupId] = groupContext + self.groupContext = groupContext } let disposable = groupContext.add(target: target, cache: cache, itemId: itemId, size: size, fetch: fetch) @@ -455,9 +460,9 @@ public final class MultiAnimationRendererImpl: MultiAnimationRenderer { } } - public func loadFirstFrameSynchronously(groupId: String, target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize) -> Bool { + public func loadFirstFrameSynchronously(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize) -> Bool { let groupContext: GroupContext - if let current = self.groupContexts[groupId] { + if let current = self.groupContext { groupContext = current } else { groupContext = GroupContext(firstFrameQueue: MultiAnimationRendererImpl.firstFrameQueue, stateUpdated: { [weak self] in @@ -466,15 +471,15 @@ public final class MultiAnimationRendererImpl: MultiAnimationRenderer { } strongSelf.updateIsPlaying() }) - self.groupContexts[groupId] = groupContext + self.groupContext = groupContext } return groupContext.loadFirstFrameSynchronously(target: target, cache: cache, itemId: itemId, size: size) } - public func loadFirstFrame(groupId: String, target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, completion: @escaping (Bool) -> Void) -> Disposable { + public func loadFirstFrame(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, completion: @escaping (Bool) -> Void) -> Disposable { let groupContext: GroupContext - if let current = self.groupContexts[groupId] { + if let current = self.groupContext { groupContext = current } else { groupContext = GroupContext(firstFrameQueue: MultiAnimationRendererImpl.firstFrameQueue, stateUpdated: { [weak self] in @@ -483,7 +488,7 @@ public final class MultiAnimationRendererImpl: MultiAnimationRenderer { } strongSelf.updateIsPlaying() }) - self.groupContexts[groupId] = groupContext + self.groupContext = groupContext } return groupContext.loadFirstFrame(target: target, cache: cache, itemId: itemId, size: size, completion: completion) @@ -491,10 +496,9 @@ public final class MultiAnimationRendererImpl: MultiAnimationRenderer { private func updateIsPlaying() { var isPlaying = false - for (_, groupContext) in self.groupContexts { + if let groupContext = self.groupContext { if groupContext.isPlaying { isPlaying = true - break } } @@ -505,7 +509,7 @@ public final class MultiAnimationRendererImpl: MultiAnimationRenderer { let secondsPerFrame = Double(self.frameSkip) / 60.0 var tasks: [LoadFrameGroupTask] = [] - for (_, groupContext) in self.groupContexts { + if let groupContext = self.groupContext { if groupContext.isPlaying { tasks.append(contentsOf: groupContext.animationTick(advanceTimestamp: secondsPerFrame)) } diff --git a/submodules/TelegramUI/Components/TextNodeWithEntities/Sources/TextNodeWithEntities.swift b/submodules/TelegramUI/Components/TextNodeWithEntities/Sources/TextNodeWithEntities.swift index f796d63ec3..4f7ce03535 100644 --- a/submodules/TelegramUI/Components/TextNodeWithEntities/Sources/TextNodeWithEntities.swift +++ b/submodules/TelegramUI/Components/TextNodeWithEntities/Sources/TextNodeWithEntities.swift @@ -39,6 +39,18 @@ private final class InlineStickerItem: Hashable { } } +private final class RunDelegateData { + let ascent: CGFloat + let descent: CGFloat + let width: CGFloat + + init(ascent: CGFloat, descent: CGFloat, width: CGFloat) { + self.ascent = ascent + self.descent = descent + self.width = width + } +} + public final class TextNodeWithEntities { public final class Arguments { public let context: AccountContext @@ -110,14 +122,60 @@ public final class TextNodeWithEntities { if let sourceString = arguments.attributedString { let string = NSMutableAttributedString(attributedString: sourceString) - let fullRange = NSRange(location: 0, length: string.length) - string.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: fullRange, options: [], using: { value, range, _ in - if let value = value as? ChatTextInputTextCustomEmojiAttribute { - if let font = string.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont { - string.addAttribute(NSAttributedString.Key("Attribute__EmbeddedItem"), value: InlineStickerItem(emoji: value, file: value.file, fontSize: font.pointSize), range: range) + var fullRange = NSRange(location: 0, length: string.length) + while true { + var found = false + string.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: fullRange, options: [], using: { value, range, stop in + if let value = value as? ChatTextInputTextCustomEmojiAttribute, let font = string.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont { + let updatedSubstring = NSMutableAttributedString(string: ".") + + let replacementRange = NSRange(location: 0, length: updatedSubstring.length) + updatedSubstring.addAttributes(string.attributes(at: range.location, effectiveRange: nil), range: replacementRange) + updatedSubstring.addAttribute(NSAttributedString.Key("Attribute__EmbeddedItem"), value: InlineStickerItem(emoji: value, file: value.file, fontSize: font.pointSize), range: replacementRange) + updatedSubstring.addAttribute(originalTextAttributeKey, value: string.attributedSubstring(from: range).string, range: replacementRange) + + let itemSize = font.pointSize * 24.0 / 17.0 + + let runDelegateData = RunDelegateData( + ascent: font.ascender, + descent: font.descender, + width: itemSize + ) + var callbacks = CTRunDelegateCallbacks( + version: kCTRunDelegateCurrentVersion, + dealloc: { dataRef in + Unmanaged.fromOpaque(dataRef).release() + }, + getAscent: { dataRef in + let data = Unmanaged.fromOpaque(dataRef) + return data.takeUnretainedValue().ascent + }, + getDescent: { dataRef in + let data = Unmanaged.fromOpaque(dataRef) + return data.takeUnretainedValue().descent + }, + getWidth: { dataRef in + let data = Unmanaged.fromOpaque(dataRef) + return data.takeUnretainedValue().width + } + ) + + if let runDelegate = CTRunDelegateCreate(&callbacks, Unmanaged.passRetained(runDelegateData).toOpaque()) { + updatedSubstring.addAttribute(NSAttributedString.Key(kCTRunDelegateAttributeName as String), value: runDelegate, range: replacementRange) + } + + string.replaceCharacters(in: range, with: updatedSubstring) + let updatedRange = NSRange(location: range.location, length: updatedSubstring.length) + + found = true + stop.pointee = ObjCBool(true) + fullRange = NSRange(location: updatedRange.upperBound, length: fullRange.upperBound - range.upperBound) } + }) + if !found { + break } - }) + } updatedString = string } @@ -178,7 +236,7 @@ public final class TextNodeWithEntities { if let current = self.inlineStickerItemLayers[id] { itemLayer = current } else { - itemLayer = InlineStickerItemLayer(context: context, groupId: "inlineEmoji", attemptSynchronousLoad: attemptSynchronousLoad, emoji: stickerItem.emoji, file: stickerItem.file, cache: cache, renderer: renderer, placeholderColor: placeholderColor, pointSize: CGSize(width: itemSize, height: itemSize)) + itemLayer = InlineStickerItemLayer(context: context, attemptSynchronousLoad: attemptSynchronousLoad, emoji: stickerItem.emoji, file: stickerItem.file, cache: cache, renderer: renderer, placeholderColor: placeholderColor, pointSize: CGSize(width: itemSize, height: itemSize)) self.inlineStickerItemLayers[id] = itemLayer self.textNode.layer.addSublayer(itemLayer) @@ -341,7 +399,7 @@ public class ImmediateTextNodeWithEntities: TextNode { if let current = self.inlineStickerItemLayers[id] { itemLayer = current } else { - itemLayer = InlineStickerItemLayer(context: context, groupId: "inlineEmoji", attemptSynchronousLoad: false, emoji: stickerItem.emoji, file: stickerItem.file, cache: cache, renderer: renderer, placeholderColor: placeholderColor, pointSize: CGSize(width: itemSize, height: itemSize)) + itemLayer = InlineStickerItemLayer(context: context, attemptSynchronousLoad: false, emoji: stickerItem.emoji, file: stickerItem.file, cache: cache, renderer: renderer, placeholderColor: placeholderColor, pointSize: CGSize(width: itemSize, height: itemSize)) self.inlineStickerItemLayers[id] = itemLayer self.layer.addSublayer(itemLayer) diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 562af3781a..dd544f6faf 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -10857,7 +10857,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }) - let inputPanelNode = AttachmentTextInputPanelNode(context: self.context, presentationInterfaceState: presentationInterfaceState, isCaption: true, presentController: { _ in }) + let inputPanelNode = AttachmentTextInputPanelNode(context: self.context, presentationInterfaceState: presentationInterfaceState, isCaption: true, presentController: { _ in }, makeEntityInputView: { [weak self] in + guard let strongSelf = self else { + return nil + } + + return EntityInputView(context: strongSelf.context, isDark: true) + }) inputPanelNode.interfaceInteraction = interfaceInteraction inputPanelNode.effectivePresentationInterfaceState = { return presentationInterfaceState @@ -11092,7 +11098,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let currentFilesController = Atomic(value: nil) let currentLocationController = Atomic(value: nil) - let attachmentController = AttachmentController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, chatLocation: strongSelf.chatLocation, buttons: buttons, initialButton: initialButton) + let attachmentController = AttachmentController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, chatLocation: strongSelf.chatLocation, buttons: buttons, initialButton: initialButton, makeEntityInputView: { [weak self] in + guard let strongSelf = self else { + return nil + } + return EntityInputView(context: strongSelf.context, isDark: false) + }) attachmentController.requestController = { [weak self, weak attachmentController] type, completion in guard let strongSelf = self else { return diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index fae7867aee..016c32deda 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -540,14 +540,13 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { } self.addSubnode(self.inputPanelContainerNode) + self.addSubnode(self.inputContextPanelContainer) self.inputPanelContainerNode.addSubnode(self.inputPanelClippingNode) self.inputPanelClippingNode.addSubnode(self.inputPanelBackgroundNode) self.inputPanelClippingNode.addSubnode(self.inputPanelBackgroundSeparatorNode) self.inputPanelBackgroundNode.addSubnode(self.inputPanelBottomBackgroundSeparatorNode) - self.contentContainerNode.addSubnode(self.inputContextPanelContainer) - self.addSubnode(self.messageTransitionNode) self.contentContainerNode.addSubnode(self.navigateButtons) self.contentContainerNode.addSubnode(self.presentationContextMarker) @@ -1031,7 +1030,12 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { var insets: UIEdgeInsets var inputPanelBottomInsetTerm: CGFloat = 0.0 - if inputNodeForState != nil { + if let inputNodeForState = inputNodeForState { + if !self.inputPanelContainerNode.stableIsExpanded && inputNodeForState.adjustLayoutForHiddenInput { + inputNodeForState.hideInput = false + inputNodeForState.adjustLayoutForHiddenInput = false + } + insets = layout.insets(options: []) inputPanelBottomInsetTerm = max(insets.bottom, layout.standardInputHeight) } else { @@ -1191,9 +1195,6 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { inputNode.hideInputUpdated = { [weak self] transition in self?.updateInputPanelBackgroundExpansion(transition: transition) } - inputNode.expansionFractionUpdated = { [weak self] transition in - self?.updateInputPanelBackgroundExpansion(transition: transition) - } dismissedInputNode = self.inputNode if let inputNode = self.inputNode { @@ -1236,7 +1237,7 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { inputNodeHeightAndOverflow = ( boundedHeight, - max(0.0, inputHeight - boundedHeight) + inputNode.followsDefaultHeight ? max(0.0, inputHeight - boundedHeight) : 0.0 ) } else if let inputNode = self.inputNode { dismissedInputNode = inputNode @@ -1330,7 +1331,7 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) var immediatelyLayoutInputContextPanelAndAnimateAppearance = false - if let inputContextPanelNode = inputContextPanelForChatPresentationIntefaceState(self.chatPresentationInterfaceState, context: self.context, currentPanel: self.inputContextPanelNode, controllerInteraction: self.controllerInteraction, interfaceInteraction: self.interfaceInteraction) { + if let inputContextPanelNode = inputContextPanelForChatPresentationIntefaceState(self.chatPresentationInterfaceState, context: self.context, currentPanel: self.inputContextPanelNode, controllerInteraction: self.controllerInteraction, interfaceInteraction: self.interfaceInteraction, chatPresentationContext: self.controllerInteraction.presentationContext) { if inputContextPanelNode !== self.inputContextPanelNode { dismissedInputContextPanelNode = self.inputContextPanelNode self.inputContextPanelNode = inputContextPanelNode @@ -1344,7 +1345,7 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { } var immediatelyLayoutOverlayContextPanelAndAnimateAppearance = false - if let overlayContextPanelNode = chatOverlayContextPanelForChatPresentationIntefaceState(self.chatPresentationInterfaceState, context: self.context, currentPanel: self.overlayContextPanelNode, interfaceInteraction: self.interfaceInteraction) { + if let overlayContextPanelNode = chatOverlayContextPanelForChatPresentationIntefaceState(self.chatPresentationInterfaceState, context: self.context, currentPanel: self.overlayContextPanelNode, interfaceInteraction: self.interfaceInteraction, chatPresentationContext: self.controllerInteraction.presentationContext) { if overlayContextPanelNode !== self.overlayContextPanelNode { dismissedOverlayContextPanelNode = self.overlayContextPanelNode self.overlayContextPanelNode = overlayContextPanelNode @@ -2058,7 +2059,10 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { self.historyNode.verticalScrollIndicatorColor = UIColor(white: 0.5, alpha: 0.8) - let updatedInputFocus = self.chatPresentationInterfaceStateRequiresInputFocus(self.chatPresentationInterfaceState) != self.chatPresentationInterfaceStateRequiresInputFocus(chatPresentationInterfaceState) + var updatedInputFocus = self.chatPresentationInterfaceStateRequiresInputFocus(self.chatPresentationInterfaceState) != self.chatPresentationInterfaceStateRequiresInputFocus(chatPresentationInterfaceState) + if self.chatPresentationInterfaceStateInputView(self.chatPresentationInterfaceState) !== self.chatPresentationInterfaceStateInputView(chatPresentationInterfaceState) { + updatedInputFocus = true + } let updateInputTextState = self.chatPresentationInterfaceState.interfaceState.effectiveInputState != chatPresentationInterfaceState.interfaceState.effectiveInputState self.chatPresentationInterfaceState = chatPresentationInterfaceState @@ -2171,18 +2175,22 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { self.navigationBar?.setContentNode(nil, animated: transitionIsAnimated) } + var waitForKeyboardLayout = false if let textView = self.textInputPanelNode?.textInputNode?.textView { let updatedInputView = self.chatPresentationInterfaceStateInputView(chatPresentationInterfaceState) if textView.inputView !== updatedInputView { textView.inputView = updatedInputView if textView.isFirstResponder { + if self.chatPresentationInterfaceStateRequiresInputFocus(chatPresentationInterfaceState) { + waitForKeyboardLayout = true + } textView.reloadInputViews() } } } if updatedInputFocus { - if !self.ignoreUpdateHeight { + if !self.ignoreUpdateHeight && !waitForKeyboardLayout { self.scheduleLayoutTransitionRequest(layoutTransition) } diff --git a/submodules/TelegramUI/Sources/ChatEntityKeyboardInputNode.swift b/submodules/TelegramUI/Sources/ChatEntityKeyboardInputNode.swift index 64aa27d3e5..22b3258fb6 100644 --- a/submodules/TelegramUI/Sources/ChatEntityKeyboardInputNode.swift +++ b/submodules/TelegramUI/Sources/ChatEntityKeyboardInputNode.swift @@ -21,6 +21,8 @@ import AudioToolbox import UndoUI import ContextUI import GalleryUI +import AttachmentTextInputPanelNode +import TelegramPresentationData private let staticEmojiMapping: [(EmojiPagerContentComponent.StaticEmojiSegment, [String])] = { guard let path = getAppBundle().path(forResource: "emoji1016", ofType: "txt") else { @@ -46,14 +48,14 @@ private let staticEmojiMapping: [(EmojiPagerContentComponent.StaticEmojiSegment, final class ChatEntityKeyboardInputNode: ChatInputNode { struct InputData: Equatable { var emoji: EmojiPagerContentComponent - var stickers: EmojiPagerContentComponent - var gifs: GifPagerContentComponent + var stickers: EmojiPagerContentComponent? + var gifs: GifPagerContentComponent? var availableGifSearchEmojies: [EntityKeyboardComponent.GifSearchEmoji] init( emoji: EmojiPagerContentComponent, - stickers: EmojiPagerContentComponent, - gifs: GifPagerContentComponent, + stickers: EmojiPagerContentComponent?, + gifs: GifPagerContentComponent?, availableGifSearchEmojies: [EntityKeyboardComponent.GifSearchEmoji] ) { self.emoji = emoji @@ -63,7 +65,109 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } } - static func inputData(context: AccountContext, interfaceInteraction: ChatPanelInterfaceInteraction, controllerInteraction: ChatControllerInteraction, chatPeerId: PeerId?) -> Signal { + static func emojiInputData(context: AccountContext, inputInteraction: EmojiPagerContentComponent.InputInteraction, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer) -> Signal { + let hasPremium = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) + |> map { peer -> Bool in + guard case let .user(user) = peer else { + return false + } + return user.isPremium + } + |> distinctUntilChanged + + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) + let isPremiumDisabled = premiumConfiguration.isPremiumDisabled + + let emojiItems: Signal = combineLatest( + context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000), + hasPremium + ) + |> map { view, hasPremium -> EmojiPagerContentComponent in + struct ItemGroup { + var supergroupId: AnyHashable + var id: AnyHashable + var isPremium: Bool + var items: [EmojiPagerContentComponent.Item] + } + var itemGroups: [ItemGroup] = [] + var itemGroupIndexById: [AnyHashable: Int] = [:] + + for (subgroupId, list) in staticEmojiMapping { + let groupId: AnyHashable = "static" + for emojiString in list { + let resultItem = EmojiPagerContentComponent.Item( + file: nil, + staticEmoji: emojiString, + subgroupId: subgroupId.rawValue + ) + + if let groupIndex = itemGroupIndexById[groupId] { + itemGroups[groupIndex].items.append(resultItem) + } else { + itemGroupIndexById[groupId] = itemGroups.count + itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, isPremium: false, items: [resultItem])) + } + } + } + + for entry in view.entries { + guard let item = entry.item as? StickerPackItem else { + continue + } + let resultItem = EmojiPagerContentComponent.Item( + file: item.file, + staticEmoji: nil, + subgroupId: nil + ) + + let supergroupId = entry.index.collectionId + let groupId: AnyHashable = supergroupId + let isPremium: Bool = item.file.isPremiumEmoji && !hasPremium + if isPremium && isPremiumDisabled { + continue + } + /*if isPremium { + groupId = "\(supergroupId)-p" + } else { + groupId = supergroupId + }*/ + if let groupIndex = itemGroupIndexById[groupId] { + itemGroups[groupIndex].items.append(resultItem) + } else { + itemGroupIndexById[groupId] = itemGroups.count + itemGroups.append(ItemGroup(supergroupId: supergroupId, id: groupId, isPremium: isPremium, items: [resultItem])) + } + } + + return EmojiPagerContentComponent( + id: "emoji", + context: context, + animationCache: animationCache, + animationRenderer: animationRenderer, + inputInteraction: inputInteraction, + itemGroups: itemGroups.map { group -> EmojiPagerContentComponent.ItemGroup in + var title: String? + if group.id == AnyHashable("recent") { + //TODO:localize + title = "Recently Used" + } else { + for (id, info, _) in view.collectionInfos { + if AnyHashable(id) == group.id, let info = info as? StickerPackCollectionInfo { + title = info.title + break + } + } + } + + return EmojiPagerContentComponent.ItemGroup(supergroupId: group.supergroupId, groupId: group.id, title: title, isPremium: group.isPremium, displayPremiumBadges: false, items: group.items) + }, + itemLayoutType: .compact + ) + } + return emojiItems + } + + static func inputData(context: AccountContext, interfaceInteraction: ChatPanelInterfaceInteraction, controllerInteraction: ChatControllerInteraction?, chatPeerId: PeerId?) -> Signal { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let isPremiumDisabled = premiumConfiguration.isPremiumDisabled @@ -177,9 +281,9 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { chatPeerId: chatPeerId ) let stickerInputInteraction = EmojiPagerContentComponent.InputInteraction( - performItemAction: { [weak interfaceInteraction] item, view, rect, layer in + performItemAction: { [weak controllerInteraction, weak interfaceInteraction] item, view, rect, layer in let _ = (hasPremium |> take(1) |> deliverOnMainQueue).start(next: { hasPremium in - guard let interfaceInteraction = interfaceInteraction else { + guard let controllerInteraction = controllerInteraction, let interfaceInteraction = interfaceInteraction else { return } if let file = item.file { @@ -238,16 +342,6 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { }, chatPeerId: chatPeerId ) - let gifInputInteraction = GifPagerContentComponent.InputInteraction( - performItemAction: { [weak controllerInteraction] item, view, rect in - guard let controllerInteraction = controllerInteraction else { - return - } - let _ = controllerInteraction.sendGif(.savedGif(media: item.file), view, rect, false, false) - }, - openGifContextMenu: { _, _, _, _, _ in - } - ) let animationCache = AnimationCacheImpl(basePath: context.account.postbox.mediaBox.basePath + "/animation-cache", allocateTempFile: { return TempBox.shared.tempFile(fileName: "file").path @@ -259,98 +353,13 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { animationRenderer = MultiAnimationRendererImpl() //} - let orderedItemListCollectionIds: [Int32] = [Namespaces.OrderedItemList.CloudSavedStickers, Namespaces.OrderedItemList.CloudRecentStickers, Namespaces.OrderedItemList.PremiumStickers, Namespaces.OrderedItemList.CloudPremiumStickers] - let namespaces: [ItemCollectionId.Namespace] = [Namespaces.ItemCollection.CloudStickerPacks] + let emojiItems = emojiInputData(context: context, inputInteraction: emojiInputInteraction, animationCache: animationCache, animationRenderer: animationRenderer) - let emojiItems: Signal = combineLatest( - context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000), - hasPremium - ) - |> map { view, hasPremium -> EmojiPagerContentComponent in - struct ItemGroup { - var supergroupId: AnyHashable - var id: AnyHashable - var isPremium: Bool - var items: [EmojiPagerContentComponent.Item] - } - var itemGroups: [ItemGroup] = [] - var itemGroupIndexById: [AnyHashable: Int] = [:] - - for (subgroupId, list) in staticEmojiMapping { - let groupId: AnyHashable = "static" - for emojiString in list { - let resultItem = EmojiPagerContentComponent.Item( - file: nil, - staticEmoji: emojiString, - subgroupId: subgroupId.rawValue - ) - - if let groupIndex = itemGroupIndexById[groupId] { - itemGroups[groupIndex].items.append(resultItem) - } else { - itemGroupIndexById[groupId] = itemGroups.count - itemGroups.append(ItemGroup(supergroupId: groupId, id: groupId, isPremium: false, items: [resultItem])) - } - } - } - - for entry in view.entries { - guard let item = entry.item as? StickerPackItem else { - continue - } - let resultItem = EmojiPagerContentComponent.Item( - file: item.file, - staticEmoji: nil, - subgroupId: nil - ) - - let supergroupId = entry.index.collectionId - let groupId: AnyHashable = supergroupId - let isPremium: Bool = item.file.isPremiumEmoji && !hasPremium - if isPremium && isPremiumDisabled { - continue - } - /*if isPremium { - groupId = "\(supergroupId)-p" - } else { - groupId = supergroupId - }*/ - if let groupIndex = itemGroupIndexById[groupId] { - itemGroups[groupIndex].items.append(resultItem) - } else { - itemGroupIndexById[groupId] = itemGroups.count - itemGroups.append(ItemGroup(supergroupId: supergroupId, id: groupId, isPremium: isPremium, items: [resultItem])) - } - } - - return EmojiPagerContentComponent( - id: "emoji", - context: context, - animationCache: animationCache, - animationRenderer: animationRenderer, - inputInteraction: emojiInputInteraction, - itemGroups: itemGroups.map { group -> EmojiPagerContentComponent.ItemGroup in - var title: String? - if group.id == AnyHashable("recent") { - //TODO:localize - title = "Recently Used" - } else { - for (id, info, _) in view.collectionInfos { - if AnyHashable(id) == group.id, let info = info as? StickerPackCollectionInfo { - title = info.title - break - } - } - } - - return EmojiPagerContentComponent.ItemGroup(supergroupId: group.supergroupId, groupId: group.id, title: title, isPremium: group.isPremium, displayPremiumBadges: false, items: group.items) - }, - itemLayoutType: .compact - ) - } + let stickerNamespaces: [ItemCollectionId.Namespace] = [Namespaces.ItemCollection.CloudStickerPacks] + let stickerOrderedItemListCollectionIds: [Int32] = [Namespaces.OrderedItemList.CloudSavedStickers, Namespaces.OrderedItemList.CloudRecentStickers, Namespaces.OrderedItemList.PremiumStickers, Namespaces.OrderedItemList.CloudPremiumStickers] let stickerItems: Signal = combineLatest( - context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: orderedItemListCollectionIds, namespaces: namespaces, aroundIndex: nil, count: 10000000), + context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: stickerOrderedItemListCollectionIds, namespaces: stickerNamespaces, aroundIndex: nil, count: 10000000), hasPremium ) |> map { view, hasPremium -> EmojiPagerContentComponent in @@ -560,22 +569,28 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { return animatedEmojiStickers } - // We are intentionally not subscribing to the recent gif updates here - let gifItems: Signal = context.engine.data.get(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs)) - |> map { savedGifs -> GifPagerContentComponent in - var items: [GifPagerContentComponent.Item] = [] - for gifItem in savedGifs { - items.append(GifPagerContentComponent.Item( - file: gifItem.contents.get(RecentMediaItem.self)!.media - )) + let gifInputInteraction = GifPagerContentComponent.InputInteraction( + performItemAction: { [weak controllerInteraction] item, view, rect in + guard let controllerInteraction = controllerInteraction else { + return + } + let _ = controllerInteraction.sendGif(.savedGif(media: item.file), view, rect, false, false) + }, + openGifContextMenu: { _, _, _, _, _ in + }, + loadMore: { _ in } - return GifPagerContentComponent( - context: context, - inputInteraction: gifInputInteraction, - subject: .recent, - items: items - ) - } + ) + + // We are going to subscribe to the actual data when the view is loaded + let gifItems: Signal = .single(GifPagerContentComponent( + context: context, + inputInteraction: gifInputInteraction, + subject: .recent, + items: [], + isLoading: false, + loadMoreToken: nil + )) return combineLatest(queue: .mainQueue(), emojiItems, @@ -608,7 +623,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { private var currentInputData: InputData private var inputDataDisposable: Disposable? - private let controllerInteraction: ChatControllerInteraction + private let controllerInteraction: ChatControllerInteraction? private var inputNodeInteraction: ChatMediaInputNodeInteraction? @@ -621,16 +636,203 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { return self.externalTopPanelContainerImpl } + var switchToTextInput: (() -> Void)? + private var currentState: (width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, standardInputHeight: CGFloat, inputHeight: CGFloat, maximumHeight: CGFloat, inputPanelHeight: CGFloat, interfaceState: ChatPresentationInterfaceState, deviceMetrics: DeviceMetrics, isVisible: Bool, isExpanded: Bool)? private var gifMode: GifPagerContentComponent.Subject = .recent { didSet { - self.gifModeSubject.set(self.gifMode) + if self.gifMode != oldValue { + self.reloadGifContext() + } } } - private let gifModeSubject: ValuePromise - init(context: AccountContext, currentInputData: InputData, updatedInputData: Signal, defaultToEmojiTab: Bool, controllerInteraction: ChatControllerInteraction) { + private final class GifContext { + private var componentValue: GifPagerContentComponent? { + didSet { + if let componentValue = self.componentValue { + self.componentResult.set(.single(componentValue)) + } + } + } + private let componentPromise = Promise() + + private let componentResult = Promise() + var component: Signal { + return self.componentResult.get() + } + private var componentDisposable: Disposable? + + private let context: AccountContext + private let subject: GifPagerContentComponent.Subject + private let gifInputInteraction: GifPagerContentComponent.InputInteraction + + private var loadingMoreToken: String? + + init(context: AccountContext, subject: GifPagerContentComponent.Subject, gifInputInteraction: GifPagerContentComponent.InputInteraction, trendingGifs: Signal) { + self.context = context + self.subject = subject + self.gifInputInteraction = gifInputInteraction + + let gifItems: Signal + switch subject { + case .recent: + gifItems = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs)) + |> map { savedGifs -> GifPagerContentComponent in + var items: [GifPagerContentComponent.Item] = [] + for gifItem in savedGifs { + items.append(GifPagerContentComponent.Item( + file: gifItem.contents.get(RecentMediaItem.self)!.media + )) + } + return GifPagerContentComponent( + context: context, + inputInteraction: gifInputInteraction, + subject: subject, + items: items, + isLoading: false, + loadMoreToken: nil + ) + } + case .trending: + gifItems = trendingGifs + |> map { trendingGifs -> GifPagerContentComponent in + var items: [GifPagerContentComponent.Item] = [] + + var isLoading = false + if let trendingGifs = trendingGifs { + for file in trendingGifs.files { + items.append(GifPagerContentComponent.Item( + file: file.file.media + )) + } + } else { + isLoading = true + } + + return GifPagerContentComponent( + context: context, + inputInteraction: gifInputInteraction, + subject: subject, + items: items, + isLoading: isLoading, + loadMoreToken: nil + ) + } + case let .emojiSearch(query): + gifItems = paneGifSearchForQuery(context: context, query: query, offset: nil, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil) + |> map { result -> GifPagerContentComponent in + var items: [GifPagerContentComponent.Item] = [] + + var loadMoreToken: String? + var isLoading = false + if let result = result { + for file in result.files { + items.append(GifPagerContentComponent.Item( + file: file.file.media + )) + } + loadMoreToken = result.nextOffset + } else { + isLoading = true + } + + return GifPagerContentComponent( + context: context, + inputInteraction: gifInputInteraction, + subject: subject, + items: items, + isLoading: isLoading, + loadMoreToken: loadMoreToken + ) + } + } + + self.componentPromise.set(gifItems) + self.componentDisposable = (self.componentPromise.get() + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let strongSelf = self else { + return + } + strongSelf.componentValue = result + }) + } + + deinit { + self.componentDisposable?.dispose() + } + + func loadMore(token: String) { + if self.loadingMoreToken == token { + return + } + self.loadingMoreToken = token + + guard let componentValue = self.componentValue else { + return + } + + let context = self.context + let subject = self.subject + let gifInputInteraction = self.gifInputInteraction + + switch self.subject { + case let .emojiSearch(query): + let gifItems: Signal + gifItems = paneGifSearchForQuery(context: context, query: query, offset: token, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil) + |> map { result -> GifPagerContentComponent in + var items: [GifPagerContentComponent.Item] = [] + var existingIds = Set() + for item in componentValue.items { + items.append(item) + existingIds.insert(item.file.fileId) + } + + var loadMoreToken: String? + var isLoading = false + if let result = result { + for file in result.files { + if existingIds.contains(file.file.media.fileId) { + continue + } + existingIds.insert(file.file.media.fileId) + items.append(GifPagerContentComponent.Item(file: file.file.media)) + } + if !result.isComplete { + loadMoreToken = result.nextOffset + } + } else { + isLoading = true + } + + return GifPagerContentComponent( + context: context, + inputInteraction: gifInputInteraction, + subject: subject, + items: items, + isLoading: isLoading, + loadMoreToken: loadMoreToken + ) + } + + self.componentPromise.set(gifItems) + default: + break + } + } + } + private var gifContext: GifContext? { + didSet { + if let gifContext = self.gifContext { + self.gifComponent.set(gifContext.component) + } + } + } + private let gifComponent = Promise() + private var gifInputInteraction: GifPagerContentComponent.InputInteraction? + + init(context: AccountContext, currentInputData: InputData, updatedInputData: Signal, defaultToEmojiTab: Bool, controllerInteraction: ChatControllerInteraction?) { self.context = context self.currentInputData = currentInputData self.defaultToEmojiTab = defaultToEmojiTab @@ -639,17 +841,18 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { self.entityKeyboardView = ComponentHostView() - self.gifModeSubject = ValuePromise(self.gifMode, ignoreRepeated: true) - super.init() + self.topBackgroundExtension = 41.0 + self.followsDefaultHeight = true + self.view.addSubview(self.entityKeyboardView) self.externalTopPanelContainerImpl = PagerExternalTopPanelContainer() self.inputDataDisposable = (combineLatest(queue: .mainQueue(), updatedInputData, - self.updatedGifs() + self.gifComponent.get() ) |> deliverOnMainQueue).start(next: { [weak self] inputData, gifs in guard let strongSelf = self else { @@ -685,6 +888,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } ) + self.trendingGifsPromise.set(.single(nil)) self.trendingGifsPromise.set(paneGifSearchForQuery(context: context, query: "", offset: nil, incompleteResults: true, delayRequest: false, updateActivity: nil) |> map { items -> ChatMediaInputGifPaneTrendingState? in if let items = items { @@ -693,14 +897,8 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { return nil } }) - } - - deinit { - self.inputDataDisposable?.dispose() - } - - private func updatedGifs() -> Signal { - let gifInputInteraction = GifPagerContentComponent.InputInteraction( + + self.gifInputInteraction = GifPagerContentComponent.InputInteraction( performItemAction: { [weak controllerInteraction] item, view, rect in guard let controllerInteraction = controllerInteraction else { return @@ -712,83 +910,35 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { return } strongSelf.openGifContextMenu(file: file, sourceView: sourceView, sourceRect: sourceRect, gesture: gesture, isSaved: isSaved) + }, + loadMore: { [weak self] token in + guard let strongSelf = self, let gifContext = strongSelf.gifContext else { + return + } + gifContext.loadMore(token: token) } ) - let context = self.context - let trendingGifs = self.trendingGifsPromise.get() - let updatedGifs = self.gifModeSubject.get() - |> mapToSignal { subject -> Signal in - switch subject { - case .recent: - let gifItems: Signal = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs)) - |> map { savedGifs -> GifPagerContentComponent in - var items: [GifPagerContentComponent.Item] = [] - for gifItem in savedGifs { - items.append(GifPagerContentComponent.Item( - file: gifItem.contents.get(RecentMediaItem.self)!.media - )) - } - return GifPagerContentComponent( - context: context, - inputInteraction: gifInputInteraction, - subject: subject, - items: items - ) - } - return gifItems - case .trending: - return trendingGifs - |> map { trendingGifs -> GifPagerContentComponent in - var items: [GifPagerContentComponent.Item] = [] - - if let trendingGifs = trendingGifs { - for file in trendingGifs.files { - items.append(GifPagerContentComponent.Item( - file: file.file.media - )) - } - } - - return GifPagerContentComponent( - context: context, - inputInteraction: gifInputInteraction, - subject: subject, - items: items - ) - } - case let .emojiSearch(query): - return paneGifSearchForQuery(context: context, query: query, offset: nil, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil) - |> map { result -> GifPagerContentComponent in - var items: [GifPagerContentComponent.Item] = [] - - /*let canLoadMore: Bool - if let result = result { - canLoadMore = !result.isComplete - } else { - canLoadMore = true - }*/ - - if let result = result { - for file in result.files { - items.append(GifPagerContentComponent.Item( - file: file.file.media - )) - } - } - - return GifPagerContentComponent( - context: context, - inputInteraction: gifInputInteraction, - subject: subject, - items: items - ) - } + self.switchToTextInput = { [weak self] in + guard let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction else { + return + } + controllerInteraction.updateInputMode { _ in + return .text } } - return .single(self.currentInputData.gifs) - |> then(updatedGifs) + self.reloadGifContext() + } + + deinit { + self.inputDataDisposable?.dispose() + } + + private func reloadGifContext() { + if let gifInputInteraction = self.gifInputInteraction { + self.gifContext = GifContext(context: self.context, subject: self.gifMode, gifInputInteraction: gifInputInteraction, trendingGifs: self.trendingGifsPromise.get()) + } } func markInputCollapsed() { @@ -808,7 +958,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { let wasMarkedInputCollapsed = self.isMarkInputCollapsed self.isMarkInputCollapsed = false - let expandedHeight = standardInputHeight + self.expansionFraction * (maximumHeight - standardInputHeight) + let expandedHeight = standardInputHeight var hiddenInputHeight: CGFloat = 0.0 if self.hideInput && !self.adjustLayoutForHiddenInput { @@ -822,18 +972,37 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { var mappedTransition = Transition(transition) - if wasMarkedInputCollapsed { + if wasMarkedInputCollapsed || !isExpanded { mappedTransition = mappedTransition.withUserData(EntityKeyboardComponent.MarkInputCollapsed()) } + var stickerContent: EmojiPagerContentComponent? = self.currentInputData.stickers + var gifContent: GifPagerContentComponent? = self.currentInputData.gifs + + var stickersEnabled = true + if let peer = interfaceState.renderedPeer?.peer as? TelegramChannel { + if peer.hasBannedPermission(.banSendStickers) != nil { + stickersEnabled = false + } + } else if let peer = interfaceState.renderedPeer?.peer as? TelegramGroup { + if peer.hasBannedPermission(.banSendStickers) { + stickersEnabled = false + } + } + + if !stickersEnabled || interfaceState.interfaceState.editMessage != nil { + stickerContent = nil + gifContent = nil + } + let entityKeyboardSize = self.entityKeyboardView.update( transition: mappedTransition, component: AnyComponent(EntityKeyboardComponent( theme: interfaceState.theme, bottomInset: bottomInset, emojiContent: self.currentInputData.emoji, - stickerContent: self.currentInputData.stickers, - gifContent: self.currentInputData.gifs, + stickerContent: stickerContent, + gifContent: gifContent, availableGifSearchEmojies: self.currentInputData.availableGifSearchEmojies, defaultToEmojiTab: self.defaultToEmojiTab, externalTopPanelContainer: self.externalTopPanelContainerImpl, @@ -857,20 +1026,19 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } }, switchToTextInput: { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.controllerInteraction.updateInputMode { _ in - return .text - } + self?.switchToTextInput?() }, switchToGifSubject: { [weak self] subject in guard let strongSelf = self else { return } - strongSelf.gifModeSubject.set(subject) + strongSelf.gifMode = subject }, - makeSearchContainerNode: { content in + makeSearchContainerNode: { [weak controllerInteraction] content in + guard let controllerInteraction = controllerInteraction else { + return nil + } + let mappedMode: ChatMediaInputSearchMode switch content { case .stickers: @@ -935,7 +1103,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { }, action: { _, f in f(.default) if isSaved { - let _ = self?.controllerInteraction.sendGif(FileMediaReference.savedGif(media: file), sourceView, sourceRect, false, false) + let _ = self?.controllerInteraction?.sendGif(FileMediaReference.savedGif(media: file), sourceView, sourceRect, false, false) }/* else if let (collection, result) = file.contextResult { let _ = self?.controllerInteraction.sendBotContextResultAsGif(collection, result, sourceNode, sourceRect, false) }*/ @@ -1002,7 +1170,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { |> deliverOnMainQueue).start(next: { result in switch result { case .generic: - controllerInteraction.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: nil, text: presentationData.strings.Gallery_GifSaved), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil) + controllerInteraction?.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: nil, text: presentationData.strings.Gallery_GifSaved), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil) case let .limitExceeded(limit, premiumLimit): let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let text: String @@ -1011,10 +1179,10 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } else { text = presentationData.strings.Premium_MaxSavedGifsText("\(premiumLimit)").string } - controllerInteraction.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: presentationData.strings.Premium_MaxSavedGifsTitle("\(limit)").string, text: text), elevatedLayout: false, animateInAsReplacement: false, action: { action in + controllerInteraction?.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: presentationData.strings.Premium_MaxSavedGifsTitle("\(limit)").string, text: text), elevatedLayout: false, animateInAsReplacement: false, action: { action in if case .info = action { let controller = PremiumIntroScreen(context: context, source: .savedGifs) - controllerInteraction.navigationController()?.pushViewController(controller) + controllerInteraction?.navigationController()?.pushViewController(controller) return true } return false @@ -1025,7 +1193,7 @@ final class ChatEntityKeyboardInputNode: ChatInputNode { } let contextController = ContextController(account: strongSelf.context.account, presentationData: presentationData, source: .controller(ContextControllerContentSourceImpl(controller: gallery, sourceView: sourceView, sourceRect: sourceRect)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture) - strongSelf.controllerInteraction.presentGlobalOverlayController(contextController, nil) + strongSelf.controllerInteraction?.presentGlobalOverlayController(contextController, nil) }) } } @@ -1063,3 +1231,220 @@ private final class ContextControllerContentSourceImpl: ContextControllerContent } } } + +final class EntityInputView: UIView, AttachmentTextInputPanelInputView, UIInputViewAudioFeedback { + private let context: AccountContext + + public var insertText: ((NSAttributedString) -> Void)? + public var deleteBackwards: (() -> Void)? + public var switchToKeyboard: (() -> Void)? + public var presentController: ((ViewController) -> Void)? + + private var presentationData: PresentationData + private var inputNode: ChatEntityKeyboardInputNode? + private let animationCache: AnimationCache + private let animationRenderer: MultiAnimationRenderer + + init( + context: AccountContext, + isDark: Bool + ) { + self.context = context + + self.animationCache = AnimationCacheImpl(basePath: context.account.postbox.mediaBox.basePath + "/animation-cache", allocateTempFile: { + return TempBox.shared.tempFile(fileName: "file").path + }) + self.animationRenderer = MultiAnimationRendererImpl() + + self.presentationData = context.sharedContext.currentPresentationData.with { $0 } + if isDark { + self.presentationData = self.presentationData.withUpdated(theme: defaultDarkPresentationTheme) + } + + //super.init(frame: CGRect(origin: CGPoint(), size: CGSize(width: 1.0, height: 1.0)), inputViewStyle: .default) + super.init(frame: CGRect(origin: CGPoint(), size: CGSize(width: 1.0, height: 1.0))) + + self.autoresizingMask = [.flexibleWidth, .flexibleHeight] + self.clipsToBounds = true + + let inputInteraction = EmojiPagerContentComponent.InputInteraction( + performItemAction: { [weak self] item, _, _, _ in + guard let strongSelf = self else { + return + } + let hasPremium = strongSelf.context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.context.account.peerId)) + |> map { peer -> Bool in + guard case let .user(user) = peer else { + return false + } + return user.isPremium + } + |> distinctUntilChanged + + let _ = (hasPremium |> take(1) |> deliverOnMainQueue).start(next: { hasPremium in + guard let strongSelf = self else { + return + } + + if let file = item.file { + var text = "." + var emojiAttribute: ChatTextInputTextCustomEmojiAttribute? + loop: for attribute in file.attributes { + switch attribute { + case let .CustomEmoji(_, displayText, packReference): + text = displayText + emojiAttribute = ChatTextInputTextCustomEmojiAttribute(stickerPack: packReference, fileId: file.fileId.id, file: file) + break loop + default: + break + } + } + + if file.isPremiumEmoji && !hasPremium { + //TODO:localize + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + strongSelf.presentController?(UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, title: nil, text: "Subscribe to Telegram Premium to unlock this emoji.", undoText: "More", customAction: { + guard let strongSelf = self else { + return + } + + var replaceImpl: ((ViewController) -> Void)? + let controller = PremiumDemoScreen(context: strongSelf.context, subject: .premiumStickers, action: { + let controller = PremiumIntroScreen(context: strongSelf.context, source: .stickers) + replaceImpl?(controller) + }) + replaceImpl = { [weak controller] c in + controller?.replace(with: c) + } + strongSelf.presentController?(controller) + }), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false })) + return + } + + if let emojiAttribute = emojiAttribute { + AudioServicesPlaySystemSound(0x450) + strongSelf.insertText?(NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: emojiAttribute])) + } + } else if let staticEmoji = item.staticEmoji { + AudioServicesPlaySystemSound(0x450) + strongSelf.insertText?(NSAttributedString(string: staticEmoji, attributes: [:])) + } + }) + }, + deleteBackwards: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.deleteBackwards?() + }, + openStickerSettings: { + }, + openPremiumSection: { + }, + pushController: { _ in + }, + presentController: { _ in + }, + presentGlobalOverlayController: { _ in + }, + navigationController: { + return nil + }, + sendSticker: nil, + chatPeerId: nil + ) + + let semaphore = DispatchSemaphore(value: 0) + var emojiComponent: EmojiPagerContentComponent? + let _ = ChatEntityKeyboardInputNode.emojiInputData(context: context, inputInteraction: inputInteraction, animationCache: self.animationCache, animationRenderer: self.animationRenderer).start(next: { value in + emojiComponent = value + semaphore.signal() + }) + semaphore.wait() + + if let emojiComponent = emojiComponent { + let inputNode = ChatEntityKeyboardInputNode( + context: self.context, + currentInputData: ChatEntityKeyboardInputNode.InputData( + emoji: emojiComponent, + stickers: nil, + gifs: nil, + availableGifSearchEmojies: [] + ), + updatedInputData: .never(), + defaultToEmojiTab: true, + controllerInteraction: nil + ) + self.inputNode = inputNode + inputNode.externalTopPanelContainerImpl = nil + inputNode.switchToTextInput = { [weak self] in + self?.switchToKeyboard?() + } + self.addSubnode(inputNode) + } + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + guard let inputNode = self.inputNode else { + return + } + + for view in self.subviews { + if view !== inputNode.view { + view.isHidden = true + } + } + + let bottomInset: CGFloat + if #available(iOS 11.0, *) { + bottomInset = max(0.0, UIScreen.main.bounds.height - (self.window?.safeAreaLayoutGuide.layoutFrame.maxY ?? 10000.0)) + } else { + bottomInset = 0.0 + } + + let presentationInterfaceState = ChatPresentationInterfaceState( + chatWallpaper: .builtin(WallpaperSettings()), + theme: self.presentationData.theme, + strings: self.presentationData.strings, + dateTimeFormat: self.presentationData.dateTimeFormat, + nameDisplayOrder: self.presentationData.nameDisplayOrder, + limitsConfiguration: self.context.currentLimitsConfiguration.with { $0 }, + fontSize: self.presentationData.chatFontSize, + bubbleCorners: self.presentationData.chatBubbleCorners, + accountPeerId: self.context.account.peerId, + mode: .standard(previewing: false), + chatLocation: .peer(id: self.context.account.peerId), + subject: nil, + peerNearbyData: nil, + greetingData: nil, + pendingUnpinnedAllMessages: false, + activeGroupCallInfo: nil, + hasActiveGroupCall: false, + importState: nil + ) + + let _ = inputNode.updateLayout( + width: self.bounds.width, + leftInset: 0.0, + rightInset: 0.0, + bottomInset: bottomInset, + standardInputHeight: self.bounds.height, + inputHeight: self.bounds.height, + maximumHeight: self.bounds.height, + inputPanelHeight: 0.0, + transition: .immediate, + interfaceState: presentationInterfaceState, + deviceMetrics: DeviceMetrics.iPhone12, + isVisible: true, + isExpanded: false + ) + inputNode.frame = self.bounds + } +} diff --git a/submodules/TelegramUI/Sources/ChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/ChatInputContextPanelNode.swift index 9cf93a9c9c..d669d813d4 100644 --- a/submodules/TelegramUI/Sources/ChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatInputContextPanelNode.swift @@ -20,7 +20,7 @@ class ChatInputContextPanelNode: ASDisplayNode { var theme: PresentationTheme var fontSize: PresentationFontSize - init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize) { + init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.context = context self.theme = theme self.fontSize = fontSize diff --git a/submodules/TelegramUI/Sources/ChatInputNode.swift b/submodules/TelegramUI/Sources/ChatInputNode.swift index 311f6a0a8d..1aa38814f0 100644 --- a/submodules/TelegramUI/Sources/ChatInputNode.swift +++ b/submodules/TelegramUI/Sources/ChatInputNode.swift @@ -15,15 +15,14 @@ class ChatInputNode: ASDisplayNode { return nil } - var topBackgroundExtension: CGFloat = 41.0 + var topBackgroundExtension: CGFloat = 0.0 var topBackgroundExtensionUpdated: ((ContainedViewLayoutTransition) -> Void)? var hideInput: Bool = false var adjustLayoutForHiddenInput: Bool = false var hideInputUpdated: ((ContainedViewLayoutTransition) -> Void)? - var expansionFraction: CGFloat = 0.0 - var expansionFractionUpdated: ((ContainedViewLayoutTransition) -> Void)? + var followsDefaultHeight: Bool = false func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, standardInputHeight: CGFloat, inputHeight: CGFloat, maximumHeight: CGFloat, inputPanelHeight: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState, deviceMetrics: DeviceMetrics, isVisible: Bool, isExpanded: Bool) -> (CGFloat, CGFloat) { return (0.0, 0.0) diff --git a/submodules/TelegramUI/Sources/ChatInterfaceInputContextPanels.swift b/submodules/TelegramUI/Sources/ChatInterfaceInputContextPanels.swift index ddc5922a60..a41ea42c4f 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceInputContextPanels.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceInputContextPanels.swift @@ -25,7 +25,7 @@ private func inputQueryResultPriority(_ result: ChatPresentationInputQueryResult } } -func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentPanel: ChatInputContextPanelNode?, controllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?) -> ChatInputContextPanelNode? { +func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentPanel: ChatInputContextPanelNode?, controllerInteraction: ChatControllerInteraction, interfaceInteraction: ChatPanelInterfaceInteraction?, chatPresentationContext: ChatPresentationContext) -> ChatInputContextPanelNode? { guard let _ = chatPresentationInterfaceState.renderedPeer?.peer else { return nil } @@ -34,7 +34,7 @@ func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfa if let currentPanel = currentPanel as? CommandMenuChatInputContextPanelNode { return currentPanel } else { - let panel = CommandMenuChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, peerId: renderedPeer.peerId) + let panel = CommandMenuChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, peerId: renderedPeer.peerId, chatPresentationContext: chatPresentationContext) panel.interfaceInteraction = interfaceInteraction return panel } @@ -68,7 +68,7 @@ func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfa if let currentPanel = currentPanel as? DisabledContextResultsChatInputContextPanelNode { return currentPanel } else { - let panel = DisabledContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize) + let panel = DisabledContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext) panel.interfaceInteraction = interfaceInteraction return panel } @@ -86,7 +86,7 @@ func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfa currentPanel.updateResults(results: results.map({ $0.file }), query: query) return currentPanel } else { - let panel = InlineReactionSearchPanel(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, peerId: chatPresentationInterfaceState.renderedPeer?.peerId) + let panel = InlineReactionSearchPanel(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, peerId: chatPresentationInterfaceState.renderedPeer?.peerId, chatPresentationContext: chatPresentationContext) panel.controllerInteraction = controllerInteraction panel.interfaceInteraction = interfaceInteraction panel.updateResults(results: results.map({ $0.file }), query: query) @@ -99,7 +99,7 @@ func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfa currentPanel.updateResults(results) return currentPanel } else { - let panel = HashtagChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize) + let panel = HashtagChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext) panel.interfaceInteraction = interfaceInteraction panel.updateResults(results) return panel @@ -111,7 +111,7 @@ func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfa currentPanel.updateResults(results) return currentPanel } else { - let panel = EmojisChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize) + let panel = EmojisChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: chatPresentationContext) panel.interfaceInteraction = interfaceInteraction panel.updateResults(results) return panel @@ -123,7 +123,7 @@ func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfa currentPanel.updateResults(peers) return currentPanel } else { - let panel = MentionChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, mode: .input) + let panel = MentionChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, mode: .input, chatPresentationContext: chatPresentationContext) panel.interfaceInteraction = interfaceInteraction panel.updateResults(peers) return panel @@ -137,7 +137,7 @@ func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfa currentPanel.updateResults(commands) return currentPanel } else { - let panel = CommandChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize) + let panel = CommandChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext) panel.interfaceInteraction = interfaceInteraction panel.updateResults(commands) return panel @@ -153,7 +153,7 @@ func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfa currentPanel.updateResults(results) return currentPanel } else { - let panel = VerticalListContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize) + let panel = VerticalListContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext) panel.interfaceInteraction = interfaceInteraction panel.updateResults(results) return panel @@ -163,7 +163,7 @@ func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfa currentPanel.updateResults(results) return currentPanel } else { - let panel = HorizontalListContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize) + let panel = HorizontalListContextResultsChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, chatPresentationContext: controllerInteraction.presentationContext) panel.interfaceInteraction = interfaceInteraction panel.updateResults(results) return panel @@ -177,7 +177,7 @@ func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfa return nil } -func chatOverlayContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentPanel: ChatInputContextPanelNode?, interfaceInteraction: ChatPanelInterfaceInteraction?) -> ChatInputContextPanelNode? { +func chatOverlayContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentPanel: ChatInputContextPanelNode?, interfaceInteraction: ChatPanelInterfaceInteraction?, chatPresentationContext: ChatPresentationContext) -> ChatInputContextPanelNode? { guard let searchQuerySuggestionResult = chatPresentationInterfaceState.searchQuerySuggestionResult, let _ = chatPresentationInterfaceState.renderedPeer?.peer else { return nil } @@ -189,7 +189,7 @@ func chatOverlayContextPanelForChatPresentationIntefaceState(_ chatPresentationI currentPanel.updateResults(peers) return currentPanel } else { - let panel = MentionChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, mode: .search) + let panel = MentionChatInputContextPanelNode(context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, fontSize: chatPresentationInterfaceState.fontSize, mode: .search, chatPresentationContext: chatPresentationContext) panel.interfaceInteraction = interfaceInteraction panel.updateResults(peers) return panel diff --git a/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift b/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift index 3e8289094b..7c88fd6fb8 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift @@ -272,15 +272,8 @@ func inputTextPanelStateForChatPresentationInterfaceState(_ chatPresentationInte case .inputButtons: return ChatTextInputPanelState(accessoryItems: [.keyboard], contextPlaceholder: contextPlaceholder, mediaRecordingState: chatPresentationInterfaceState.inputTextPanelState.mediaRecordingState) case .none, .text: - if let editMessage = chatPresentationInterfaceState.interfaceState.editMessage { - let isTextEmpty = editMessage.inputState.inputText.length == 0 - - let stickersAreEmoji = !isTextEmpty - - var stickersEnabled = true - stickersEnabled = true - - accessoryItems.append(.stickers(isEnabled: stickersEnabled, isEmoji: stickersAreEmoji)) + if let _ = chatPresentationInterfaceState.interfaceState.editMessage { + accessoryItems.append(.stickers(isEnabled: true, isEmoji: true)) return ChatTextInputPanelState(accessoryItems: accessoryItems, contextPlaceholder: contextPlaceholder, mediaRecordingState: chatPresentationInterfaceState.inputTextPanelState.mediaRecordingState) } else { @@ -330,7 +323,11 @@ func inputTextPanelStateForChatPresentationInterfaceState(_ chatPresentationInte accessoryItems.append(.commands) } - accessoryItems.append(.stickers(isEnabled: stickersEnabled, isEmoji: stickersAreEmoji)) + if stickersEnabled { + accessoryItems.append(.stickers(isEnabled: true, isEmoji: stickersAreEmoji)) + } else { + accessoryItems.append(.stickers(isEnabled: true, isEmoji: true)) + } if isTextEmpty, let message = chatPresentationInterfaceState.keyboardButtonsMessage, let _ = message.visibleButtonKeyboardMarkup, chatPresentationInterfaceState.interfaceState.messageActionsState.dismissedButtonKeyboardMessageId != message.id { accessoryItems.append(.inputButtons) diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift index 6a5ce76378..b4dc54ec9c 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift @@ -341,21 +341,61 @@ private func updatedContextQueryResultStateForQuery(context: AccountContext, pee ) } } + + let hasPremium = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) + |> map { peer -> Bool in + guard case let .user(user) = peer else { + return false + } + return user.isPremium + } + |> distinctUntilChanged return signal - |> map { keywords -> [(String, String)] in - var result: [(String, String)] = [] - for keyword in keywords { - for emoticon in keyword.emoticons { - result.append((emoticon, keyword.keyword)) - } - } - return result - } - |> map { result -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in - return { _ in return .emojis(result, range) } - } |> castError(ChatContextQueryError.self) + |> mapToSignal { keywords -> Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> in + return combineLatest( + context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000), + hasPremium + ) + |> map { view, hasPremium -> [(String, TelegramMediaFile?, String)] in + var result: [(String, TelegramMediaFile?, String)] = [] + + var allEmoticons: [String: String] = [:] + for keyword in keywords { + for emoticon in keyword.emoticons { + allEmoticons[emoticon] = keyword.keyword + } + } + + for entry in view.entries { + guard let item = entry.item as? StickerPackItem else { + continue + } + for attribute in item.file.attributes { + switch attribute { + case let .CustomEmoji(_, alt, _): + if !alt.isEmpty, let keyword = allEmoticons[alt] { + result.append((alt, item.file, keyword)) + } + default: + break + } + } + } + + for keyword in keywords { + for emoticon in keyword.emoticons { + result.append((emoticon, nil, keyword.keyword)) + } + } + return result + } + |> map { result -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in + return { _ in return .emojis(result, range) } + } + |> castError(ChatContextQueryError.self) + } } } diff --git a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift index 6d1a5550fe..2df9ca3c88 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift @@ -49,7 +49,7 @@ private final class CachedChatMessageText { class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { private let textNode: TextNodeWithEntities - private var spoilerTextNode: TextNode? + private var spoilerTextNode: TextNodeWithEntities? private var dustNode: InvisibleInkDustNode? private let textAccessibilityOverlayNode: TextAccessibilityOverlayNode @@ -67,11 +67,13 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { switch self.visibility { case .none: self.textNode.visibilityRect = nil + self.spoilerTextNode?.visibilityRect = nil case let .visible(_, subRect): var subRect = subRect subRect.origin.x = 0.0 subRect.size.width = 10000.0 self.textNode.visibilityRect = subRect + self.spoilerTextNode?.visibilityRect = subRect } } } @@ -120,7 +122,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { override func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { let textLayout = TextNodeWithEntities.asyncLayout(self.textNode) - let spoilerTextLayout = TextNode.asyncLayout(self.spoilerTextNode) + let spoilerTextLayout = TextNodeWithEntities.asyncLayout(self.spoilerTextNode) let statusLayout = self.statusNode.asyncLayout() let currentCachedChatMessageText = self.cachedChatMessageText @@ -339,9 +341,9 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { let (textLayout, textApply) = textLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: cutout, insets: textInsets, lineColor: messageTheme.accentControlColor)) - let spoilerTextLayoutAndApply: (TextNodeLayout, () -> TextNode)? + let spoilerTextLayoutAndApply: (TextNodeLayout, (TextNodeWithEntities.Arguments?) -> TextNodeWithEntities)? if !textLayout.spoilers.isEmpty { - spoilerTextLayoutAndApply = spoilerTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: cutout, insets: textInsets, lineColor: messageTheme.accentControlColor, displaySpoilers: true)) + spoilerTextLayoutAndApply = spoilerTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: cutout, insets: textInsets, lineColor: messageTheme.accentControlColor, displaySpoilers: true, displayEmbeddedItemsUnderSpoilers: true)) } else { spoilerTextLayoutAndApply = nil } @@ -440,33 +442,33 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { animation.animator.updateFrame(layer: strongSelf.textNode.textNode.layer, frame: textFrame, completion: nil) if let (_, spoilerTextApply) = spoilerTextLayoutAndApply { - let spoilerTextNode = spoilerTextApply() + let spoilerTextNode = spoilerTextApply(TextNodeWithEntities.Arguments(context: item.context, cache: item.controllerInteraction.presentationContext.animationCache, renderer: item.controllerInteraction.presentationContext.animationRenderer, placeholderColor: messageTheme.mediaPlaceholderColor, attemptSynchronous: synchronousLoads)) if strongSelf.spoilerTextNode == nil { - spoilerTextNode.alpha = 0.0 - spoilerTextNode.isUserInteractionEnabled = false - spoilerTextNode.contentMode = .topLeft - spoilerTextNode.contentsScale = UIScreenScale - spoilerTextNode.displaysAsynchronously = false - strongSelf.insertSubnode(spoilerTextNode, aboveSubnode: strongSelf.textAccessibilityOverlayNode) + spoilerTextNode.textNode.alpha = 0.0 + spoilerTextNode.textNode.isUserInteractionEnabled = false + spoilerTextNode.textNode.contentMode = .topLeft + spoilerTextNode.textNode.contentsScale = UIScreenScale + spoilerTextNode.textNode.displaysAsynchronously = false + strongSelf.insertSubnode(spoilerTextNode.textNode, aboveSubnode: strongSelf.textAccessibilityOverlayNode) strongSelf.spoilerTextNode = spoilerTextNode } - strongSelf.spoilerTextNode?.frame = textFrame + strongSelf.spoilerTextNode?.textNode.frame = textFrame let dustNode: InvisibleInkDustNode if let current = strongSelf.dustNode { dustNode = current } else { - dustNode = InvisibleInkDustNode(textNode: spoilerTextNode) + dustNode = InvisibleInkDustNode(textNode: spoilerTextNode.textNode) strongSelf.dustNode = dustNode - strongSelf.insertSubnode(dustNode, aboveSubnode: spoilerTextNode) + strongSelf.insertSubnode(dustNode, aboveSubnode: spoilerTextNode.textNode) } dustNode.frame = textFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 3.0) dustNode.update(size: dustNode.frame.size, color: messageTheme.secondaryTextColor, textColor: messageTheme.primaryTextColor, rects: textLayout.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: textLayout.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }) } else if let spoilerTextNode = strongSelf.spoilerTextNode { strongSelf.spoilerTextNode = nil - spoilerTextNode.removeFromSupernode() + spoilerTextNode.textNode.removeFromSupernode() if let dustNode = strongSelf.dustNode { strongSelf.dustNode = nil @@ -474,6 +476,18 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } } + switch strongSelf.visibility { + case .none: + strongSelf.textNode.visibilityRect = nil + strongSelf.spoilerTextNode?.visibilityRect = nil + case let .visible(_, subRect): + var subRect = subRect + subRect.origin.x = 0.0 + subRect.size.width = 10000.0 + strongSelf.textNode.visibilityRect = subRect + strongSelf.spoilerTextNode?.visibilityRect = subRect + } + if let textSelectionNode = strongSelf.textSelectionNode { let shouldUpdateLayout = textSelectionNode.frame.size != textFrame.size textSelectionNode.frame = textFrame diff --git a/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift b/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift index cfb4bb135a..c07d3aa24c 100644 --- a/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatPinnedMessageTitlePanelNode.swift @@ -56,7 +56,7 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { private let lineNode: AnimatedNavigationStripeNode private let titleNode: AnimatedCountLabelNode private let textNode: TextNodeWithEntities - private var spoilerTextNode: TextNode? + private var spoilerTextNode: TextNodeWithEntities? private var dustNode: InvisibleInkDustNode? private let actionButton: HighlightableButtonNode private let actionButtonTitleNode: ImmediateTextNode @@ -525,7 +525,7 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { let makeTitleLayout = self.titleNode.asyncLayout() let makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode) - let makeSpoilerTextLayout = TextNode.asyncLayout(self.spoilerTextNode) + let makeSpoilerTextLayout = TextNodeWithEntities.asyncLayout(self.spoilerTextNode) let imageNodeLayout = self.imageNode.asyncLayout() let previousMediaReference = self.previousMediaReference @@ -668,9 +668,9 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { let textConstrainedSize = CGSize(width: width - textLineInset - contentLeftInset - rightInset - textRightInset, height: CGFloat.greatestFiniteMagnitude) let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: messageText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 0.0, bottom: 2.0, right: 0.0))) - let spoilerTextLayoutAndApply: (TextNodeLayout, () -> TextNode)? + let spoilerTextLayoutAndApply: (TextNodeLayout, (TextNodeWithEntities.Arguments?) -> TextNodeWithEntities)? if !textLayout.spoilers.isEmpty { - spoilerTextLayoutAndApply = makeSpoilerTextLayout(TextNodeLayoutArguments(attributedString: messageText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 0.0, bottom: 2.0, right: 0.0), displaySpoilers: true)) + spoilerTextLayoutAndApply = makeSpoilerTextLayout(TextNodeLayoutArguments(attributedString: messageText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 0.0, bottom: 2.0, right: 0.0), displaySpoilers: true, displayEmbeddedItemsUnderSpoilers: true)) } else { spoilerTextLayoutAndApply = nil } @@ -701,33 +701,33 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { strongSelf.textNode.textNode.frame = textFrame if let (_, spoilerTextApply) = spoilerTextLayoutAndApply { - let spoilerTextNode = spoilerTextApply() + let spoilerTextNode = spoilerTextApply(textArguments) if strongSelf.spoilerTextNode == nil { - spoilerTextNode.alpha = 0.0 - spoilerTextNode.isUserInteractionEnabled = false - spoilerTextNode.contentMode = .topLeft - spoilerTextNode.contentsScale = UIScreenScale - spoilerTextNode.displaysAsynchronously = false - strongSelf.contentTextContainer.insertSubnode(spoilerTextNode, aboveSubnode: strongSelf.textNode.textNode) + spoilerTextNode.textNode.alpha = 0.0 + spoilerTextNode.textNode.isUserInteractionEnabled = false + spoilerTextNode.textNode.contentMode = .topLeft + spoilerTextNode.textNode.contentsScale = UIScreenScale + spoilerTextNode.textNode.displaysAsynchronously = false + strongSelf.contentTextContainer.insertSubnode(spoilerTextNode.textNode, aboveSubnode: strongSelf.textNode.textNode) strongSelf.spoilerTextNode = spoilerTextNode } - strongSelf.spoilerTextNode?.frame = textFrame + strongSelf.spoilerTextNode?.textNode.frame = textFrame let dustNode: InvisibleInkDustNode if let current = strongSelf.dustNode { dustNode = current } else { - dustNode = InvisibleInkDustNode(textNode: spoilerTextNode) + dustNode = InvisibleInkDustNode(textNode: spoilerTextNode.textNode) strongSelf.dustNode = dustNode - strongSelf.contentTextContainer.insertSubnode(dustNode, aboveSubnode: spoilerTextNode) + strongSelf.contentTextContainer.insertSubnode(dustNode, aboveSubnode: spoilerTextNode.textNode) } dustNode.frame = textFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 3.0) dustNode.update(size: dustNode.frame.size, color: theme.chat.inputPanel.secondaryTextColor, textColor: theme.chat.inputPanel.primaryTextColor, rects: textLayout.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: textLayout.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }) } else if let spoilerTextNode = strongSelf.spoilerTextNode { strongSelf.spoilerTextNode = nil - spoilerTextNode.removeFromSupernode() + spoilerTextNode.textNode.removeFromSupernode() if let dustNode = strongSelf.dustNode { strongSelf.dustNode = nil @@ -735,6 +735,9 @@ final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { } } + strongSelf.textNode.visibilityRect = CGRect.infinite + strongSelf.spoilerTextNode?.visibilityRect = CGRect.infinite + let lineFrame = CGRect(origin: CGPoint(x: contentLeftInset, y: 0.0), size: CGSize(width: 2.0, height: panelHeight)) animationTransition.updateFrame(node: strongSelf.lineNode, frame: lineFrame) strongSelf.lineNode.update( diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index f7de318cab..692d82c6a2 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -619,10 +619,13 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { self.menuButton.cornerRadius = 16.0 self.menuButton.accessibilityLabel = presentationInterfaceState.strings.Conversation_InputMenu self.menuButtonBackgroundNode = ASDisplayNode() + self.menuButtonBackgroundNode.isUserInteractionEnabled = false self.menuButtonClippingNode = ASDisplayNode() self.menuButtonClippingNode.clipsToBounds = true + self.menuButtonClippingNode.isUserInteractionEnabled = false self.menuButtonIconNode = MenuIconNode() + self.menuButtonIconNode.isUserInteractionEnabled = false self.menuButtonIconNode.customColor = presentationInterfaceState.theme.chat.inputPanel.actionControlForegroundColor self.menuButtonTextNode = ImmediateTextNode() @@ -799,7 +802,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { return UIView() } - return EmojiTextAttachmentView(context: context, emoji: emoji, file: emoji.file, cache: presentationContext.animationCache, renderer: presentationContext.animationRenderer, placeholderColor: presentationInterfaceState.theme.chat.inputPanel.inputTextColor.withAlphaComponent(0.12)) + return EmojiTextAttachmentView(context: context, emoji: emoji, file: emoji.file, cache: presentationContext.animationCache, renderer: presentationContext.animationRenderer, placeholderColor: presentationInterfaceState.theme.chat.inputPanel.inputTextColor.withAlphaComponent(0.12), pointSize: CGSize(width: 24.0, height: 24.0)) } } } @@ -1782,6 +1785,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { transition.updateAlpha(node: self.textInputContainer, alpha: audioRecordingItemsAlpha) if let textInputNode = self.textInputNode { + textInputNode.textContainerInset = textInputViewRealInsets let textFieldFrame = CGRect(origin: CGPoint(x: self.textInputViewInternalInsets.left, y: self.textInputViewInternalInsets.top), size: CGSize(width: textInputFrame.size.width - (self.textInputViewInternalInsets.left + self.textInputViewInternalInsets.right), height: textInputFrame.size.height - self.textInputViewInternalInsets.top - textInputViewInternalInsets.bottom)) let shouldUpdateLayout = textFieldFrame.size != textInputNode.frame.size transition.updateFrame(node: textInputNode, frame: textFieldFrame) @@ -2069,7 +2073,9 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { let endIndex = currentIndex addSpoiler(startIndex: currentStartIndex, endIndex: endIndex) } - } else if let value = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute { + } + + if let value = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute { if let start = textInputNode.textView.position(from: beginning, offset: range.location), let end = textInputNode.textView.position(from: start, offset: range.length), let textRange = textInputNode.textView.textRange(from: start, to: end) { let textRects = textInputNode.textView.selectionRects(for: textRange) for textRect in textRects { diff --git a/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift index a3aa3b2f4e..8b9f2f0345 100644 --- a/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift @@ -63,7 +63,7 @@ final class CommandChatInputContextPanelNode: ChatInputContextPanelNode { private var enqueuedTransitions: [(CommandChatInputContextPanelTransition, Bool)] = [] private var validLayout: (CGSize, CGFloat, CGFloat, CGFloat)? - override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize) { + override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.listView = ListView() self.listView.isOpaque = false self.listView.stackFromBottom = true @@ -74,7 +74,7 @@ final class CommandChatInputContextPanelNode: ChatInputContextPanelNode { return strings.VoiceOver_ScrollStatus(row, count).string } - super.init(context: context, theme: theme, strings: strings, fontSize: fontSize) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false self.clipsToBounds = true diff --git a/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift index c3d69c08db..de6d79d3bc 100644 --- a/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift @@ -66,7 +66,7 @@ final class CommandMenuChatInputContextPanelNode: ChatInputContextPanelNode { private let disposable = MetaDisposable() - init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, peerId: PeerId) { + init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, peerId: PeerId, chatPresentationContext: ChatPresentationContext) { self.listView = ListView() self.listView.clipsToBounds = false self.listView.isOpaque = false @@ -77,7 +77,7 @@ final class CommandMenuChatInputContextPanelNode: ChatInputContextPanelNode { return strings.VoiceOver_ScrollStatus(row, count).string } - super.init(context: context, theme: theme, strings: strings, fontSize: fontSize) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false self.clipsToBounds = true diff --git a/submodules/TelegramUI/Sources/DisabledContextResultsChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/DisabledContextResultsChatInputContextPanelNode.swift index c5e77562fe..8a558045eb 100644 --- a/submodules/TelegramUI/Sources/DisabledContextResultsChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/DisabledContextResultsChatInputContextPanelNode.swift @@ -16,14 +16,14 @@ final class DisabledContextResultsChatInputContextPanelNode: ChatInputContextPan private var validLayout: (CGSize, CGFloat, CGFloat, CGFloat)? - override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize) { + override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.containerNode = ASDisplayNode() self.separatorNode = ASDisplayNode() self.textNode = ImmediateTextNode() self.textNode.maximumNumberOfLines = 0 self.textNode.textAlignment = .center - super.init(context: context, theme: theme, strings: strings, fontSize: fontSize) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false self.clipsToBounds = true diff --git a/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift index f9b39020be..c94ca1f172 100644 --- a/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift @@ -10,9 +10,13 @@ import MergeLists import AccountContext import Emoji import ChatPresentationInterfaceState +import AnimationCache +import MultiAnimationRenderer +import TextFormat -private struct EmojisChatInputContextPanelEntryStableId: Hashable, Equatable { - let symbol: String +private enum EmojisChatInputContextPanelEntryStableId: Hashable, Equatable { + case symbol(String) + case media(MediaId) } private func backgroundCenterImage(_ theme: PresentationTheme) -> UIImage? { @@ -55,25 +59,30 @@ private struct EmojisChatInputContextPanelEntry: Comparable, Identifiable { let theme: PresentationTheme let symbol: String let text: String + let file: TelegramMediaFile? var stableId: EmojisChatInputContextPanelEntryStableId { - return EmojisChatInputContextPanelEntryStableId(symbol: self.symbol) + if let file = self.file { + return .media(file.fileId) + } else { + return .symbol(self.symbol) + } } func withUpdatedTheme(_ theme: PresentationTheme) -> EmojisChatInputContextPanelEntry { - return EmojisChatInputContextPanelEntry(index: self.index, theme: theme, symbol: self.symbol, text: self.text) + return EmojisChatInputContextPanelEntry(index: self.index, theme: theme, symbol: self.symbol, text: self.text, file: self.file) } static func ==(lhs: EmojisChatInputContextPanelEntry, rhs: EmojisChatInputContextPanelEntry) -> Bool { - return lhs.index == rhs.index && lhs.symbol == rhs.symbol && lhs.text == rhs.text && lhs.theme === rhs.theme + return lhs.index == rhs.index && lhs.symbol == rhs.symbol && lhs.text == rhs.text && lhs.theme === rhs.theme && lhs.file?.fileId == rhs.file?.fileId } static func <(lhs: EmojisChatInputContextPanelEntry, rhs: EmojisChatInputContextPanelEntry) -> Bool { return lhs.index < rhs.index } - func item(account: Account, emojiSelected: @escaping (String) -> Void) -> ListViewItem { - return EmojisChatInputPanelItem(theme: self.theme, symbol: self.symbol, text: self.text, emojiSelected: emojiSelected) + func item(context: AccountContext, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, emojiSelected: @escaping (String, TelegramMediaFile?) -> Void) -> ListViewItem { + return EmojisChatInputPanelItem(context: context, theme: self.theme, symbol: self.symbol, text: self.text, file: self.file, animationCache: animationCache, animationRenderer: animationRenderer, emojiSelected: emojiSelected) } } @@ -83,12 +92,12 @@ private struct EmojisChatInputContextPanelTransition { let updates: [ListViewUpdateItem] } -private func preparedTransition(from fromEntries: [EmojisChatInputContextPanelEntry], to toEntries: [EmojisChatInputContextPanelEntry], account: Account, emojiSelected: @escaping (String) -> Void) -> EmojisChatInputContextPanelTransition { +private func preparedTransition(from fromEntries: [EmojisChatInputContextPanelEntry], to toEntries: [EmojisChatInputContextPanelEntry], context: AccountContext, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, emojiSelected: @escaping (String, TelegramMediaFile?) -> Void) -> EmojisChatInputContextPanelTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } - let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, emojiSelected: emojiSelected), directionHint: nil) } - let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, emojiSelected: emojiSelected), directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, animationCache: animationCache, animationRenderer: animationRenderer, emojiSelected: emojiSelected), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, animationCache: animationCache, animationRenderer: animationRenderer, emojiSelected: emojiSelected), directionHint: nil) } return EmojisChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates) } @@ -106,7 +115,13 @@ final class EmojisChatInputContextPanelNode: ChatInputContextPanelNode { private var validLayout: (CGSize, CGFloat, CGFloat, CGFloat)? private var presentationInterfaceState: ChatPresentationInterfaceState? - override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize) { + private let animationCache: AnimationCache + private let animationRenderer: MultiAnimationRenderer + + override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { + self.animationCache = chatPresentationContext.animationCache + self.animationRenderer = chatPresentationContext.animationRenderer + self.backgroundNode = ASImageNode() self.backgroundNode.displayWithoutProcessing = true self.backgroundNode.displaysAsynchronously = false @@ -134,7 +149,7 @@ final class EmojisChatInputContextPanelNode: ChatInputContextPanelNode { return strings.VoiceOver_ScrollStatus(row, count).string } - super.init(context: context, theme: theme, strings: strings, fontSize: fontSize) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.placement = .overTextInput self.isOpaque = false @@ -147,12 +162,12 @@ final class EmojisChatInputContextPanelNode: ChatInputContextPanelNode { self.clippingNode.addSubnode(self.listView) } - func updateResults(_ results: [(String, String)]) { + func updateResults(_ results: [(String, TelegramMediaFile?, String)]) { var entries: [EmojisChatInputContextPanelEntry] = [] var index = 0 var stableIds = Set() - for (symbol, text) in results { - let entry = EmojisChatInputContextPanelEntry(index: index, theme: self.theme, symbol: symbol.normalizedEmoji, text: text) + for (symbol, file, text) in results { + let entry = EmojisChatInputContextPanelEntry(index: index, theme: self.theme, symbol: symbol.normalizedEmoji, text: text, file: file) if stableIds.contains(entry.stableId) { continue } @@ -165,33 +180,54 @@ final class EmojisChatInputContextPanelNode: ChatInputContextPanelNode { private func prepareTransition(from: [EmojisChatInputContextPanelEntry]? , to: [EmojisChatInputContextPanelEntry]) { let firstTime = self.currentEntries == nil - let transition = preparedTransition(from: from ?? [], to: to, account: self.context.account, emojiSelected: { [weak self] text in - if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction { - interfaceInteraction.updateTextInputStateAndMode { textInputState, inputMode in - var hashtagQueryRange: NSRange? - inner: for (range, type, _) in textInputStateContextQueryRangeAndType(textInputState) { - if type == [.emojiSearch] { - var range = range - range.location -= 1 - range.length += 1 - hashtagQueryRange = range - break inner + let transition = preparedTransition(from: from ?? [], to: to, context: self.context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, emojiSelected: { [weak self] text, file in + guard let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction else { + return + } + + var text = text + + interfaceInteraction.updateTextInputStateAndMode { textInputState, inputMode in + var hashtagQueryRange: NSRange? + inner: for (range, type, _) in textInputStateContextQueryRangeAndType(textInputState) { + if type == [.emojiSearch] { + var range = range + range.location -= 1 + range.length += 1 + hashtagQueryRange = range + break inner + } + } + + if let range = hashtagQueryRange { + let inputText = NSMutableAttributedString(attributedString: textInputState.inputText) + + var emojiAttribute: ChatTextInputTextCustomEmojiAttribute? + if let file = file { + loop: for attribute in file.attributes { + switch attribute { + case let .CustomEmoji(_, displayText, packReference): + text = displayText + emojiAttribute = ChatTextInputTextCustomEmojiAttribute(stickerPack: packReference, fileId: file.fileId.id, file: file) + break loop + default: + break + } } } - if let range = hashtagQueryRange { - let inputText = NSMutableAttributedString(attributedString: textInputState.inputText) - - let replacementText = text - - inputText.replaceCharacters(in: range, with: replacementText) - - let selectionPosition = range.lowerBound + (replacementText as NSString).length - - return (ChatTextInputState(inputText: inputText, selectionRange: selectionPosition ..< selectionPosition), inputMode) + var replacementText = NSAttributedString(string: text) + if let emojiAttribute = emojiAttribute { + replacementText = NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: emojiAttribute]) } - return (textInputState, inputMode) + + inputText.replaceCharacters(in: range, with: replacementText) + + let selectionPosition = range.lowerBound + (replacementText.string as NSString).length + + return (ChatTextInputState(inputText: inputText, selectionRange: selectionPosition ..< selectionPosition), inputMode) } + return (textInputState, inputMode) } }) self.currentEntries = to diff --git a/submodules/TelegramUI/Sources/EmojisChatInputPanelItem.swift b/submodules/TelegramUI/Sources/EmojisChatInputPanelItem.swift index 7fdb8192a5..ea075f56ba 100644 --- a/submodules/TelegramUI/Sources/EmojisChatInputPanelItem.swift +++ b/submodules/TelegramUI/Sources/EmojisChatInputPanelItem.swift @@ -6,19 +6,32 @@ import TelegramCore import SwiftSignalKit import Postbox import TelegramPresentationData +import AnimationCache +import MultiAnimationRenderer +import EmojiTextAttachmentView +import AccountContext +import TextFormat final class EmojisChatInputPanelItem: ListViewItem { + fileprivate let context: AccountContext fileprivate let theme: PresentationTheme fileprivate let symbol: String fileprivate let text: String - private let emojiSelected: (String) -> Void + fileprivate let file: TelegramMediaFile? + fileprivate let animationCache: AnimationCache + fileprivate let animationRenderer: MultiAnimationRenderer + private let emojiSelected: (String, TelegramMediaFile?) -> Void let selectable: Bool = true - public init(theme: PresentationTheme, symbol: String, text: String, emojiSelected: @escaping (String) -> Void) { + public init(context: AccountContext, theme: PresentationTheme, symbol: String, text: String, file: TelegramMediaFile?, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, emojiSelected: @escaping (String, TelegramMediaFile?) -> Void) { + self.context = context self.theme = theme self.symbol = symbol self.text = text + self.file = file + self.animationCache = animationCache + self.animationRenderer = animationRenderer self.emojiSelected = emojiSelected } @@ -70,7 +83,7 @@ final class EmojisChatInputPanelItem: ListViewItem { } func selected(listView: ListView) { - self.emojiSelected(self.symbol) + self.emojiSelected(self.symbol, self.file) } } @@ -79,6 +92,7 @@ private let textFont = Font.regular(32.0) final class EmojisChatInputPanelItemNode: ListViewItemNode { static let itemSize = CGSize(width: 45.0, height: 45.0) private let symbolNode: TextNode + private var emojiView: EmojiTextAttachmentView? init() { self.symbolNode = TextNode() @@ -111,6 +125,45 @@ final class EmojisChatInputPanelItemNode: ListViewItemNode { if let strongSelf = self { let _ = symbolApply() strongSelf.symbolNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((EmojisChatInputPanelItemNode.itemSize.width - symbolLayout.size.width) / 2.0), y: 0.0), size: symbolLayout.size) + + if let file = item.file { + strongSelf.symbolNode.isHidden = true + + let emojiView: EmojiTextAttachmentView + if let current = strongSelf.emojiView { + emojiView = current + } else { + emojiView = EmojiTextAttachmentView( + context: item.context, + emoji: ChatTextInputTextCustomEmojiAttribute( + stickerPack: nil, + fileId: file.fileId.id, + file: file + ), + file: file, + cache: item.animationCache, + renderer: item.animationRenderer, + placeholderColor: item.theme.list.mediaPlaceholderColor, + pointSize: CGSize(width: 40.0, height: 40.0) + ) + emojiView.layer.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) + strongSelf.emojiView = emojiView + strongSelf.view.addSubview(emojiView) + + let emojiSize = CGSize(width: 40.0, height: 40.0) + let emojiFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((EmojisChatInputPanelItemNode.itemSize.width - emojiSize.width) / 2.0) + 1.0, y: floorToScreenPixels((EmojisChatInputPanelItemNode.itemSize.height - emojiSize.height) / 2.0)), size: emojiSize) + + emojiView.center = emojiFrame.center + emojiView.bounds = CGRect(origin: CGPoint(), size: emojiFrame.size) + } + } else { + strongSelf.symbolNode.isHidden = false + + if let emojiView = strongSelf.emojiView { + strongSelf.emojiView = nil + emojiView.removeFromSuperview() + } + } } }) } diff --git a/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift index 93a48f9171..da5d7bc8a0 100644 --- a/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift @@ -69,7 +69,7 @@ final class HashtagChatInputContextPanelNode: ChatInputContextPanelNode { private var enqueuedTransitions: [(HashtagChatInputContextPanelTransition, Bool)] = [] private var validLayout: (CGSize, CGFloat, CGFloat, CGFloat)? - override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize) { + override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.listView = ListView() self.listView.isOpaque = false self.listView.stackFromBottom = true @@ -80,7 +80,7 @@ final class HashtagChatInputContextPanelNode: ChatInputContextPanelNode { return strings.VoiceOver_ScrollStatus(row, count).string } - super.init(context: context, theme: theme, strings: strings, fontSize: fontSize) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false self.clipsToBounds = true diff --git a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift index 69d6957d63..b229b974d7 100644 --- a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift @@ -92,7 +92,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont private var enqueuedTransitions: [(HorizontalListContextResultsChatInputContextPanelTransition, Bool)] = [] private var hasValidLayout = false - override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize) { + override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.strings = strings self.separatorNode = ASDisplayNode() @@ -109,7 +109,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont return strings.VoiceOver_ScrollStatus(row, count).string } - super.init(context: context, theme: theme, strings: strings, fontSize: fontSize) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false self.clipsToBounds = true diff --git a/submodules/TelegramUI/Sources/HorizontalStickersChatContextPanelNode.swift b/submodules/TelegramUI/Sources/HorizontalStickersChatContextPanelNode.swift index b8ff4ae9d3..ae6925f588 100755 --- a/submodules/TelegramUI/Sources/HorizontalStickersChatContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HorizontalStickersChatContextPanelNode.swift @@ -118,7 +118,7 @@ final class HorizontalStickersChatContextPanelNode: ChatInputContextPanelNode { private var stickerPreviewController: StickerPreviewController? - override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize) { + override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.strings = strings self.backgroundNode = ASImageNode() @@ -145,7 +145,7 @@ final class HorizontalStickersChatContextPanelNode: ChatInputContextPanelNode { self.stickersInteraction = HorizontalStickersChatContextPanelInteraction() - super.init(context: context, theme: theme, strings: strings, fontSize: fontSize) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.placement = .overTextInput self.isOpaque = false diff --git a/submodules/TelegramUI/Sources/InlineReactionSearchPanel.swift b/submodules/TelegramUI/Sources/InlineReactionSearchPanel.swift index 0c68d1c416..986bd97fbf 100644 --- a/submodules/TelegramUI/Sources/InlineReactionSearchPanel.swift +++ b/submodules/TelegramUI/Sources/InlineReactionSearchPanel.swift @@ -493,7 +493,7 @@ final class InlineReactionSearchPanel: ChatInputContextPanelNode { private var choosingStickerDisposable: Disposable? - init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, peerId: PeerId?) { + init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, peerId: PeerId?, chatPresentationContext: ChatPresentationContext) { self.containerNode = ASDisplayNode() self.backgroundNode = ASDisplayNode() @@ -536,7 +536,7 @@ final class InlineReactionSearchPanel: ChatInputContextPanelNode { self.stickersNode = InlineReactionSearchStickersNode(context: context, theme: theme, strings: strings, peerId: peerId) - super.init(context: context, theme: theme, strings: strings, fontSize: fontSize) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.placement = .overPanels self.isOpaque = false diff --git a/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift index 8705221cdc..2c78873718 100644 --- a/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift @@ -67,7 +67,7 @@ final class MentionChatInputContextPanelNode: ChatInputContextPanelNode { private var enqueuedTransitions: [(CommandChatInputContextPanelTransition, Bool)] = [] private var validLayout: (CGSize, CGFloat, CGFloat, CGFloat)? - init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, mode: MentionChatInputContextPanelMode) { + init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, mode: MentionChatInputContextPanelMode, chatPresentationContext: ChatPresentationContext) { self.mode = mode self.listView = ListView() @@ -80,7 +80,7 @@ final class MentionChatInputContextPanelNode: ChatInputContextPanelNode { return strings.VoiceOver_ScrollStatus(row, count).string } - super.init(context: context, theme: theme, strings: strings, fontSize: fontSize) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false self.clipsToBounds = true diff --git a/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift index a99acd9b86..6693d99a67 100644 --- a/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift @@ -390,7 +390,9 @@ final class PeerSelectionControllerNode: ASDisplayNode { self.addSubnode(forwardAccessoryPanelNode) self.forwardAccessoryPanelNode = forwardAccessoryPanelNode - let textInputPanelNode = AttachmentTextInputPanelNode(context: self.context, presentationInterfaceState: self.presentationInterfaceState, presentController: { [weak self] c in self?.present(c, nil) }) + let textInputPanelNode = AttachmentTextInputPanelNode(context: self.context, presentationInterfaceState: self.presentationInterfaceState, presentController: { [weak self] c in self?.present(c, nil) }, makeEntityInputView: { + return nil + }) textInputPanelNode.interfaceInteraction = self.interfaceInteraction textInputPanelNode.sendMessage = { [weak self] mode in guard let strongSelf = self else { diff --git a/submodules/TelegramUI/Sources/ReplyAccessoryPanelNode.swift b/submodules/TelegramUI/Sources/ReplyAccessoryPanelNode.swift index a80f41176a..4469e3bf1a 100644 --- a/submodules/TelegramUI/Sources/ReplyAccessoryPanelNode.swift +++ b/submodules/TelegramUI/Sources/ReplyAccessoryPanelNode.swift @@ -70,6 +70,7 @@ final class ReplyAccessoryPanelNode: AccessoryPanelNode { self.textNode.maximumNumberOfLines = 1 self.textNode.displaysAsynchronously = false self.textNode.insets = UIEdgeInsets(top: 3.0, left: 0.0, bottom: 3.0, right: 0.0) + self.textNode.visibility = true if let animationCache = animationCache, let animationRenderer = animationRenderer { self.textNode.arguments = TextNodeWithEntities.Arguments( diff --git a/submodules/TelegramUI/Sources/StickersChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/StickersChatInputContextPanelNode.swift index 042724bb32..76c41904a1 100644 --- a/submodules/TelegramUI/Sources/StickersChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/StickersChatInputContextPanelNode.swift @@ -85,7 +85,7 @@ final class StickersChatInputContextPanelNode: ChatInputContextPanelNode { private var stickerPreviewController: StickerPreviewController? - override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize) { + override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.strings = strings self.listView = ListView() @@ -100,7 +100,7 @@ final class StickersChatInputContextPanelNode: ChatInputContextPanelNode { self.stickersInteraction = StickersChatInputContextPanelInteraction() - super.init(context: context, theme: theme, strings: strings, fontSize: fontSize) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false self.clipsToBounds = true diff --git a/submodules/TelegramUI/Sources/TelegramRootController.swift b/submodules/TelegramUI/Sources/TelegramRootController.swift index 57ddcbac1b..73927f1538 100644 --- a/submodules/TelegramUI/Sources/TelegramRootController.swift +++ b/submodules/TelegramUI/Sources/TelegramRootController.swift @@ -126,11 +126,11 @@ public final class TelegramRootController: NavigationController { } let accountSettingsController = PeerInfoScreenImpl(context: self.context, updatedPresentationData: nil, peerId: self.context.account.peerId, avatarInitiallyExpanded: false, isOpenedFromChat: false, nearbyPeerDistance: nil, callMessages: [], isSettings: true) - accountSettingsController.tabBarItemDebugTapAction = { [weak self, weak accountSettingsController] in - guard let strongSelf = self, let accountSettingsController = accountSettingsController else { + accountSettingsController.tabBarItemDebugTapAction = { [weak self] in + guard let strongSelf = self else { return } - accountSettingsController.push(debugController(sharedContext: strongSelf.context.sharedContext, context: strongSelf.context)) + strongSelf.pushViewController(debugController(sharedContext: strongSelf.context.sharedContext, context: strongSelf.context)) } controllers.append(accountSettingsController) diff --git a/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift index 4909d96e51..4574657846 100644 --- a/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/VerticalListContextResultsChatInputContextPanelNode.swift @@ -133,7 +133,7 @@ final class VerticalListContextResultsChatInputContextPanelNode: ChatInputContex private let loadMoreDisposable = MetaDisposable() private var isLoadingMore: Bool = false - override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize) { + override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.listView = ListView() self.listView.isOpaque = false self.listView.stackFromBottom = true @@ -145,7 +145,7 @@ final class VerticalListContextResultsChatInputContextPanelNode: ChatInputContex return strings.VoiceOver_ScrollStatus(row, count).string } - super.init(context: context, theme: theme, strings: strings, fontSize: fontSize) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false self.clipsToBounds = true diff --git a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift index 8d3924042d..172113d076 100644 --- a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift +++ b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift @@ -23,6 +23,8 @@ public struct ChatTextInputAttributes { public static let allAttributes = [ChatTextInputAttributes.bold, ChatTextInputAttributes.italic, ChatTextInputAttributes.monospace, ChatTextInputAttributes.strikethrough, ChatTextInputAttributes.underline, ChatTextInputAttributes.textMention, ChatTextInputAttributes.textUrl, ChatTextInputAttributes.spoiler, ChatTextInputAttributes.customEmoji] } +public let originalTextAttributeKey = NSAttributedString.Key(rawValue: "Attribute__OriginalText") + public func stateAttributedStringForText(_ text: NSAttributedString) -> NSAttributedString { let sourceString = NSMutableAttributedString(attributedString: text) while true { @@ -139,35 +141,6 @@ public func textAttributedStringForStateText(_ stateText: NSAttributedString, fo } }) - /*if #available(iOS 15, *), let emojiViewProvider = emojiViewProvider { - let _ = CustomTextAttachmentViewProvider.ensureRegistered - - var nextIndex: [String: Int] = [:] - - result.string.enumerateSubstrings(in: result.string.startIndex ..< result.string.endIndex, options: [.byComposedCharacterSequences]) { substring, substringRange, _, stop in - if let substring = substring { - let emoji = substring.basicEmoji.0 - - if !emoji.isEmpty && emoji.isSingleEmoji && availableEmojis.contains(emoji) { - let index: Int - if let value = nextIndex[emoji] { - index = value - } else { - index = 0 - } - nextIndex[emoji] = index + 1 - - let attachment = EmojiTextAttachment(index: index, emoji: emoji, viewProvider: emojiViewProvider) - attachment.bounds = CGRect(origin: CGPoint(), size: CGSize(width: 26.0, height: 16.0)) - - result.replaceCharacters(in: NSRange(substringRange, in: result.string), with: NSAttributedString(attachment: attachment)) - - stop = true - } - } - } - }*/ - return result } @@ -234,7 +207,8 @@ public final class ChatTextInputTextCustomEmojiAttribute: NSObject { override public func isEqual(_ object: Any?) -> Bool { if let other = object as? ChatTextInputTextCustomEmojiAttribute { - return self.stickerPack == other.stickerPack && self.fileId == other.fileId && self.file?.fileId == other.file?.fileId + return self === other + //return self.stickerPack == other.stickerPack && self.fileId == other.fileId && self.file?.fileId == other.file?.fileId } else { return false } @@ -562,15 +536,6 @@ public func refreshChatTextInputAttributes(_ textNode: ASEditableTextNode, theme } } else if key == ChatTextInputAttributes.customEmoji, let value = value as? ChatTextInputTextCustomEmojiAttribute { textNode.textView.textStorage.addAttribute(key, value: value, range: range) - if let emojiViewProvider = emojiViewProvider { - let _ = emojiViewProvider - /*let emojiText = attributedText.attributedSubstring(from: range) - let attachment = EmojiTextAttachment(index: emojiIndex, text: emojiText.string, emoji: value, viewProvider: emojiViewProvider) - emojiIndex += 1 - attachment.bounds = CGRect(origin: CGPoint(), size: CGSize(width: 26.0, height: 16.0)) - - replaceRanges.append((range, attachment))*/ - } } } @@ -602,52 +567,6 @@ public func refreshChatTextInputAttributes(_ textNode: ASEditableTextNode, theme textNode.textView.textStorage.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment)) } } - - if #available(iOS 15, *), let _ = emojiViewProvider { - let _ = CustomTextAttachmentViewProvider.ensureRegistered - - /*var nextIndex: [String: Int] = [:] - - var count = 0 - - let fullRange = NSRange(textNode.textView.textStorage.string.startIndex ..< textNode.textView.textStorage.string.endIndex, in: textNode.textView.textStorage.string) - textNode.textView.textStorage.enumerateAttribute(NSAttributedString.Key.attachment, in: fullRange, options: [], using: { value, _, _ in - if let _ = value as? EmojiTextAttachment { - count += 1 - } - }) - - while count < 400 { - var found = false - textNode.textView.textStorage.string.enumerateSubstrings(in: textNode.textView.textStorage.string.startIndex ..< textNode.textView.textStorage.string.endIndex, options: [.byComposedCharacterSequences]) { substring, substringRange, _, stop in - if let substring = substring { - let emoji = substring.basicEmoji.0 - - if !emoji.isEmpty && emoji.isSingleEmoji && availableEmojis.contains(emoji) { - let index: Int - if let value = nextIndex[emoji] { - index = value - } else { - index = 0 - } - nextIndex[emoji] = index + 1 - - let attachment = EmojiTextAttachment(index: index, emoji: emoji, viewProvider: emojiViewProvider) - attachment.bounds = CGRect(origin: CGPoint(), size: CGSize(width: 26.0, height: 16.0)) - - textNode.textView.textStorage.replaceCharacters(in: NSRange(substringRange, in: textNode.textView.textStorage.string), with: NSAttributedString(attachment: attachment)) - - count += 1 - found = true - stop = true - } - } - } - if !found { - break - } - }*/ - } } public func refreshGenericTextInputAttributes(_ textNode: ASEditableTextNode, theme: PresentationTheme, baseFontSize: CGFloat, availableEmojis: Set, emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?, spoilersRevealed: Bool = false) { diff --git a/submodules/TextSelectionNode/BUILD b/submodules/TextSelectionNode/BUILD index 05e706d364..2bb977e11c 100644 --- a/submodules/TextSelectionNode/BUILD +++ b/submodules/TextSelectionNode/BUILD @@ -13,6 +13,7 @@ swift_library( "//submodules/AsyncDisplayKit:AsyncDisplayKit", "//submodules/Display:Display", "//submodules/TelegramPresentationData:TelegramPresentationData", + "//submodules/TextFormat:TextFormat", ], visibility = [ "//visibility:public", diff --git a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift index 930eaaf914..8b58713f3a 100644 --- a/submodules/TextSelectionNode/Sources/TextSelectionNode.swift +++ b/submodules/TextSelectionNode/Sources/TextSelectionNode.swift @@ -4,6 +4,7 @@ import UIKit.UIGestureRecognizerSubclass import AsyncDisplayKit import Display import TelegramPresentationData +import TextFormat private func findScrollView(view: UIView?) -> UIScrollView? { if let view = view { @@ -494,20 +495,43 @@ public final class TextSelectionNode: ASDisplayNode { } completeRect = completeRect.insetBy(dx: 0.0, dy: -12.0) - let attributedText = attributedString.attributedSubstring(from: range) + let string = NSMutableAttributedString(attributedString: attributedString.attributedSubstring(from: range)) + + var fullRange = NSRange(location: 0, length: string.length) + while true { + var found = false + string.enumerateAttribute(originalTextAttributeKey, in: fullRange, options: [], using: { value, range, stop in + if let value = value as? String { + let updatedSubstring = NSMutableAttributedString(string: value) + + let replacementRange = NSRange(location: 0, length: updatedSubstring.length) + updatedSubstring.addAttributes(string.attributes(at: range.location, effectiveRange: nil), range: replacementRange) + + string.replaceCharacters(in: range, with: updatedSubstring) + let updatedRange = NSRange(location: range.location, length: updatedSubstring.length) + + found = true + stop.pointee = ObjCBool(true) + fullRange = NSRange(location: updatedRange.upperBound, length: fullRange.upperBound - range.upperBound) + } + }) + if !found { + break + } + } var actions: [ContextMenuAction] = [] actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: { [weak self] in - self?.performAction(attributedText, .copy) + self?.performAction(string, .copy) self?.dismissSelection() })) actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuLookUp, accessibilityLabel: self.strings.Conversation_ContextMenuLookUp), action: { [weak self] in - self?.performAction(attributedText, .lookup) + self?.performAction(string, .lookup) self?.dismissSelection() })) if #available(iOS 15.0, *) { actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuTranslate, accessibilityLabel: self.strings.Conversation_ContextMenuTranslate), action: { [weak self] in - self?.performAction(attributedText, .translate) + self?.performAction(string, .translate) self?.dismissSelection() })) } @@ -518,7 +542,7 @@ public final class TextSelectionNode: ASDisplayNode { // })) // } actions.append(ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuShare, accessibilityLabel: self.strings.Conversation_ContextMenuShare), action: { [weak self] in - self?.performAction(attributedText, .share) + self?.performAction(string, .share) self?.dismissSelection() })) diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 06fdce9e88..1e74242bed 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -1273,7 +1273,9 @@ private final class WebAppContextReferenceContentSource: ContextReferenceContent } public func standaloneWebAppController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, params: WebAppParameters, openUrl: @escaping (String) -> Void, getInputContainerNode: @escaping () -> (CGFloat, ASDisplayNode, () -> AttachmentController.InputPanelTransition?)? = { return nil }, completion: @escaping () -> Void = {}, willDismiss: @escaping () -> Void = {}, didDismiss: @escaping () -> Void = {}, getNavigationController: @escaping () -> NavigationController? = { return nil }) -> ViewController { - let controller = AttachmentController(context: context, updatedPresentationData: updatedPresentationData, chatLocation: .peer(id: params.peerId), buttons: [.standalone], initialButton: .standalone, fromMenu: params.fromMenu) + let controller = AttachmentController(context: context, updatedPresentationData: updatedPresentationData, chatLocation: .peer(id: params.peerId), buttons: [.standalone], initialButton: .standalone, fromMenu: params.fromMenu, makeEntityInputView: { + return nil + }) controller.getInputContainerNode = getInputContainerNode controller.requestController = { _, present in let webAppController = WebAppController(context: context, updatedPresentationData: updatedPresentationData, params: params, replyToMessageId: nil)