diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index a56654c9fe..4d79b0f308 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -822,6 +822,10 @@ public protocol AccountContext: AnyObject { var cachedGroupCallContexts: AccountGroupCallContextCache { get } var meshAnimationCache: MeshAnimationCache { get } + var animatedEmojiStickers: [String: [StickerPackItem]] { get } + + var userLimits: EngineConfiguration.UserLimits { get } + func storeSecureIdPassword(password: String) func getStoredSecureIdPassword() -> String? diff --git a/submodules/AnimatedStickerNode/Sources/AnimatedStickerNode.swift b/submodules/AnimatedStickerNode/Sources/AnimatedStickerNode.swift index d3ed4f1898..1e4ba26a44 100644 --- a/submodules/AnimatedStickerNode/Sources/AnimatedStickerNode.swift +++ b/submodules/AnimatedStickerNode/Sources/AnimatedStickerNode.swift @@ -144,7 +144,7 @@ public protocol AnimatedStickerNodeSource { var isVideo: Bool { get } func cachedDataPath(width: Int, height: Int) -> Signal<(String, Bool), NoError> - func directDataPath() -> Signal + func directDataPath(attemptSynchronously: Bool) -> Signal } public final class AnimatedStickerNode: ASDisplayNode { @@ -304,9 +304,10 @@ public final class AnimatedStickerNode: ASDisplayNode { strongSelf.play(firstFrame: true) } } - self.disposable.set((source.directDataPath() + self.disposable.set((source.directDataPath(attemptSynchronously: false) + |> filter { $0 != nil } |> deliverOnMainQueue).start(next: { path in - f(path) + f(path!) })) case .cached: self.disposable.set((source.cachedDataPath(width: width, height: height) diff --git a/submodules/AsyncDisplayKit/Source/ASTextKitComponents.mm b/submodules/AsyncDisplayKit/Source/ASTextKitComponents.mm index eeff17607d..75bfd3657a 100644 --- a/submodules/AsyncDisplayKit/Source/ASTextKitComponents.mm +++ b/submodules/AsyncDisplayKit/Source/ASTextKitComponents.mm @@ -13,6 +13,27 @@ #import +@interface ASCustomLayoutManager : NSLayoutManager + +@end + +@implementation ASCustomLayoutManager + +- (void)drawGlyphsForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin { + /*CGGlyph glyph = [self glyphAtIndex:glyphsToShow.location]; + if (glyph) { + } + + CGRect bounds = [self boundingRectForGlyphRange:glyphsToShow inTextContainer:[self textContainerForGlyphAtIndex:glyphsToShow.location effectiveRange:nil]]; + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetFillColorWithColor(context, [UIColor grayColor].CGColor); + CGContextFillRect(context, bounds);*/ + + [super drawGlyphsForGlyphRange:glyphsToShow atPoint:origin]; +} + +@end + @interface ASTextKitComponentsTextView () { // Prevent UITextView from updating contentOffset while deallocating: https://github.com/TextureGroup/Texture/issues/860 BOOL _deallocating; @@ -83,7 +104,7 @@ return [self componentsWithTextStorage:textStorage textContainerSize:textContainerSize - layoutManager:[[NSLayoutManager alloc] init]]; + layoutManager:[[ASCustomLayoutManager alloc] init]]; } + (instancetype)componentsWithTextStorage:(NSTextStorage *)textStorage diff --git a/submodules/AttachmentTextInputPanelNode/BUILD b/submodules/AttachmentTextInputPanelNode/BUILD index 29d59ef3f8..0c9fa47d61 100644 --- a/submodules/AttachmentTextInputPanelNode/BUILD +++ b/submodules/AttachmentTextInputPanelNode/BUILD @@ -28,6 +28,7 @@ swift_library( "//submodules/ChatPresentationInterfaceState:ChatPresentationInterfaceState", "//submodules/Pasteboard:Pasteboard", "//submodules/ContextUI:ContextUI", + "//submodules/TelegramUI/Components/EmojiTextAttachmentView:EmojiTextAttachmentView", ], visibility = [ "//visibility:public", diff --git a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift index f53669f298..de43468a11 100644 --- a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift +++ b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift @@ -18,6 +18,7 @@ import InvisibleInkDustNode import TextInputMenu import ChatPresentationInterfaceState import Pasteboard +import EmojiTextAttachmentView private let counterFont = Font.with(size: 14.0, design: .regular, traits: [.monospacedNumbers]) private let minInputFontSize: CGFloat = 5.0 @@ -208,7 +209,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS accentTextColor = presentationInterfaceState.theme.chat.inputPanel.panelControlAccentColor baseFontSize = max(minInputFontSize, presentationInterfaceState.fontSize.baseDisplaySize) } - textInputNode.attributedText = textAttributedStringForStateText(state.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed) + textInputNode.attributedText = textAttributedStringForStateText(state.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(self.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) textInputNode.selectedRange = NSMakeRange(state.selectionRange.lowerBound, state.selectionRange.count) self.updatingInputState = false self.updateTextNodeText(animated: animated) @@ -241,6 +242,8 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS private var spoilersRevealed = false + private var emojiViewProvider: ((String) -> UIView)? + public init(context: AccountContext, presentationInterfaceState: ChatPresentationInterfaceState, isCaption: Bool = false, isAttachment: Bool = false, presentController: @escaping (ViewController) -> Void) { self.context = context self.presentationInterfaceState = presentationInterfaceState @@ -311,6 +314,14 @@ 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 { + return UIView() + } + + return EmojiTextAttachmentView(context: context, file: file) + } + self.updateSendButtonEnabled(isCaption || isAttachment, animated: false) } @@ -741,7 +752,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS @objc public func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) { if let textInputNode = self.textInputNode, let presentationInterfaceState = self.presentationInterfaceState { let baseFontSize = max(minInputFontSize, presentationInterfaceState.fontSize.baseDisplaySize) - refreshChatTextInputAttributes(textInputNode, theme: presentationInterfaceState.theme, baseFontSize: baseFontSize, spoilersRevealed: self.spoilersRevealed) + refreshChatTextInputAttributes(textInputNode, theme: presentationInterfaceState.theme, baseFontSize: baseFontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(self.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) refreshChatTextInputTypingAttributes(textInputNode, theme: presentationInterfaceState.theme, baseFontSize: baseFontSize) self.updateSpoiler() @@ -876,9 +887,9 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS textInputNode.textView.isScrollEnabled = false - refreshChatTextInputAttributes(textInputNode, theme: presentationInterfaceState.theme, baseFontSize: baseFontSize, spoilersRevealed: self.spoilersRevealed) + refreshChatTextInputAttributes(textInputNode, theme: presentationInterfaceState.theme, baseFontSize: baseFontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(self.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) - textInputNode.attributedText = textAttributedStringForStateText(self.inputTextState.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed) + textInputNode.attributedText = textAttributedStringForStateText(self.inputTextState.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(self.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) if textInputNode.textView.subviews.count > 1, animated { let containerView = textInputNode.textView.subviews[1] @@ -968,7 +979,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS let textFont = Font.regular(baseFontSize) let accentTextColor = presentationInterfaceState.theme.chat.inputPanel.panelControlAccentColor - let attributedText = textAttributedStringForStateText(self.inputTextState.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: false) + let attributedText = textAttributedStringForStateText(self.inputTextState.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: false, availableEmojis: Set(self.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) let range = (attributedText.string as NSString).range(of: "\n") if range.location != NSNotFound { @@ -1234,7 +1245,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } } } - + if cleanText != text { let string = NSMutableAttributedString(attributedString: editableTextNode.attributedText ?? NSAttributedString()) var textColor: UIColor = .black @@ -1245,7 +1256,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS accentTextColor = presentationInterfaceState.theme.chat.inputPanel.panelControlAccentColor baseFontSize = max(minInputFontSize, presentationInterfaceState.fontSize.baseDisplaySize) } - let cleanReplacementString = textAttributedStringForStateText(NSAttributedString(string: cleanText), fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed) + let cleanReplacementString = textAttributedStringForStateText(NSAttributedString(string: cleanText), fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(self.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) string.replaceCharacters(in: range, with: cleanReplacementString) self.textInputNode?.attributedText = string self.textInputNode?.selectedRange = NSMakeRange(range.lowerBound + cleanReplacementString.length, 0) diff --git a/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift b/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift index c2f290a428..20481f84aa 100644 --- a/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift +++ b/submodules/ComposePollUI/Sources/CreatePollTextInputItem.swift @@ -228,7 +228,7 @@ public class CreatePollTextInputItemNode: ListViewItemNode, ASEditableTextNodeDe rightInset += inlineAction.icon.size.width + 8.0 } - let itemText = textAttributedStringForStateText(item.text, fontSize: 17.0, textColor: item.presentationData.theme.chat.inputPanel.primaryTextColor, accentTextColor: item.presentationData.theme.chat.inputPanel.panelControlAccentColor, writingDirection: nil, spoilersRevealed: false) + let itemText = textAttributedStringForStateText(item.text, fontSize: 17.0, textColor: item.presentationData.theme.chat.inputPanel.primaryTextColor, accentTextColor: item.presentationData.theme.chat.inputPanel.panelControlAccentColor, writingDirection: nil, spoilersRevealed: false, availableEmojis: Set(), emojiViewProvider: nil) let measureText = NSMutableAttributedString(attributedString: itemText) let measureRawString = measureText.string if measureRawString.hasSuffix("\n") || measureRawString.isEmpty { @@ -294,11 +294,11 @@ public class CreatePollTextInputItemNode: ListViewItemNode, ASEditableTextNodeDe if let currentText = strongSelf.textNode.attributedText { if currentText.string != attributedText.string || updatedTheme != nil { strongSelf.textNode.attributedText = attributedText - refreshGenericTextInputAttributes(strongSelf.textNode, theme: item.presentationData.theme, baseFontSize: 17.0) + refreshGenericTextInputAttributes(strongSelf.textNode, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil) } } else { strongSelf.textNode.attributedText = attributedText - refreshGenericTextInputAttributes(strongSelf.textNode, theme: item.presentationData.theme, baseFontSize: 17.0) + refreshGenericTextInputAttributes(strongSelf.textNode, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil) } if strongSelf.backgroundNode.supernode == nil { @@ -514,7 +514,7 @@ public class CreatePollTextInputItemNode: ListViewItemNode, ASEditableTextNodeDe public func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) { if let item = self.item { if let _ = self.textNode.attributedText { - refreshGenericTextInputAttributes(editableTextNode, theme: item.presentationData.theme, baseFontSize: 17.0) + refreshGenericTextInputAttributes(editableTextNode, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil) let updatedText = stateAttributedStringForText(self.textNode.attributedText!) item.textUpdated(updatedText) } else { @@ -544,7 +544,7 @@ public class CreatePollTextInputItemNode: ListViewItemNode, ASEditableTextNodeDe } refreshChatTextInputTypingAttributes(editableTextNode, theme: item.presentationData.theme, baseFontSize: 17.0) - refreshGenericTextInputAttributes(editableTextNode, theme: item.presentationData.theme, baseFontSize: 17.0) + refreshGenericTextInputAttributes(editableTextNode, theme: item.presentationData.theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil) } } @@ -591,7 +591,7 @@ private func chatTextInputAddFormattingAttribute(item: CreatePollTextInputItem, textNode.selectedRange = nsRange refreshChatTextInputTypingAttributes(textNode, theme: theme, baseFontSize: 17.0) - refreshGenericTextInputAttributes(textNode, theme: theme, baseFontSize: 17.0) + refreshGenericTextInputAttributes(textNode, theme: theme, baseFontSize: 17.0, availableEmojis: Set(), emojiViewProvider: nil) let updatedText = stateAttributedStringForText(textNode.attributedText!) item.textUpdated(updatedText) diff --git a/submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift b/submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift index 424cec0595..0721e77e5c 100644 --- a/submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift +++ b/submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift @@ -895,6 +895,8 @@ func settingsSearchableItems(context: AccountContext, notificationExceptionsList let faq = SettingsSearchableItem(id: .faq(0), title: strings.Settings_FAQ, alternate: synonyms(strings.SettingsSearch_Synonyms_FAQ), icon: .faq, breadcrumbs: [], present: { context, navigationController, present in + #if DEBUG + #else let _ = (cachedFaqInstantPage(context: context) |> deliverOnMainQueue).start(next: { resolvedUrl in context.sharedContext.openResolvedUrl(resolvedUrl, context: context, urlContext: .generic, navigationController: navigationController, forceExternal: false, openPeer: { peer, navigation in @@ -902,6 +904,7 @@ func settingsSearchableItems(context: AccountContext, notificationExceptionsList present(.push, controller) }, dismissInput: {}, contentContext: nil) }) + #endif }) allItems.append(faq) diff --git a/submodules/TelegramAnimatedStickerNode/Sources/TelegramAnimatedStickerNode.swift b/submodules/TelegramAnimatedStickerNode/Sources/TelegramAnimatedStickerNode.swift index ca20f39c04..56c2e313e9 100644 --- a/submodules/TelegramAnimatedStickerNode/Sources/TelegramAnimatedStickerNode.swift +++ b/submodules/TelegramAnimatedStickerNode/Sources/TelegramAnimatedStickerNode.swift @@ -18,11 +18,11 @@ public final class AnimatedStickerNodeLocalFileSource: AnimatedStickerNodeSource self.name = name } - public func directDataPath() -> Signal { + public func directDataPath(attemptSynchronously: Bool) -> Signal { if let path = self.path { return .single(path) } else { - return .never() + return .single(nil) } } @@ -30,6 +30,10 @@ public final class AnimatedStickerNodeLocalFileSource: AnimatedStickerNodeSource return .never() } + func maybeCachedDataPath(width: Int, height: Int) -> (String, Bool)? { + return nil + } + public var path: String? { if let path = getAppBundle().path(forResource: self.name, ofType: "tgs") { return path @@ -64,13 +68,14 @@ public final class AnimatedStickerResourceSource: AnimatedStickerNodeSource { } } - public func directDataPath() -> Signal { - return self.account.postbox.mediaBox.resourceData(self.resource) - |> filter { data in - return data.complete - } - |> map { data -> String in - return data.path + public func directDataPath(attemptSynchronously: Bool) -> Signal { + return self.account.postbox.mediaBox.resourceData(self.resource, attemptSynchronously: attemptSynchronously) + |> map { data -> String? in + if data.complete { + return data.path + } else { + return nil + } } } } diff --git a/submodules/TelegramCore/Sources/State/UserLimitsConfiguration.swift b/submodules/TelegramCore/Sources/State/UserLimitsConfiguration.swift index f58dadb194..2d21b919ec 100644 --- a/submodules/TelegramCore/Sources/State/UserLimitsConfiguration.swift +++ b/submodules/TelegramCore/Sources/State/UserLimitsConfiguration.swift @@ -11,6 +11,7 @@ public struct UserLimitsConfiguration: Equatable { public let maxFolderChatsCount: Int32 public let maxCaptionLengthCount: Int32 public let maxUploadFileParts: Int32 + public let maxAnimatedEmojisInText: Int32 public static var defaultValue: UserLimitsConfiguration { return UserLimitsConfiguration( @@ -22,7 +23,8 @@ public struct UserLimitsConfiguration: Equatable { maxFoldersCount: 10, maxFolderChatsCount: 100, maxCaptionLengthCount: 1024, - maxUploadFileParts: 4000 + maxUploadFileParts: 4000, + maxAnimatedEmojisInText: 10 ) } @@ -35,7 +37,8 @@ public struct UserLimitsConfiguration: Equatable { maxFoldersCount: Int32, maxFolderChatsCount: Int32, maxCaptionLengthCount: Int32, - maxUploadFileParts: Int32 + maxUploadFileParts: Int32, + maxAnimatedEmojisInText: Int32 ) { self.maxPinnedChatCount = maxPinnedChatCount self.maxChannelsCount = maxChannelsCount @@ -46,6 +49,7 @@ public struct UserLimitsConfiguration: Equatable { self.maxFolderChatsCount = maxFolderChatsCount self.maxCaptionLengthCount = maxCaptionLengthCount self.maxUploadFileParts = maxUploadFileParts + self.maxAnimatedEmojisInText = maxAnimatedEmojisInText } } @@ -62,6 +66,14 @@ extension UserLimitsConfiguration { } } + func getGeneralValue(_ key: String, orElse defaultValue: Int32) -> Int32 { + if let value = appConfiguration.data?[key] as? Double { + return Int32(value) + } else { + return defaultValue + } + } + self.maxPinnedChatCount = getValue("dialogs_pinned_limit", orElse: defaultValue.maxPinnedChatCount) self.maxChannelsCount = getValue("channels_limit", orElse: defaultValue.maxPinnedChatCount) self.maxPublicLinksCount = getValue("channels_public_limit", orElse: defaultValue.maxPinnedChatCount) @@ -71,5 +83,6 @@ extension UserLimitsConfiguration { self.maxFolderChatsCount = getValue("dialog_filters_chats_limit", orElse: defaultValue.maxPinnedChatCount) self.maxCaptionLengthCount = getValue("caption_length_limit", orElse: defaultValue.maxCaptionLengthCount) self.maxUploadFileParts = getValue("upload_max_fileparts", orElse: defaultValue.maxUploadFileParts) + self.maxAnimatedEmojisInText = getGeneralValue("message_animated_emoji_max", orElse: defaultValue.maxAnimatedEmojisInText) } } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_AudioTranscriptionMessageAttribute.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_AudioTranscriptionMessageAttribute.swift index de17ee5790..47e1b00d16 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_AudioTranscriptionMessageAttribute.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_AudioTranscriptionMessageAttribute.swift @@ -1,30 +1,30 @@ import Postbox public class AudioTranscriptionMessageAttribute: MessageAttribute, Equatable { - public let locale: String + public let id: Int64 public let text: String public var associatedPeerIds: [PeerId] { return [] } - public init(locale: String, text: String) { - self.locale = locale + public init(id: Int64, text: String) { + self.id = id self.text = text } required public init(decoder: PostboxDecoder) { - self.locale = decoder.decodeStringForKey("locale", orElse: "") + self.id = decoder.decodeInt64ForKey("id", orElse: 0) self.text = decoder.decodeStringForKey("text", orElse: "") } public func encode(_ encoder: PostboxEncoder) { - encoder.encodeString(self.locale, forKey: "locale") + encoder.encodeInt64(self.id, forKey: "id") encoder.encodeString(self.text, forKey: "text") } public static func ==(lhs: AudioTranscriptionMessageAttribute, rhs: AudioTranscriptionMessageAttribute) -> Bool { - if lhs.locale != rhs.locale { + if lhs.id != rhs.id { return false } if lhs.text != rhs.text { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Data/Configuration.swift b/submodules/TelegramCore/Sources/TelegramEngine/Data/Configuration.swift index 5d83c3f397..6b7a210d3d 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Data/Configuration.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Data/Configuration.swift @@ -57,6 +57,7 @@ public enum EngineConfiguration { public let maxFolderChatsCount: Int32 public let maxCaptionLengthCount: Int32 public let maxUploadFileParts: Int32 + public let maxAnimatedEmojisInText: Int32 public static var defaultValue: UserLimits { return UserLimits(UserLimitsConfiguration.defaultValue) @@ -71,7 +72,8 @@ public enum EngineConfiguration { maxFoldersCount: Int32, maxFolderChatsCount: Int32, maxCaptionLengthCount: Int32, - maxUploadFileParts: Int32 + maxUploadFileParts: Int32, + maxAnimatedEmojisInText: Int32 ) { self.maxPinnedChatCount = maxPinnedChatCount self.maxChannelsCount = maxChannelsCount @@ -82,6 +84,7 @@ public enum EngineConfiguration { self.maxFolderChatsCount = maxFolderChatsCount self.maxCaptionLengthCount = maxCaptionLengthCount self.maxUploadFileParts = maxUploadFileParts + self.maxAnimatedEmojisInText = maxAnimatedEmojisInText } } } @@ -105,7 +108,7 @@ extension EngineConfiguration.Limits { } } -extension EngineConfiguration.UserLimits { +public extension EngineConfiguration.UserLimits { init(_ userLimitsConfiguration: UserLimitsConfiguration) { self.init( maxPinnedChatCount: userLimitsConfiguration.maxPinnedChatCount, @@ -116,7 +119,8 @@ extension EngineConfiguration.UserLimits { maxFoldersCount: userLimitsConfiguration.maxFoldersCount, maxFolderChatsCount: userLimitsConfiguration.maxFolderChatsCount, maxCaptionLengthCount: userLimitsConfiguration.maxCaptionLengthCount, - maxUploadFileParts: userLimitsConfiguration.maxUploadFileParts + maxUploadFileParts: userLimitsConfiguration.maxUploadFileParts, + maxAnimatedEmojisInText: userLimitsConfiguration.maxAnimatedEmojisInText ) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index bf5eda9925..04fdcde27d 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -322,10 +322,14 @@ public extension TelegramEngine { return _internal_translate(network: self.account.network, text: text, fromLang: fromLang, toLang: toLang) } - public func transcribeAudio(messageId: MessageId) -> Signal { + public func transcribeAudio(messageId: MessageId) -> Signal { return _internal_transcribeAudio(postbox: self.account.postbox, network: self.account.network, messageId: messageId) } + public func rateAudioTranscription(messageId: MessageId, id: Int64, isGood: Bool) -> Signal { + return _internal_rateAudioTranscription(postbox: self.account.postbox, network: self.account.network, messageId: messageId, id: id, isGood: isGood) + } + public func requestWebView(peerId: PeerId, botId: PeerId, url: String?, payload: String?, themeParams: [String: Any]?, fromMenu: Bool, replyToMessageId: MessageId?) -> Signal { return _internal_requestWebView(postbox: self.account.postbox, network: self.account.network, stateManager: self.account.stateManager, peerId: peerId, botId: botId, url: url, payload: payload, themeParams: themeParams, fromMenu: fromMenu, replyToMessageId: replyToMessageId) } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift index 0b4f467de1..a71eb42664 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Translate.swift @@ -29,32 +29,70 @@ func _internal_translate(network: Network, text: String, fromLang: String?, toLa } } -public struct EngineAudioTranscriptionResult { - public var id: Int64 - public var text: String +public enum EngineAudioTranscriptionResult { + public struct Success { + public var id: Int64 + public var text: String + + public init(id: Int64, text: String) { + self.id = id + self.text = text + } + } + + case success(Success) + case error } -func _internal_transcribeAudio(postbox: Postbox, network: Network, messageId: MessageId) -> Signal { +func _internal_transcribeAudio(postbox: Postbox, network: Network, messageId: MessageId) -> Signal { return postbox.transaction { transaction -> Api.InputPeer? in return transaction.getPeer(messageId.peerId).flatMap(apiInputPeer) } - |> mapToSignal { inputPeer -> Signal in + |> mapToSignal { inputPeer -> Signal in guard let inputPeer = inputPeer else { - return .single(nil) + return .single(.error) } return network.request(Api.functions.messages.transcribeAudio(peer: inputPeer, msgId: messageId.id)) |> map(Optional.init) |> `catch` { _ -> Signal in return .single(nil) } - |> mapToSignal { result -> Signal in + |> mapToSignal { result -> Signal in guard let result = result else { - return .single(nil) + return .single(.error) } - switch result { - case let .transcribedAudio(transcriptionId, text): - return .single(EngineAudioTranscriptionResult(id: transcriptionId, text: text)) + + return postbox.transaction { transaction -> EngineAudioTranscriptionResult in + switch result { + case let .transcribedAudio(transcriptionId, text): + transaction.updateMessage(messageId, update: { currentMessage in + let storeForwardInfo = currentMessage.forwardInfo.flatMap(StoreMessageForwardInfo.init) + var attributes = currentMessage.attributes.filter { !($0 is AudioTranscriptionMessageAttribute) } + + attributes.append(AudioTranscriptionMessageAttribute(id: transcriptionId, text: text)) + + return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) + }) + + return .success(EngineAudioTranscriptionResult.Success(id: transcriptionId, text: text)) + } } } } } + +func _internal_rateAudioTranscription(postbox: Postbox, network: Network, messageId: MessageId, id: Int64, isGood: Bool) -> Signal { + return postbox.transaction { transaction -> Api.InputPeer? in + return transaction.getPeer(messageId.peerId).flatMap(apiInputPeer) + } + |> mapToSignal { inputPeer -> Signal in + guard let inputPeer = inputPeer else { + return .complete() + } + return network.request(Api.functions.messages.rateTranscribedAudio(peer: inputPeer, msgId: messageId.id, transcriptionId: id, good: isGood ? .boolTrue : .boolFalse)) + |> `catch` { _ -> Signal in + return .single(.boolFalse) + } + |> ignoreValues + } +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChatListFiltering.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChatListFiltering.swift index cb3eb551ef..2cb2f5f900 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChatListFiltering.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/ChatListFiltering.swift @@ -348,8 +348,6 @@ extension ChatListFilter { } ) ) - case .dialogFilterDefault: - preconditionFailure() } } @@ -486,8 +484,6 @@ private func requestChatListFilters(accountPeerId: PeerId, postbox: Postbox, net } } } - case .dialogFilterDefault: - break } } return (filters, missingPeers, missingChats) diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 10eb67012b..a7bee6a82b 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -273,6 +273,8 @@ swift_library( "//submodules/InAppPurchaseManager:InAppPurchaseManager", "//submodules/TelegramUI/Components/AudioTranscriptionButtonComponent:AudioTranscriptionButtonComponent", "//submodules/TelegramUI/Components/AudioWaveformComponent:AudioWaveformComponent", + "//submodules/TelegramUI/Components/EditableChatTextNode:EditableChatTextNode", + "//submodules/TelegramUI/Components/EmojiTextAttachmentView:EmojiTextAttachmentView", "//submodules/Media/ConvertOpusToAAC:ConvertOpusToAAC", "//submodules/Media/LocalAudioTranscription:LocalAudioTranscription", ] + select({ diff --git a/submodules/TelegramUI/Components/AudioWaveformComponent/Sources/AudioWaveformComponent.swift b/submodules/TelegramUI/Components/AudioWaveformComponent/Sources/AudioWaveformComponent.swift index 9115cf716c..33235763b4 100644 --- a/submodules/TelegramUI/Components/AudioWaveformComponent/Sources/AudioWaveformComponent.swift +++ b/submodules/TelegramUI/Components/AudioWaveformComponent/Sources/AudioWaveformComponent.swift @@ -139,7 +139,7 @@ public final class AudioWaveformComponent: Component { self.updateShimmer() } - if let previousContents = previousContents, let contents = self.contents { + if let previousContents = previousContents, CFGetTypeID(previousContents as CFTypeRef) == CGImage.typeID, (previousContents as! CGImage).width != Int(image.size.width * image.scale), let contents = self.contents { self.animate(from: previousContents as AnyObject, to: contents as AnyObject, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.linear.rawValue, duration: 0.15) } } @@ -317,7 +317,7 @@ public final class AudioWaveformComponent: Component { func update(component: AudioWaveformComponent, availableSize: CGSize, transition: Transition) -> CGSize { let size = CGSize(width: availableSize.width, height: availableSize.height) - if self.validSize != size || self.component?.samples != component.samples || self.component?.peak != component.peak { + if self.validSize != size || self.component != component { self.setNeedsDisplay() } @@ -464,8 +464,6 @@ public final class AudioWaveformComponent: Component { } memset(adjustedSamplesMemory, 0, numSamples * 2) - var generateFakeSamples = false - var bins: [UInt16: Int] = [:] for i in 0 ..< maxReadSamples { let index = i * numSamples / maxReadSamples @@ -491,27 +489,6 @@ public final class AudioWaveformComponent: Component { } sortedSamples.sort { $0.1 > $1.1 } - let topSamples = sortedSamples.prefix(1) - let topCount = topSamples.map{ $0.1 }.reduce(.zero, +) - var topCountPercent: Float = 0.0 - if bins.count > 0 { - topCountPercent = Float(topCount) / Float(totalCount) - } - - if topCountPercent > 0.75 { - generateFakeSamples = true - } - - if generateFakeSamples { - if maxSample < 10 { - maxSample = 20 - } - for i in 0 ..< maxReadSamples { - let index = i * numSamples / maxReadSamples - adjustedSamples[index] = UInt16.random(in: 6...maxSample) - } - } - let invScale = 1.0 / max(1.0, CGFloat(maxSample)) let commonRevealFraction = listViewAnimationCurveSystem(self.revealProgress) diff --git a/submodules/TelegramUI/Components/EditableChatTextNode/BUILD b/submodules/TelegramUI/Components/EditableChatTextNode/BUILD new file mode 100644 index 0000000000..35be430855 --- /dev/null +++ b/submodules/TelegramUI/Components/EditableChatTextNode/BUILD @@ -0,0 +1,19 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "EditableChatTextNode", + module_name = "EditableChatTextNode", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit:AsyncDisplayKit", + "//submodules/Display:Display", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/EditableChatTextNode/Sources/EditableChatTextNode.swift b/submodules/TelegramUI/Components/EditableChatTextNode/Sources/EditableChatTextNode.swift new file mode 100644 index 0000000000..e15ae7dd01 --- /dev/null +++ b/submodules/TelegramUI/Components/EditableChatTextNode/Sources/EditableChatTextNode.swift @@ -0,0 +1,7 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit + +public final class EditableChatTextNode: EditableTextNode { +} diff --git a/submodules/TelegramUI/Components/EmojiTextAttachmentView/BUILD b/submodules/TelegramUI/Components/EmojiTextAttachmentView/BUILD new file mode 100644 index 0000000000..571d0edd31 --- /dev/null +++ b/submodules/TelegramUI/Components/EmojiTextAttachmentView/BUILD @@ -0,0 +1,26 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "EmojiTextAttachmentView", + module_name = "EmojiTextAttachmentView", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit:AsyncDisplayKit", + "//submodules/Display:Display", + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/Postbox:Postbox", + "//submodules/TelegramCore:TelegramCore", + "//submodules/AnimatedStickerNode:AnimatedStickerNode", + "//submodules/TelegramAnimatedStickerNode:TelegramAnimatedStickerNode", + "//submodules/YuvConversion:YuvConversion", + "//submodules/AccountContext:AccountContext", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/EmojiTextAttachmentView/Sources/EmojiTextAttachmentView.swift b/submodules/TelegramUI/Components/EmojiTextAttachmentView/Sources/EmojiTextAttachmentView.swift new file mode 100644 index 0000000000..37ae264449 --- /dev/null +++ b/submodules/TelegramUI/Components/EmojiTextAttachmentView/Sources/EmojiTextAttachmentView.swift @@ -0,0 +1,213 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import AnimatedStickerNode +import TelegramAnimatedStickerNode +import SwiftSignalKit +import AccountContext +import YuvConversion +import TelegramCore +import Postbox + +private final class InlineStickerItemLayer: SimpleLayer { + static let queue = Queue() + + private let file: TelegramMediaFile + private let source: AnimatedStickerNodeSource + private var frameSource: QueueLocalObject? + private var disposable: Disposable? + private var fetchDisposable: Disposable? + + private var isInHierarchyValue: Bool = false + var isVisibleForAnimations: Bool = false { + didSet { + self.updatePlayback() + } + } + private var displayLink: ConstantDisplayLinkAnimator? + + init(context: AccountContext, file: TelegramMediaFile) { + self.source = AnimatedStickerResourceSource(account: context.account, resource: file.resource, fitzModifier: nil, isVideo: false) + self.file = file + + super.init() + + let pathPrefix = context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id) + let width = Int(24 * UIScreenScale) + let height = Int(24 * UIScreenScale) + + let directDataPath = Atomic(value: nil) + let _ = (self.source.directDataPath(attemptSynchronously: true) |> take(1)).start(next: { result in + let _ = directDataPath.swap(result) + }) + + if let directDataPath = directDataPath.with({ $0 }), let directData = try? Data(contentsOf: URL(fileURLWithPath: directDataPath), options: .alwaysMapped) { + let syncFrameSource = AnimatedStickerDirectFrameSource(queue: .mainQueue(), data: directData, width: width, height: height, cachePathPrefix: pathPrefix, useMetalCache: false, fitzModifier: nil)! + + if let animationFrame = syncFrameSource.takeFrame(draw: true) { + var image: UIImage? + + autoreleasepool { + image = generateImagePixel(CGSize(width: CGFloat(animationFrame.width), height: CGFloat(animationFrame.height)), scale: 1.0, pixelGenerator: { _, pixelData, contextBytesPerRow in + var data = animationFrame.data + data.withUnsafeMutableBytes { bytes -> Void in + guard let baseAddress = bytes.baseAddress else { + return + } + switch animationFrame.type { + case .argb: + memcpy(pixelData, baseAddress.assumingMemoryBound(to: UInt8.self), bytes.count) + case .yuva: + if animationFrame.bytesPerRow <= 0 || animationFrame.height <= 0 || animationFrame.width <= 0 || animationFrame.bytesPerRow * animationFrame.height > bytes.count { + assert(false) + return + } + decodeYUVAToRGBA(baseAddress.assumingMemoryBound(to: UInt8.self), pixelData, Int32(animationFrame.width), Int32(animationFrame.height), Int32(contextBytesPerRow)) + default: + break + } + } + }) + } + + if let image = image { + self.contents = image.cgImage + } + } + } + + self.disposable = (self.source.directDataPath(attemptSynchronously: false) + |> filter { $0 != nil } + |> take(1) + |> deliverOn(InlineStickerItemLayer.queue)).start(next: { [weak self] path in + guard let directData = try? Data(contentsOf: URL(fileURLWithPath: path!), options: [.mappedRead]) else { + return + } + Queue.mainQueue().async { + guard let strongSelf = self else { + return + } + strongSelf.frameSource = QueueLocalObject(queue: InlineStickerItemLayer.queue, generate: { + return AnimatedStickerDirectFrameSource(queue: InlineStickerItemLayer.queue, data: directData, width: width, height: height, cachePathPrefix: pathPrefix, useMetalCache: false, fitzModifier: nil)! + }) + strongSelf.updatePlayback() + } + }) + + self.fetchDisposable = freeMediaFileInteractiveFetched(account: context.account, fileReference: .standalone(media: file)).start() + } + + override init(layer: Any) { + guard let layer = layer as? InlineStickerItemLayer else { + preconditionFailure() + } + self.source = layer.source + self.file = layer.file + + super.init(layer: layer) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.disposable?.dispose() + self.fetchDisposable?.dispose() + } + + override func action(forKey event: String) -> CAAction? { + if event == kCAOnOrderIn { + self.isInHierarchyValue = true + } else if event == kCAOnOrderOut { + self.isInHierarchyValue = false + } + self.updatePlayback() + return nullAction + } + + private func updatePlayback() { + let shouldBePlaying = self.isInHierarchyValue && self.isVisibleForAnimations && self.frameSource != nil + if shouldBePlaying != (self.displayLink != nil) { + if shouldBePlaying { + self.displayLink = ConstantDisplayLinkAnimator(update: { [weak self] in + self?.loadNextFrame() + }) + self.displayLink?.isPaused = false + } else { + self.displayLink?.invalidate() + self.displayLink = nil + } + } + } + + private var didRequestFrame = false + + private func loadNextFrame() { + guard let frameSource = self.frameSource else { + return + } + self.didRequestFrame = true + frameSource.with { [weak self] impl in + if let animationFrame = impl.takeFrame(draw: true) { + var image: UIImage? + + autoreleasepool { + image = generateImagePixel(CGSize(width: CGFloat(animationFrame.width), height: CGFloat(animationFrame.height)), scale: 1.0, pixelGenerator: { _, pixelData, contextBytesPerRow in + var data = animationFrame.data + data.withUnsafeMutableBytes { bytes -> Void in + guard let baseAddress = bytes.baseAddress else { + return + } + switch animationFrame.type { + case .argb: + memcpy(pixelData, baseAddress.assumingMemoryBound(to: UInt8.self), bytes.count) + case .yuva: + if animationFrame.bytesPerRow <= 0 || animationFrame.height <= 0 || animationFrame.width <= 0 || animationFrame.bytesPerRow * animationFrame.height > bytes.count { + assert(false) + return + } + decodeYUVAToRGBA(baseAddress.assumingMemoryBound(to: UInt8.self), pixelData, Int32(animationFrame.width), Int32(animationFrame.height), Int32(contextBytesPerRow)) + default: + break + } + } + }) + } + + if let image = image { + Queue.mainQueue().async { + guard let strongSelf = self else { + return + } + strongSelf.contents = image.cgImage + } + } + } + } + } +} + +public final class EmojiTextAttachmentView: UIView { + private let contentLayer: InlineStickerItemLayer + + public init(context: AccountContext, file: TelegramMediaFile) { + self.contentLayer = InlineStickerItemLayer(context: context, file: file) + + super.init(frame: CGRect()) + + self.layer.addSublayer(self.contentLayer) + self.contentLayer.isVisibleForAnimations = true + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func layoutSubviews() { + super.layoutSubviews() + + self.contentLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: -2.0), size: CGSize(width: self.bounds.width - 0.0, height: self.bounds.height + 9.0)) + } +} diff --git a/submodules/TelegramUI/Sources/AccountContext.swift b/submodules/TelegramUI/Sources/AccountContext.swift index d325e1e0e1..62091547d7 100644 --- a/submodules/TelegramUI/Sources/AccountContext.swift +++ b/submodules/TelegramUI/Sources/AccountContext.swift @@ -159,12 +159,20 @@ public final class AccountContextImpl: AccountContext { public let cachedGroupCallContexts: AccountGroupCallContextCache public let meshAnimationCache: MeshAnimationCache + private var animatedEmojiStickersDisposable: Disposable? + public private(set) var animatedEmojiStickers: [String: [StickerPackItem]] = [:] + + private var userLimitsConfigurationDisposable: Disposable? + public private(set) var userLimits: EngineConfiguration.UserLimits + public init(sharedContext: SharedAccountContextImpl, account: Account, limitsConfiguration: LimitsConfiguration, contentSettings: ContentSettings, appConfiguration: AppConfiguration, temp: Bool = false) { self.sharedContextImpl = sharedContext self.account = account self.engine = TelegramEngine(account: account) + self.userLimits = EngineConfiguration.UserLimits(UserLimitsConfiguration.defaultValue) + self.downloadedMediaStoreManager = DownloadedMediaStoreManagerImpl(postbox: account.postbox, accountManager: sharedContext.accountManager) if let locationManager = self.sharedContextImpl.locationManager { @@ -244,6 +252,40 @@ public final class AccountContextImpl: AccountContext { account.callSessionManager.updateVersions(versions: PresentationCallManagerImpl.voipVersions(includeExperimental: true, includeReference: true).map { version, supportsVideo -> CallSessionManagerImplementationVersion in CallSessionManagerImplementationVersion(version: version, supportsVideo: supportsVideo) }) + + self.animatedEmojiStickersDisposable = (self.engine.stickers.loadedStickerPack(reference: .animatedEmoji, forceActualized: false) + |> map { animatedEmoji -> [String: [StickerPackItem]] in + var animatedEmojiStickers: [String: [StickerPackItem]] = [:] + switch animatedEmoji { + case let .result(_, items, _): + for item in items { + if let emoji = item.getStringRepresentationsOfIndexKeys().first { + animatedEmojiStickers[emoji.basicEmoji.0] = [item] + let strippedEmoji = emoji.basicEmoji.0.strippedEmoji + if animatedEmojiStickers[strippedEmoji] == nil { + animatedEmojiStickers[strippedEmoji] = [item] + } + } + } + default: + break + } + return animatedEmojiStickers + } + |> deliverOnMainQueue).start(next: { [weak self] stickers in + guard let strongSelf = self else { + return + } + strongSelf.animatedEmojiStickers = stickers + }) + + self.userLimitsConfigurationDisposable = (self.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.UserLimits(isPremium: false)) + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let strongSelf = self else { + return + } + strongSelf.userLimits = value + }) } deinit { @@ -252,6 +294,7 @@ public final class AccountContextImpl: AccountContext { self.contentSettingsDisposable?.dispose() self.appConfigurationDisposable?.dispose() self.experimentalUISettingsDisposable?.dispose() + self.animatedEmojiStickersDisposable?.dispose() } public func storeSecureIdPassword(password: String) { diff --git a/submodules/TelegramUI/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Sources/ChatControllerInteraction.swift index 4317870fcd..d40b4d9d30 100644 --- a/submodules/TelegramUI/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Sources/ChatControllerInteraction.swift @@ -138,6 +138,7 @@ public final class ChatControllerInteraction { var canPlayMedia: Bool = false var hiddenMedia: [MessageId: [Media]] = [:] + var expandedTranslationMessageStableIds: Set = Set() var selectionState: ChatInterfaceSelectionState? var highlightedState: ChatInterfaceHighlightedState? var contextHighlightedState: ChatInterfaceHighlightedState? diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 829c731b86..af990eda74 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -1932,9 +1932,12 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { case .none: break case .inputButtons: - self.interfaceInteraction?.updateInputModeAndDismissedButtonKeyboardMessageId({ state in - return (.none, state.keyboardButtonsMessage?.id ?? state.interfaceState.messageActionsState.closedButtonKeyboardMessageId) - }) + if let peer = self.chatPresentationInterfaceState.renderedPeer?.peer as? TelegramUser, peer.botInfo != nil { + } else { + self.interfaceInteraction?.updateInputModeAndDismissedButtonKeyboardMessageId({ state in + return (.none, state.keyboardButtonsMessage?.id ?? state.interfaceState.messageActionsState.closedButtonKeyboardMessageId) + }) + } default: self.interfaceInteraction?.updateInputModeAndDismissedButtonKeyboardMessageId({ state in return (.none, state.interfaceState.messageActionsState.closedButtonKeyboardMessageId) @@ -2427,7 +2430,7 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { for text in breakChatInputText(trimChatInputText(inputText)) { if text.length != 0 { var attributes: [MessageAttribute] = [] - let entities = generateTextEntities(text.string, enabledTypes: .all, currentEntities: generateChatInputTextEntities(text)) + let entities = generateTextEntities(text.string, enabledTypes: .all, currentEntities: generateChatInputTextEntities(text, maxAnimatedEmojisInText: Int(self.context.userLimits.maxAnimatedEmojisInText))) if !entities.isEmpty { attributes.append(TextEntitiesMessageAttribute(entities: entities)) } diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index e6a632fcef..7e843ba639 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -684,6 +684,25 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } + var audioTranscription: AudioTranscriptionMessageAttribute? + for attribute in message.attributes { + if let attribute = attribute as? AudioTranscriptionMessageAttribute { + audioTranscription = attribute + break + } + } + + if let audioTranscription = audioTranscription { + actions.insert(.custom(ChatRateTranscriptionContextItem(context: context, message: message, action: { [weak context] value in + guard let context = context else { + return + } + + let _ = context.engine.messages.rateAudioTranscription(messageId: message.id, id: audioTranscription.id, isGood: value).start() + }), false), at: 0) + actions.insert(.separator, at: 1) + } + for media in message.media { if let file = media as? TelegramMediaFile, let size = file.size, size < 1 * 1024 * 1024, let duration = file.duration, duration < 60, (["audio/mpeg", "audio/mp3", "audio/mpeg3", "audio/ogg"] as [String]).contains(file.mimeType.lowercased()) { let fileName = file.fileName ?? "Tone" @@ -716,9 +735,9 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }) } }))) + actions.append(.separator) } } - actions.append(.separator) } var isReplyThreadHead = false @@ -773,6 +792,16 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } } + for attribute in message.attributes { + if let attribute = attribute as? AudioTranscriptionMessageAttribute { + if !messageText.isEmpty { + messageText.append("\n") + } + messageText.append(attribute.text) + break + } + } + var isPoll = false if messageText.isEmpty { for media in message.media { @@ -2293,3 +2322,162 @@ private func stringForRemainingTime(_ duration: Int32, strings: PresentationStri } return strings.Conversation_AutoremoveRemainingTime(durationString).string } + +final class ChatRateTranscriptionContextItem: ContextMenuCustomItem { + fileprivate let context: AccountContext + fileprivate let message: Message + fileprivate let action: (Bool) -> Void + + init(context: AccountContext, message: Message, action: @escaping (Bool) -> Void) { + self.context = context + self.message = message + self.action = action + } + + func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode { + return ChatRateTranscriptionContextItemNode(presentationData: presentationData, item: self, getController: getController, actionSelected: actionSelected, action: self.action) + } +} + +private final class ChatRateTranscriptionContextItemNode: ASDisplayNode, ContextMenuCustomNode, ContextActionNodeProtocol { + private let item: ChatRateTranscriptionContextItem + private var presentationData: PresentationData + private let getController: () -> ContextControllerProtocol? + private let actionSelected: (ContextMenuActionResult) -> Void + private let action: (Bool) -> Void + + private let backgroundNode: ASDisplayNode + private let textNode: ImmediateTextNode + + private let upButtonImageNode: ASImageNode + private let downButtonImageNode: ASImageNode + private let upButtonNode: HighlightableButtonNode + private let downButtonNode: HighlightableButtonNode + + init(presentationData: PresentationData, item: ChatRateTranscriptionContextItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void, action: @escaping (Bool) -> Void) { + self.item = item + self.presentationData = presentationData + self.getController = getController + self.actionSelected = actionSelected + self.action = action + + let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize * 15.0 / 17.0) + + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isAccessibilityElement = false + self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor + + self.textNode = ImmediateTextNode() + self.textNode.isAccessibilityElement = false + self.textNode.isUserInteractionEnabled = false + self.textNode.displaysAsynchronously = false + self.textNode.attributedText = NSAttributedString(string: "Rate Transcription", font: textFont, textColor: presentationData.theme.contextMenu.secondaryColor) + self.textNode.maximumNumberOfLines = 1 + + self.upButtonImageNode = ASImageNode() + self.upButtonImageNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/DarkMode"), color: presentationData.theme.contextMenu.primaryColor, backgroundColor: nil) + self.upButtonImageNode.isUserInteractionEnabled = false + + self.downButtonImageNode = ASImageNode() + self.downButtonImageNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: presentationData.theme.contextMenu.primaryColor, backgroundColor: nil) + self.downButtonImageNode.isUserInteractionEnabled = false + + self.upButtonNode = HighlightableButtonNode() + self.upButtonNode.addSubnode(self.upButtonImageNode) + + self.downButtonNode = HighlightableButtonNode() + self.downButtonNode.addSubnode(self.downButtonImageNode) + + super.init() + + self.addSubnode(self.backgroundNode) + self.addSubnode(self.textNode) + + self.addSubnode(self.upButtonNode) + self.addSubnode(self.downButtonNode) + + self.upButtonNode.addTarget(self, action: #selector(self.upPressed), forControlEvents: .touchUpInside) + self.downButtonNode.addTarget(self, action: #selector(self.downPressed), forControlEvents: .touchUpInside) + } + + deinit { + } + + override func didLoad() { + super.didLoad() + } + + @objc private func upPressed() { + self.action(true) + self.getController()?.dismiss(completion: nil) + } + + @objc private func downPressed() { + self.action(false) + self.getController()?.dismiss(completion: nil) + } + + func updateLayout(constrainedWidth: CGFloat, constrainedHeight: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) { + let sideInset: CGFloat = 14.0 + let verticalInset: CGFloat = 9.0 + + let calculatedWidth = min(constrainedWidth, 250.0) + + let textSize = self.textNode.updateLayout(CGSize(width: calculatedWidth - sideInset, height: .greatestFiniteMagnitude)) + + let combinedTextHeight = textSize.height + return (CGSize(width: calculatedWidth, height: verticalInset * 2.0 + combinedTextHeight + 35.0), { size, transition in + let verticalOrigin = verticalInset + let textFrame = CGRect(origin: CGPoint(x: floor((size.width - textSize.width) / 2.0), y: verticalOrigin), size: textSize) + transition.updateFrameAdditive(node: self.textNode, frame: textFrame) + + let buttonArea = CGRect(origin: CGPoint(x: 0.0, y: size.height - 35.0 - 6.0), size: CGSize(width: size.width, height: 35.0)) + + self.upButtonNode.frame = CGRect(origin: CGPoint(x: buttonArea.minX, y: buttonArea.minY), size: CGSize(width: floor(buttonArea.size.width / 2.0), height: buttonArea.height)) + self.downButtonNode.frame = CGRect(origin: CGPoint(x: buttonArea.minX + floor(buttonArea.size.width / 2.0), y: buttonArea.minY), size: CGSize(width: floor(buttonArea.size.width / 2.0), height: buttonArea.height)) + + let spacing: CGFloat = 56.0 + + if let image = self.upButtonImageNode.image { + self.upButtonImageNode.frame = CGRect(origin: CGPoint(x: floor(buttonArea.width / 2.0) - floor(spacing / 2.0) - image.size.width, y: floor((buttonArea.height - image.size.height) / 2.0)), size: image.size) + } + if let image = self.downButtonImageNode.image { + self.downButtonImageNode.frame = CGRect(origin: CGPoint(x: floor(spacing / 2.0), y: floor((buttonArea.height - image.size.height) / 2.0)), size: image.size) + } + + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) + }) + } + + func updateTheme(presentationData: PresentationData) { + self.presentationData = presentationData + + self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor + + let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize) + + self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor) + } + + func canBeHighlighted() -> Bool { + return self.isActionEnabled + } + + func updateIsHighlighted(isHighlighted: Bool) { + self.setIsHighlighted(isHighlighted) + } + + func performAction() { + } + + var isActionEnabled: Bool { + return false + } + + func setIsHighlighted(_ value: Bool) { + } + + func actionNode(at point: CGPoint) -> ContextActionNodeProtocol { + return self + } +} diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift index 86f7254fd9..913a944ac5 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift @@ -175,7 +175,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { private var fileIconImage: UIImage? private var audioTranscriptionState: AudioTranscriptionButtonComponent.TranscriptionState = .possible - private var transcribedText: String? + private var transcribedText: EngineAudioTranscriptionResult? private var transcribeDisposable: Disposable? override init() { @@ -305,6 +305,17 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { guard let context = self.context, let message = self.message, let presentationData = self.presentationData else { return } + + if self.transcribedText == nil { + for attribute in message.attributes { + if let attribute = attribute as? AudioTranscriptionMessageAttribute { + self.transcribedText = .success(EngineAudioTranscriptionResult.Success(id: attribute.id, text: attribute.text)) + self.audioTranscriptionState = .collapsed + break + } + } + } + if self.transcribedText == nil { if self.transcribeDisposable == nil { self.audioTranscriptionState = .inProgress @@ -352,7 +363,11 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { return } strongSelf.transcribeDisposable = nil - strongSelf.transcribedText = result + if let result = result { + strongSelf.transcribedText = .success(EngineAudioTranscriptionResult.Success(id: 0, text: result)) + } else { + strongSelf.transcribedText = .error + } if strongSelf.transcribedText != nil { strongSelf.audioTranscriptionState = .expanded } else { @@ -368,7 +383,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { } strongSelf.transcribeDisposable = nil strongSelf.audioTranscriptionState = .expanded - strongSelf.transcribedText = result?.text + strongSelf.transcribedText = result strongSelf.requestUpdateLayout(true) }) } @@ -573,7 +588,14 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { let textFont = arguments.presentationData.messageFont let textString: NSAttributedString? if let transcribedText = transcribedText, case .expanded = audioTranscriptionState { - textString = NSAttributedString(string: transcribedText, font: textFont, textColor: messageTheme.primaryTextColor) + switch transcribedText { + case let .success(success): + textString = NSAttributedString(string: success.text, font: textFont, textColor: messageTheme.primaryTextColor) + case .error: + let errorTextFont = Font.regular(floor(arguments.presentationData.fontSize.baseDisplaySize * 15.0 / 17.0)) + //TODO:localize + textString = NSAttributedString(string: "No speech detected", font: errorTextFont, textColor: messageTheme.secondaryTextColor) + } } else { textString = nil } diff --git a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift index 1e77029daf..7fd81efd49 100644 --- a/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageTextBubbleContentNode.swift @@ -91,10 +91,11 @@ private final class InlineStickerItemLayer: SimpleLayer { let pathPrefix = context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id) - self.disposable = (self.source.directDataPath() + self.disposable = (self.source.directDataPath(attemptSynchronously: false) + |> filter { $0 != nil } |> take(1) |> deliverOn(InlineStickerItemLayer.queue)).start(next: { [weak self] path in - guard let directData = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.mappedRead]) else { + guard let directData = try? Data(contentsOf: URL(fileURLWithPath: path!), options: [.mappedRead]) else { return } Queue.mainQueue().async { @@ -161,12 +162,6 @@ private final class InlineStickerItemLayer: SimpleLayer { guard let frameSource = self.frameSource else { return } - if self.contents != nil { - return - } - if self.didRequestFrame { - return - } self.didRequestFrame = true frameSource.with { [weak self] impl in if let animationFrame = impl.takeFrame(draw: true) { @@ -200,9 +195,6 @@ private final class InlineStickerItemLayer: SimpleLayer { guard let strongSelf = self else { return } - if strongSelf.contents != nil { - return - } strongSelf.contents = image.cgImage } } @@ -473,41 +465,72 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { attributedText = NSAttributedString(string: " ", font: textFont, textColor: messageTheme.primaryTextColor) } - /*if item.context.sharedContext.immediateExperimentalUISettings.inlineStickers*/ do { - var currentCount = 0 + if let entities = entities { let updatedString = NSMutableAttributedString(attributedString: attributedText) - var startIndex = updatedString.string.startIndex - while true { - var hadUpdates = false - updatedString.string.enumerateSubstrings(in: startIndex ..< updatedString.string.endIndex, options: [.byComposedCharacterSequences]) { substring, substringRange, _, stop in - if let substring = substring { - let emoji = substring.basicEmoji.0 - - var emojiFile: TelegramMediaFile? - emojiFile = item.associatedData.animatedEmojiStickers[emoji]?.first?.file - if emojiFile == nil { - emojiFile = item.associatedData.animatedEmojiStickers[emoji.strippedEmoji]?.first?.file - } - - if let emojiFile = emojiFile { - let currentDict = updatedString.attributes(at: NSRange(substringRange, in: updatedString.string).lowerBound, effectiveRange: nil) - var updatedAttributes: [NSAttributedString.Key: Any] = currentDict - updatedAttributes[NSAttributedString.Key.foregroundColor] = UIColor.clear.cgColor - updatedAttributes[NSAttributedString.Key("Attribute__EmbeddedItem")] = InlineStickerItem(file: emojiFile) + + for entity in entities.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { + guard case .AnimatedEmoji = entity.type else { + continue + } + + let range = NSRange(location: entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound) + + let substring = updatedString.attributedSubstring(from: range) + + let emoji = substring.string.basicEmoji.0 + + var emojiFile: TelegramMediaFile? + emojiFile = item.associatedData.animatedEmojiStickers[emoji]?.first?.file + if emojiFile == nil { + emojiFile = item.associatedData.animatedEmojiStickers[emoji.strippedEmoji]?.first?.file + } + + if let emojiFile = emojiFile { + let currentDict = updatedString.attributes(at: range.lowerBound, effectiveRange: nil) + var updatedAttributes: [NSAttributedString.Key: Any] = currentDict + updatedAttributes[NSAttributedString.Key.foregroundColor] = UIColor.clear.cgColor + updatedAttributes[NSAttributedString.Key("Attribute__EmbeddedItem")] = InlineStickerItem(file: emojiFile) + + let insertString = NSAttributedString(string: "[\u{00a0}\u{00a0}\u{00a0}]", attributes: updatedAttributes) + //updatedString.insert(insertString, at: NSRange(substringRange, in: updatedString.string).upperBound) + updatedString.replaceCharacters(in: range, with: insertString) + } + + /*var currentCount = 0 + let updatedString = NSMutableAttributedString(attributedString: attributedText) + var startIndex = updatedString.string.startIndex + while true { + var hadUpdates = false + updatedString.string.enumerateSubstrings(in: startIndex ..< updatedString.string.endIndex, options: [.byComposedCharacterSequences]) { substring, substringRange, _, stop in + if let substring = substring { + let emoji = substring.basicEmoji.0 - let insertString = NSAttributedString(string: "[\u{00a0}\u{00a0}\u{00a0}]", attributes: updatedAttributes) - //updatedString.insert(insertString, at: NSRange(substringRange, in: updatedString.string).upperBound) - updatedString.replaceCharacters(in: NSRange(substringRange, in: updatedString.string), with: insertString) - startIndex = substringRange.lowerBound - currentCount += 1 - hadUpdates = true - stop = true + var emojiFile: TelegramMediaFile? + emojiFile = item.associatedData.animatedEmojiStickers[emoji]?.first?.file + if emojiFile == nil { + emojiFile = item.associatedData.animatedEmojiStickers[emoji.strippedEmoji]?.first?.file + } + + if let emojiFile = emojiFile { + let currentDict = updatedString.attributes(at: NSRange(substringRange, in: updatedString.string).lowerBound, effectiveRange: nil) + var updatedAttributes: [NSAttributedString.Key: Any] = currentDict + updatedAttributes[NSAttributedString.Key.foregroundColor] = UIColor.clear.cgColor + updatedAttributes[NSAttributedString.Key("Attribute__EmbeddedItem")] = InlineStickerItem(file: emojiFile) + + let insertString = NSAttributedString(string: "[\u{00a0}\u{00a0}\u{00a0}]", attributes: updatedAttributes) + //updatedString.insert(insertString, at: NSRange(substringRange, in: updatedString.string).upperBound) + updatedString.replaceCharacters(in: NSRange(substringRange, in: updatedString.string), with: insertString) + startIndex = substringRange.lowerBound + currentCount += 1 + hadUpdates = true + stop = true + } } } - } - if !hadUpdates || currentCount >= 10 { - break - } + if !hadUpdates || currentCount >= 10 { + break + } + }*/ } attributedText = updatedString } diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index b8cb0c4f2b..4e4fbe4a86 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -23,6 +23,8 @@ import Pasteboard import ChatPresentationInterfaceState import ManagedAnimationNode import AttachmentUI +import EditableChatTextNode +import EmojiTextAttachmentView private let accessoryButtonFont = Font.medium(14.0) private let counterFont = Font.with(size: 14.0, design: .regular, traits: [.monospacedNumbers]) @@ -409,8 +411,13 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { accentTextColor = presentationInterfaceState.theme.chat.inputPanel.panelControlAccentColor baseFontSize = max(minInputFontSize, presentationInterfaceState.fontSize.baseDisplaySize) } - textInputNode.attributedText = textAttributedStringForStateText(state.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed) + textInputNode.attributedText = textAttributedStringForStateText(state.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed, availableEmojis: (self.context?.animatedEmojiStickers.keys).flatMap(Set.init) ?? Set(), emojiViewProvider: self.emojiViewProvider) textInputNode.selectedRange = NSMakeRange(state.selectionRange.lowerBound, state.selectionRange.count) + + if let presentationInterfaceState = self.presentationInterfaceState { + refreshChatTextInputAttributes(textInputNode, theme: presentationInterfaceState.theme, baseFontSize: baseFontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: (self.context?.animatedEmojiStickers.keys).flatMap(Set.init) ?? Set(), emojiViewProvider: self.emojiViewProvider) + } + self.updatingInputState = false self.keepSendButtonEnabled = keepSendButtonEnabled self.extendedSearchLayout = extendedSearchLayout @@ -452,6 +459,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { private var touchDownGestureRecognizer: TouchDownGestureRecognizer? + private var emojiViewProvider: ((String) -> UIView)? + init(presentationInterfaceState: ChatPresentationInterfaceState, presentationContext: ChatPresentationContext?, presentController: @escaping (ViewController) -> Void) { self.presentationInterfaceState = presentationInterfaceState @@ -657,6 +666,14 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { } self.textInputBackgroundNode.isUserInteractionEnabled = true self.textInputBackgroundNode.view.addGestureRecognizer(recognizer) + + self.emojiViewProvider = { [weak self] emoji in + guard let strongSelf = self, let context = strongSelf.context, let file = strongSelf.context?.animatedEmojiStickers[emoji]?.first?.file else { + return UIView() + } + + return EmojiTextAttachmentView(context: context, file: file) + } } required init?(coder aDecoder: NSCoder) { @@ -674,7 +691,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { } private func loadTextInputNode() { - let textInputNode = EditableTextNode() + let textInputNode = EditableChatTextNode() textInputNode.initialPrimaryLanguage = self.presentationInterfaceState?.interfaceState.inputLanguage var textColor: UIColor = .black var tintColor: UIColor = .blue @@ -1858,7 +1875,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { @objc func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) { if let textInputNode = self.textInputNode, let presentationInterfaceState = self.presentationInterfaceState { let baseFontSize = max(minInputFontSize, presentationInterfaceState.fontSize.baseDisplaySize) - refreshChatTextInputAttributes(textInputNode, theme: presentationInterfaceState.theme, baseFontSize: baseFontSize, spoilersRevealed: self.spoilersRevealed) + refreshChatTextInputAttributes(textInputNode, theme: presentationInterfaceState.theme, baseFontSize: baseFontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: (self.context?.animatedEmojiStickers.keys).flatMap(Set.init) ?? Set(), emojiViewProvider: self.emojiViewProvider) refreshChatTextInputTypingAttributes(textInputNode, theme: presentationInterfaceState.theme, baseFontSize: baseFontSize) self.updateSpoiler() @@ -1983,9 +2000,9 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { textInputNode.textView.isScrollEnabled = false - refreshChatTextInputAttributes(textInputNode, theme: presentationInterfaceState.theme, baseFontSize: baseFontSize, spoilersRevealed: self.spoilersRevealed) + refreshChatTextInputAttributes(textInputNode, theme: presentationInterfaceState.theme, baseFontSize: baseFontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: (self.context?.animatedEmojiStickers.keys).flatMap(Set.init) ?? Set(), emojiViewProvider: self.emojiViewProvider) - textInputNode.attributedText = textAttributedStringForStateText(self.inputTextState.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed) + textInputNode.attributedText = textAttributedStringForStateText(self.inputTextState.inputText, fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed, availableEmojis: (self.context?.animatedEmojiStickers.keys).flatMap(Set.init) ?? Set(), emojiViewProvider: self.emojiViewProvider) if textInputNode.textView.subviews.count > 1, animated { let containerView = textInputNode.textView.subviews[1] @@ -2310,6 +2327,12 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { func editableTextNodeDidFinishEditing(_ editableTextNode: ASEditableTextNode) { self.storedInputLanguage = editableTextNode.textInputMode.primaryLanguage self.inputMenu.deactivate() + + if let presentationInterfaceState = self.presentationInterfaceState, let peer = presentationInterfaceState.renderedPeer?.peer as? TelegramUser, peer.botInfo != nil { + self.interfaceInteraction?.updateInputModeAndDismissedButtonKeyboardMessageId { _ in + return (.inputButtons, nil) + } + } } func editableTextNodeTarget(forAction action: Selector) -> ASEditableTextNodeTargetForAction? { @@ -2487,7 +2510,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { accentTextColor = presentationInterfaceState.theme.chat.inputPanel.panelControlAccentColor baseFontSize = max(minInputFontSize, presentationInterfaceState.fontSize.baseDisplaySize) } - let cleanReplacementString = textAttributedStringForStateText(NSAttributedString(string: cleanText), fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed) + let cleanReplacementString = textAttributedStringForStateText(NSAttributedString(string: cleanText), fontSize: baseFontSize, textColor: textColor, accentTextColor: accentTextColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed, availableEmojis: (self.context?.animatedEmojiStickers.keys).flatMap(Set.init) ?? Set(), emojiViewProvider: self.emojiViewProvider) string.replaceCharacters(in: range, with: cleanReplacementString) self.textInputNode?.attributedText = string self.textInputNode?.selectedRange = NSMakeRange(range.lowerBound + cleanReplacementString.length, 0) diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index bcf573f800..7637f0f6a0 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -2929,7 +2929,11 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate |> `catch` { _ -> Signal in return .single(false) })) + + #if DEBUG + #else self.cachedFaq.set(.single(nil) |> then(cachedFaqInstantPage(context: self.context) |> map(Optional.init))) + #endif screenData = peerInfoScreenSettingsData(context: context, peerId: peerId, accountsAndPeers: self.accountsAndPeers.get(), activeSessionsContextAndCount: self.activeSessionsContextAndCount.get(), notificationExceptions: self.notificationExceptions.get(), privacySettings: self.privacySettings.get(), archivedStickerPacks: self.archivedPacks.get(), hasPassport: self.hasPassport.get()) diff --git a/submodules/TextFormat/BUILD b/submodules/TextFormat/BUILD index 2585c4f6cd..99474d4fed 100644 --- a/submodules/TextFormat/BUILD +++ b/submodules/TextFormat/BUILD @@ -14,6 +14,7 @@ swift_library( "//submodules/Display:Display", "//submodules/TelegramPresentationData:TelegramPresentationData", "//submodules/Markdown:Markdown", + "//submodules/Emoji:Emoji", ], visibility = [ "//visibility:public", diff --git a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift index 5969580f71..f9dbd1d6e7 100644 --- a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift +++ b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift @@ -4,6 +4,7 @@ import Display import AsyncDisplayKit import Postbox import TelegramPresentationData +import Emoji private let alphanumericCharacters = CharacterSet.alphanumerics @@ -21,12 +22,28 @@ public struct ChatTextInputAttributes { } public func stateAttributedStringForText(_ text: NSAttributedString) -> NSAttributedString { - let result = NSMutableAttributedString(string: text.string) + let sourceString = NSMutableAttributedString(attributedString: text) + while true { + var found = false + let fullRange = NSRange(sourceString.string.startIndex ..< sourceString.string.endIndex, in: sourceString.string) + sourceString.enumerateAttribute(NSAttributedString.Key.attachment, in: fullRange, options: [.longestEffectiveRangeNotRequired], using: { value, range, stop in + if let value = value as? EmojiTextAttachment { + sourceString.replaceCharacters(in: range, with: NSAttributedString(string: value.emoji)) + stop.pointee = true + found = true + } + }) + if !found { + break + } + } + + let result = NSMutableAttributedString(string: sourceString.string) let fullRange = NSRange(location: 0, length: result.length) - text.enumerateAttributes(in: fullRange, options: [], using: { attributes, range, _ in + sourceString.enumerateAttributes(in: fullRange, options: [], using: { attributes, range, _ in for (key, value) in attributes { - if ChatTextInputAttributes.allAttributes.contains(key) { + if ChatTextInputAttributes.allAttributes.contains(key) || key == NSAttributedString.Key.attachment { result.addAttribute(key, value: value, range: range) } } @@ -47,7 +64,7 @@ public struct ChatTextFontAttributes: OptionSet { public static let blockQuote = ChatTextFontAttributes(rawValue: 1 << 3) } -public func textAttributedStringForStateText(_ stateText: NSAttributedString, fontSize: CGFloat, textColor: UIColor, accentTextColor: UIColor, writingDirection: NSWritingDirection?, spoilersRevealed: Bool) -> NSAttributedString { +public func textAttributedStringForStateText(_ stateText: NSAttributedString, fontSize: CGFloat, textColor: UIColor, accentTextColor: UIColor, writingDirection: NSWritingDirection?, spoilersRevealed: Bool, availableEmojis: Set, emojiViewProvider: ((String) -> UIView)?) -> NSAttributedString { let result = NSMutableAttributedString(string: stateText.string) let fullRange = NSRange(location: 0, length: result.length) @@ -117,6 +134,36 @@ 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 } @@ -412,7 +459,7 @@ private func refreshTextUrls(text: NSString, initialAttributedText: NSAttributed } } -public func refreshChatTextInputAttributes(_ textNode: ASEditableTextNode, theme: PresentationTheme, baseFontSize: CGFloat, spoilersRevealed: Bool) { +public func refreshChatTextInputAttributes(_ textNode: ASEditableTextNode, theme: PresentationTheme, baseFontSize: CGFloat, spoilersRevealed: Bool, availableEmojis: Set, emojiViewProvider: ((String) -> UIView)?) { guard let initialAttributedText = textNode.attributedText, initialAttributedText.length != 0 else { return } @@ -427,16 +474,18 @@ public func refreshChatTextInputAttributes(_ textNode: ASEditableTextNode, theme var attributedText = NSMutableAttributedString(attributedString: stateAttributedStringForText(initialAttributedText)) refreshTextMentions(text: text, initialAttributedText: initialAttributedText, attributedText: attributedText, fullRange: fullRange) - var resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection, spoilersRevealed: spoilersRevealed) + var resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection, spoilersRevealed: spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider) text = resultAttributedText.string as NSString - fullRange = NSRange(location: 0, length: initialAttributedText.length) + fullRange = NSRange(location: 0, length: text.length) attributedText = NSMutableAttributedString(attributedString: stateAttributedStringForText(resultAttributedText)) refreshTextUrls(text: text, initialAttributedText: resultAttributedText, attributedText: attributedText, fullRange: fullRange) - resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection, spoilersRevealed: spoilersRevealed) + resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection, spoilersRevealed: spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider) if !resultAttributedText.isEqual(to: initialAttributedText) { + fullRange = NSRange(location: 0, length: textNode.textView.textStorage.length) + textNode.textView.textStorage.removeAttribute(NSAttributedString.Key.font, range: fullRange) textNode.textView.textStorage.removeAttribute(NSAttributedString.Key.foregroundColor, range: fullRange) textNode.textView.textStorage.removeAttribute(NSAttributedString.Key.underlineStyle, range: fullRange) @@ -508,9 +557,55 @@ public func refreshChatTextInputAttributes(_ textNode: ASEditableTextNode, theme } }) } + + if #available(iOS 15, *), let emojiViewProvider = 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 < 10 { + 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, spoilersRevealed: Bool = false) { +public func refreshGenericTextInputAttributes(_ textNode: ASEditableTextNode, theme: PresentationTheme, baseFontSize: CGFloat, availableEmojis: Set, emojiViewProvider: ((String) -> UIView)?, spoilersRevealed: Bool = false) { guard let initialAttributedText = textNode.attributedText, initialAttributedText.length != 0 else { return } @@ -523,14 +618,14 @@ public func refreshGenericTextInputAttributes(_ textNode: ASEditableTextNode, th var text: NSString = initialAttributedText.string as NSString var fullRange = NSRange(location: 0, length: initialAttributedText.length) var attributedText = NSMutableAttributedString(attributedString: stateAttributedStringForText(initialAttributedText)) - var resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection, spoilersRevealed: spoilersRevealed) + var resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection, spoilersRevealed: spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider) text = resultAttributedText.string as NSString fullRange = NSRange(location: 0, length: initialAttributedText.length) attributedText = NSMutableAttributedString(attributedString: stateAttributedStringForText(resultAttributedText)) refreshTextUrls(text: text, initialAttributedText: resultAttributedText, attributedText: attributedText, fullRange: fullRange) - resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection, spoilersRevealed: spoilersRevealed) + resultAttributedText = textAttributedStringForStateText(attributedText, fontSize: baseFontSize, textColor: theme.chat.inputPanel.primaryTextColor, accentTextColor: theme.chat.inputPanel.panelControlAccentColor, writingDirection: writingDirection, spoilersRevealed: spoilersRevealed, availableEmojis: availableEmojis, emojiViewProvider: emojiViewProvider) if !resultAttributedText.isEqual(to: initialAttributedText) { textNode.textView.textStorage.removeAttribute(NSAttributedString.Key.font, range: fullRange) @@ -805,3 +900,39 @@ public func convertMarkdownToAttributes(_ text: NSAttributedString) -> NSAttribu return text } + +private final class EmojiTextAttachment: NSTextAttachment { + let emoji: String + let viewProvider: (String) -> UIView + + init(index: Int, emoji: String, viewProvider: @escaping (String) -> UIView) { + self.emoji = emoji + self.viewProvider = viewProvider + + super.init(data: "\(emoji):\(index)".data(using: .utf8)!, ofType: "public.data") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +@available(iOS 15, *) +private final class CustomTextAttachmentViewProvider: NSTextAttachmentViewProvider { + static let ensureRegistered: Bool = { + NSTextAttachment.registerViewProviderClass(CustomTextAttachmentViewProvider.self, forFileType: "public.data") + + return true + }() + + override func loadView() { + super.loadView() + + if let attachment = self.textAttachment as? EmojiTextAttachment { + self.view = attachment.viewProvider(attachment.emoji) + } else { + self.view = UIView() + self.view!.backgroundColor = .clear + } + } +} diff --git a/submodules/TextFormat/Sources/GenerateTextEntities.swift b/submodules/TextFormat/Sources/GenerateTextEntities.swift index d5369b815c..aa2df5f7f6 100644 --- a/submodules/TextFormat/Sources/GenerateTextEntities.swift +++ b/submodules/TextFormat/Sources/GenerateTextEntities.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import TelegramCore +import Emoji private let whitelistedHosts: Set = Set([ "telegram.org", @@ -142,8 +143,29 @@ private func commitEntity(_ utf16: String.UTF16View, _ type: CurrentEntityType, } } -public func generateChatInputTextEntities(_ text: NSAttributedString) -> [MessageTextEntity] { +public func generateChatInputTextEntities(_ text: NSAttributedString, maxAnimatedEmojisInText: Int? = nil) -> [MessageTextEntity] { var entities: [MessageTextEntity] = [] + + if let maxAnimatedEmojisInText = maxAnimatedEmojisInText { + var count = 0 + text.string.enumerateSubstrings(in: text.string.startIndex ..< text.string.endIndex, options: [.byComposedCharacterSequences], { substring, substringRange, _, stop in + if let substring = substring { + let emoji = substring.basicEmoji.0 + + if !emoji.isEmpty && emoji.isSingleEmoji { + let mappedRange = NSRange(substringRange, in: text.string) + + entities.append(MessageTextEntity(range: mappedRange.lowerBound ..< mappedRange.upperBound, type: .AnimatedEmoji)) + + count += 1 + if count >= maxAnimatedEmojisInText { + stop = true + } + } + } + }) + } + text.enumerateAttributes(in: NSRange(location: 0, length: text.length), options: [], using: { attributes, range, _ in for (key, value) in attributes { if key == ChatTextInputAttributes.bold {