From 09e2e5bdc20a995f9ba822135a1774bac67fd43a Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 20 Jun 2023 18:05:20 +0400 Subject: [PATCH] Story caption improvements --- submodules/Camera/Sources/Camera.swift | 2 + submodules/ChatContextQuery/BUILD | 21 + .../Sources/ChatContextQuery.swift | 241 +++++++++ .../ChatPresentationInterfaceState/BUILD | 1 + .../ChatPresentationInterfaceState.swift | 48 +- .../Sources/PagerComponent.swift | 4 +- .../Sources/ContactsController.swift | 42 ++ .../Sources/ContactsControllerNode.swift | 64 ++- .../Display/Source/ContainerViewLayout.swift | 2 +- submodules/Display/Source/DeviceMetrics.swift | 6 +- .../Sources/DrawingStickerEntity.swift | 2 +- submodules/FeaturedStickersScreen/BUILD | 1 - .../Sources/ChatMediaInputTrendingPane.swift | 35 +- .../Sources/InvisibleInkDustNode.swift | 316 ++++++++++++ .../GlobalAutoremoveScreen.swift | 4 +- submodules/TelegramUI/BUILD | 1 + .../Sources/ChatEntityKeyboardInputNode.swift | 352 +++++++------ .../Sources/GifPaneSearchContentNode.swift | 10 +- .../Sources/PaneSearchContainerNode.swift | 10 +- .../StickerPaneSearchContentNode.swift | 35 +- .../Sources/EmojiPagerContentComponent.swift | 55 +- .../Sources/EmojiSearchContent.swift | 1 + .../Sources/GifPagerContentComponent.swift | 2 +- .../Sources/MediaEditorVideoExport.swift | 13 + .../Components/MediaEditorScreen/BUILD | 2 + .../Sources/MediaEditorScreen.swift | 466 +++++++++++++++-- .../Sources/StoryPreviewComponent.swift | 5 +- .../MessageInputPanelComponent/BUILD | 3 + .../Sources/ContextResultPanelComponent.swift | 375 ++++++++++++++ .../Sources/InputContextQueries.swift | 135 +++++ .../Sources/MessageInputPanelComponent.swift | 314 ++++++++++-- .../Stories/PeerListItemComponent/BUILD | 30 ++ .../Sources/PeerListItemComponent.swift | 134 +++-- .../Stories/StoryContainerScreen/BUILD | 2 + .../StoryItemSetContainerComponent.swift | 19 +- ...StoryItemSetContainerViewSendMessage.swift | 11 + .../StoryItemSetViewListComponent.swift | 5 + .../Sources/StoryChatContent.swift | 3 +- .../Components/TextFieldComponent/BUILD | 4 + .../Sources/TextFieldComponent.swift | 475 ++++++++++++++---- .../TelegramUI/Sources/ChatController.swift | 3 +- .../Sources/ChatControllerNode.swift | 36 +- .../Sources/ChatInterfaceInputContexts.swift | 177 +------ .../ChatInterfaceStateContextQueries.swift | 38 +- .../ChatRecentActionsControllerNode.swift | 2 +- .../Sources/ChatTextInputPanelNode.swift | 1 + .../CommandChatInputContextPanelNode.swift | 1 + ...CommandMenuChatInputContextPanelNode.swift | 1 + .../EmojisChatInputContextPanelNode.swift | 1 + .../HashtagChatInputContextPanelNode.swift | 1 + .../MentionChatInputContextPanelNode.swift | 1 + .../Sources/PeerInfo/PeerInfoScreen.swift | 4 +- .../Sources/TelegramRootController.swift | 8 +- .../Sources/ChatTextInputAttributes.swift | 26 + .../Sources/UndoOverlayController.swift | 2 +- .../Sources/UndoOverlayControllerNode.swift | 16 +- 56 files changed, 2903 insertions(+), 666 deletions(-) create mode 100644 submodules/ChatContextQuery/BUILD create mode 100644 submodules/ChatContextQuery/Sources/ChatContextQuery.swift create mode 100644 submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/ContextResultPanelComponent.swift create mode 100644 submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/InputContextQueries.swift create mode 100644 submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD rename submodules/TelegramUI/Components/Stories/{StoryContainerScreen => PeerListItemComponent}/Sources/PeerListItemComponent.swift (76%) diff --git a/submodules/Camera/Sources/Camera.swift b/submodules/Camera/Sources/Camera.swift index d2b658f5f7..5596929159 100644 --- a/submodules/Camera/Sources/Camera.swift +++ b/submodules/Camera/Sources/Camera.swift @@ -214,6 +214,8 @@ private final class CameraContext { func stopCapture(invalidate: Bool = false) { if invalidate { + self.setZoomLevel(1.0) + self.configure { self.mainDeviceContext.invalidate() } diff --git a/submodules/ChatContextQuery/BUILD b/submodules/ChatContextQuery/BUILD new file mode 100644 index 0000000000..e7940d5e71 --- /dev/null +++ b/submodules/ChatContextQuery/BUILD @@ -0,0 +1,21 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ChatContextQuery", + module_name = "ChatContextQuery", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/TelegramCore:TelegramCore", + "//submodules/TextFormat:TextFormat", + "//submodules/AccountContext:AccountContext", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/ChatContextQuery/Sources/ChatContextQuery.swift b/submodules/ChatContextQuery/Sources/ChatContextQuery.swift new file mode 100644 index 0000000000..7de2cbbb01 --- /dev/null +++ b/submodules/ChatContextQuery/Sources/ChatContextQuery.swift @@ -0,0 +1,241 @@ +import Foundation +import SwiftSignalKit +import TextFormat +import TelegramCore +import AccountContext + +public struct PossibleContextQueryTypes: OptionSet { + public var rawValue: Int32 + + public init() { + self.rawValue = 0 + } + + public init(rawValue: Int32) { + self.rawValue = rawValue + } + + public static let emoji = PossibleContextQueryTypes(rawValue: (1 << 0)) + public static let hashtag = PossibleContextQueryTypes(rawValue: (1 << 1)) + public static let mention = PossibleContextQueryTypes(rawValue: (1 << 2)) + public static let command = PossibleContextQueryTypes(rawValue: (1 << 3)) + public static let contextRequest = PossibleContextQueryTypes(rawValue: (1 << 4)) + public static let emojiSearch = PossibleContextQueryTypes(rawValue: (1 << 5)) +} + +private func scalarCanPrependQueryControl(_ c: UnicodeScalar?) -> Bool { + if let c = c { + if c == " " || c == "\n" || c == "." || c == "," { + return true + } + return false + } else { + return true + } +} + +private func makeScalar(_ c: Character) -> Character { + return c +} + +private let spaceScalar = " " as UnicodeScalar +private let newlineScalar = "\n" as UnicodeScalar +private let hashScalar = "#" as UnicodeScalar +private let atScalar = "@" as UnicodeScalar +private let slashScalar = "/" as UnicodeScalar +private let colonScalar = ":" as UnicodeScalar +private let alphanumerics = CharacterSet.alphanumerics + +public func textInputStateContextQueryRangeAndType(_ inputState: ChatTextInputState) -> [(NSRange, PossibleContextQueryTypes, NSRange?)] { + return textInputStateContextQueryRangeAndType(inputText: inputState.inputText, selectionRange: inputState.selectionRange) +} + +public func textInputStateContextQueryRangeAndType(inputText: NSAttributedString, selectionRange: Range) -> [(NSRange, PossibleContextQueryTypes, NSRange?)] { + if selectionRange.count != 0 { + return [] + } + + let inputString: NSString = inputText.string as NSString + var results: [(NSRange, PossibleContextQueryTypes, NSRange?)] = [] + let inputLength = inputString.length + + if inputLength != 0 { + if inputString.hasPrefix("@") && inputLength != 1 { + let startIndex = 1 + var index = startIndex + var contextAddressRange: NSRange? + + while true { + if index == inputLength { + break + } + if let c = UnicodeScalar(inputString.character(at: index)) { + if c == " " { + if index != startIndex { + contextAddressRange = NSRange(location: startIndex, length: index - startIndex) + index += 1 + } + break + } else { + if !((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_") { + break + } + } + + if index == inputLength { + break + } else { + index += 1 + } + } else { + index += 1 + } + } + + if let contextAddressRange = contextAddressRange { + results.append((contextAddressRange, [.contextRequest], NSRange(location: index, length: inputLength - index))) + } + } + + let maxIndex = min(selectionRange.lowerBound, inputLength) + if maxIndex == 0 { + return results + } + var index = maxIndex - 1 + + var possibleQueryRange: NSRange? + + let string = (inputString as String) + let trimmedString = string.trimmingTrailingSpaces() + if string.count < 3, trimmedString.isSingleEmoji { + if inputText.attribute(ChatTextInputAttributes.customEmoji, at: 0, effectiveRange: nil) == nil { + return [(NSRange(location: 0, length: inputString.length - (string.count - trimmedString.count)), [.emoji], nil)] + } + } else { + /*let activeString = inputText.attributedSubstring(from: NSRange(location: 0, length: inputState.selectionRange.upperBound)) + if let lastCharacter = activeString.string.last, String(lastCharacter).isSingleEmoji { + let matchLength = (String(lastCharacter) as NSString).length + + if activeString.attribute(ChatTextInputAttributes.customEmoji, at: activeString.length - matchLength, effectiveRange: nil) == nil { + return [(NSRange(location: inputState.selectionRange.upperBound - matchLength, length: matchLength), [.emojiSearch], nil)] + } + }*/ + } + + var possibleTypes = PossibleContextQueryTypes([.command, .mention, .hashtag, .emojiSearch]) + var definedType = false + + while true { + var previousC: UnicodeScalar? + if index != 0 { + previousC = UnicodeScalar(inputString.character(at: index - 1)) + } + if let c = UnicodeScalar(inputString.character(at: index)) { + if c == spaceScalar || c == newlineScalar { + possibleTypes = [] + } else if c == hashScalar { + if scalarCanPrependQueryControl(previousC) { + possibleTypes = possibleTypes.intersection([.hashtag]) + definedType = true + index += 1 + possibleQueryRange = NSRange(location: index, length: maxIndex - index) + } + break + } else if c == atScalar { + if scalarCanPrependQueryControl(previousC) { + possibleTypes = possibleTypes.intersection([.mention]) + definedType = true + index += 1 + possibleQueryRange = NSRange(location: index, length: maxIndex - index) + } + break + } else if c == slashScalar { + if scalarCanPrependQueryControl(previousC) { + possibleTypes = possibleTypes.intersection([.command]) + definedType = true + index += 1 + possibleQueryRange = NSRange(location: index, length: maxIndex - index) + } + break + } else if c == colonScalar { + if scalarCanPrependQueryControl(previousC) { + possibleTypes = possibleTypes.intersection([.emojiSearch]) + definedType = true + index += 1 + possibleQueryRange = NSRange(location: index, length: maxIndex - index) + } + break + } + } + + if index == 0 { + break + } else { + index -= 1 + possibleQueryRange = NSRange(location: index, length: maxIndex - index) + } + } + + if let possibleQueryRange = possibleQueryRange, definedType && !possibleTypes.isEmpty { + results.append((possibleQueryRange, possibleTypes, nil)) + } + } + return results +} + +public enum ChatPresentationInputQueryKind: Int32 { + case emoji + case hashtag + case mention + case command + case contextRequest + case emojiSearch +} + +public struct ChatInputQueryMentionTypes: OptionSet, Hashable { + public var rawValue: Int32 + + public init(rawValue: Int32) { + self.rawValue = rawValue + } + + public static let contextBots = ChatInputQueryMentionTypes(rawValue: 1 << 0) + public static let members = ChatInputQueryMentionTypes(rawValue: 1 << 1) + public static let accountPeer = ChatInputQueryMentionTypes(rawValue: 1 << 2) +} + +public enum ChatPresentationInputQuery: Hashable, Equatable { + case emoji(String) + case hashtag(String) + case mention(query: String, types: ChatInputQueryMentionTypes) + case command(String) + case emojiSearch(query: String, languageCode: String, range: NSRange) + case contextRequest(addressName: String, query: String) + + public var kind: ChatPresentationInputQueryKind { + switch self { + case .emoji: + return .emoji + case .hashtag: + return .hashtag + case .mention: + return .mention + case .command: + return .command + case .contextRequest: + return .contextRequest + case .emojiSearch: + return .emojiSearch + } + } +} + +public enum ChatContextQueryError { + case generic + case inlineBotLocationRequest(EnginePeer.Id) +} + +public enum ChatContextQueryUpdate { + case remove + case update(ChatPresentationInputQuery, Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError>) +} diff --git a/submodules/ChatPresentationInterfaceState/BUILD b/submodules/ChatPresentationInterfaceState/BUILD index a6c4d04ec5..e8c07bfa74 100644 --- a/submodules/ChatPresentationInterfaceState/BUILD +++ b/submodules/ChatPresentationInterfaceState/BUILD @@ -19,6 +19,7 @@ swift_library( "//submodules/ChatInterfaceState:ChatInterfaceState", "//submodules/TelegramUIPreferences:TelegramUIPreferences", "//submodules/TelegramPresentationData:TelegramPresentationData", + "//submodules/ChatContextQuery", ], visibility = [ "//visibility:public", diff --git a/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift b/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift index 1ea4a4052e..fad2f1e499 100644 --- a/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift +++ b/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift @@ -6,6 +6,7 @@ import TelegramPresentationData import TelegramUIPreferences import AccountContext import ChatInterfaceState +import ChatContextQuery public extension ChatLocation { var peerId: PeerId? { @@ -31,53 +32,6 @@ public extension ChatLocation { } } -public enum ChatPresentationInputQueryKind: Int32 { - case emoji - case hashtag - case mention - case command - case contextRequest - case emojiSearch -} - -public struct ChatInputQueryMentionTypes: OptionSet, Hashable { - public var rawValue: Int32 - - public init(rawValue: Int32) { - self.rawValue = rawValue - } - - public static let contextBots = ChatInputQueryMentionTypes(rawValue: 1 << 0) - public static let members = ChatInputQueryMentionTypes(rawValue: 1 << 1) - public static let accountPeer = ChatInputQueryMentionTypes(rawValue: 1 << 2) -} - -public enum ChatPresentationInputQuery: Hashable, Equatable { - case emoji(String) - case hashtag(String) - case mention(query: String, types: ChatInputQueryMentionTypes) - case command(String) - case emojiSearch(query: String, languageCode: String, range: NSRange) - case contextRequest(addressName: String, query: String) - - public var kind: ChatPresentationInputQueryKind { - switch self { - case .emoji: - return .emoji - case .hashtag: - return .hashtag - case .mention: - return .mention - case .command: - return .command - case .contextRequest: - return .contextRequest - case .emojiSearch: - return .emojiSearch - } - } -} - public enum ChatMediaInputMode { case gif case other diff --git a/submodules/Components/PagerComponent/Sources/PagerComponent.swift b/submodules/Components/PagerComponent/Sources/PagerComponent.swift index fc63876b10..2a36a49474 100644 --- a/submodules/Components/PagerComponent/Sources/PagerComponent.swift +++ b/submodules/Components/PagerComponent/Sources/PagerComponent.swift @@ -14,7 +14,7 @@ open class PagerExternalTopPanelContainer: SparseContainerView { } public protocol PagerContentViewWithBackground: UIView { - func pagerUpdateBackground(backgroundFrame: CGRect, transition: Transition) + func pagerUpdateBackground(backgroundFrame: CGRect, topPanelHeight: CGFloat, transition: Transition) } public final class PagerComponentChildEnvironment: Equatable { @@ -904,7 +904,7 @@ public final class PagerComponent() + private var panRecognizer: InteractiveTransitionGestureRecognizer? + init(context: AccountContext, sortOrder: Signal, present: @escaping (ViewController, Any?) -> Void, controller: ContactsController) { self.context = context self.controller = controller @@ -263,6 +265,34 @@ final class ContactsControllerNode: ASDisplayNode { self.storySubscriptionsDisposable?.dispose() } + override func didLoad() { + super.didLoad() + + let panRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: { _ in + let directions: InteractiveTransitionGestureRecognizerDirections = [.rightCenter, .rightEdge] + return directions + }, edgeWidth: .widthMultiplier(factor: 1.0 / 6.0, min: 22.0, max: 80.0)) + panRecognizer.delegate = self + panRecognizer.delaysTouchesBegan = false + panRecognizer.cancelsTouchesInView = true + self.panRecognizer = panRecognizer + self.view.addGestureRecognizer(panRecognizer) + } + + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } + + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if let _ = otherGestureRecognizer as? InteractiveTransitionGestureRecognizer { + return false + } + if let _ = otherGestureRecognizer as? UIPanGestureRecognizer { + return true + } + return false + } + private func updateThemeAndStrings() { self.backgroundColor = self.presentationData.theme.chatList.backgroundColor self.searchDisplayController?.updatePresentationData(self.presentationData) @@ -512,4 +542,36 @@ final class ContactsControllerNode: ASDisplayNode { placeholderNode.frame = previousFrame } } + + @objc private func panGesture(_ recognizer: UIPanGestureRecognizer) { + guard let (layout, _) = self.containerLayout else { + return + } + switch recognizer.state { + case .began: + break + case .changed: + let translation = recognizer.translation(in: self.view) + if case .compact = layout.metrics.widthClass { + let cameraIsAlreadyOpened = self.controller?.hasStoryCameraTransition ?? false + if translation.x > 0.0 { + self.controller?.storyCameraPanGestureChanged(transitionFraction: translation.x / layout.size.width) + } else if translation.x <= 0.0 && cameraIsAlreadyOpened { + self.controller?.storyCameraPanGestureChanged(transitionFraction: 0.0) + } + if cameraIsAlreadyOpened { + return + } + } + case .cancelled, .ended: + let translation = recognizer.translation(in: self.view) + let velocity = recognizer.velocity(in: self.view) + let hasStoryCameraTransition = self.controller?.hasStoryCameraTransition ?? false + if hasStoryCameraTransition { + self.controller?.storyCameraPanGestureEnded(transitionFraction: translation.x / layout.size.width, velocity: velocity.x) + } + default: + break + } + } } diff --git a/submodules/Display/Source/ContainerViewLayout.swift b/submodules/Display/Source/ContainerViewLayout.swift index edf00a8555..5a73047f18 100644 --- a/submodules/Display/Source/ContainerViewLayout.swift +++ b/submodules/Display/Source/ContainerViewLayout.swift @@ -160,6 +160,6 @@ public extension ContainerViewLayout { } var standardInputHeight: CGFloat { - return self.deviceMetrics.keyboardHeight(inLandscape: self.orientation == .landscape) + self.deviceMetrics.predictiveInputHeight(inLandscape: self.orientation == .landscape) + return self.deviceMetrics.standardInputHeight(inLandscape: self.orientation == .landscape) } } diff --git a/submodules/Display/Source/DeviceMetrics.swift b/submodules/Display/Source/DeviceMetrics.swift index 3e58e08d6e..32b74325ee 100644 --- a/submodules/Display/Source/DeviceMetrics.swift +++ b/submodules/Display/Source/DeviceMetrics.swift @@ -266,7 +266,7 @@ public enum DeviceMetrics: CaseIterable, Equatable { return 20.0 } } - + public func keyboardHeight(inLandscape: Bool) -> CGFloat { if inLandscape { switch self { @@ -337,6 +337,10 @@ public enum DeviceMetrics: CaseIterable, Equatable { } } + public func standardInputHeight(inLandscape: Bool) -> CGFloat { + return self.keyboardHeight(inLandscape: inLandscape) + predictiveInputHeight(inLandscape: inLandscape) + } + public var hasTopNotch: Bool { switch self { case .iPhoneX, .iPhoneXSMax, .iPhoneXr, .iPhone12Mini, .iPhone12, .iPhone12ProMax: diff --git a/submodules/DrawingUI/Sources/DrawingStickerEntity.swift b/submodules/DrawingUI/Sources/DrawingStickerEntity.swift index a905c6a31e..f43d8f35bc 100644 --- a/submodules/DrawingUI/Sources/DrawingStickerEntity.swift +++ b/submodules/DrawingUI/Sources/DrawingStickerEntity.swift @@ -582,7 +582,7 @@ final class DrawingStickerEntititySelectionView: DrawingEntitySelectionView, UIG } } -private let snapTimeout = 2.0 +private let snapTimeout = 1.0 class DrawingEntitySnapTool { enum SnapType { diff --git a/submodules/FeaturedStickersScreen/BUILD b/submodules/FeaturedStickersScreen/BUILD index 6ccb138918..629784cd42 100644 --- a/submodules/FeaturedStickersScreen/BUILD +++ b/submodules/FeaturedStickersScreen/BUILD @@ -31,7 +31,6 @@ swift_library( "//submodules/StickerResources:StickerResources", "//submodules/AnimatedStickerNode:AnimatedStickerNode", "//submodules/TelegramAnimatedStickerNode:TelegramAnimatedStickerNode", - "//submodules/TelegramUI/Components/ChatControllerInteraction:ChatControllerInteraction", ], visibility = [ "//visibility:public", diff --git a/submodules/FeaturedStickersScreen/Sources/ChatMediaInputTrendingPane.swift b/submodules/FeaturedStickersScreen/Sources/ChatMediaInputTrendingPane.swift index 9a784833c0..6e253b32c8 100644 --- a/submodules/FeaturedStickersScreen/Sources/ChatMediaInputTrendingPane.swift +++ b/submodules/FeaturedStickersScreen/Sources/ChatMediaInputTrendingPane.swift @@ -12,7 +12,6 @@ import AccountContext import StickerPackPreviewUI import PresentationDataUtils import UndoUI -import ChatControllerInteraction public final class TrendingPaneInteraction { public let installPack: (ItemCollectionInfo) -> Void @@ -192,8 +191,24 @@ private func trendingPaneEntries(trendingEntries: [FeaturedStickerPackItem], ins } public final class ChatMediaInputTrendingPane: ChatMediaInputPane { + public final class Interaction { + let sendSticker: (FileMediaReference, Bool, Bool, String?, Bool, UIView, CGRect, CALayer?, [ItemCollectionId]) -> Bool + let presentController: (ViewController, Any?) -> Void + let getNavigationController: () -> NavigationController? + + public init( + sendSticker: @escaping (FileMediaReference, Bool, Bool, String?, Bool, UIView, CGRect, CALayer?, [ItemCollectionId]) -> Bool, + presentController: @escaping (ViewController, Any?) -> Void, + getNavigationController: @escaping () -> NavigationController? + ) { + self.sendSticker = sendSticker + self.presentController = presentController + self.getNavigationController = getNavigationController + } + } + private let context: AccountContext - private let controllerInteraction: ChatControllerInteraction + private let interaction: ChatMediaInputTrendingPane.Interaction private let getItemIsPreviewed: (StickerPackItem) -> Bool private let isPane: Bool @@ -215,9 +230,9 @@ public final class ChatMediaInputTrendingPane: ChatMediaInputPane { private let installDisposable = MetaDisposable() - public init(context: AccountContext, controllerInteraction: ChatControllerInteraction, getItemIsPreviewed: @escaping (StickerPackItem) -> Bool, isPane: Bool) { + public init(context: AccountContext, interaction: ChatMediaInputTrendingPane.Interaction, getItemIsPreviewed: @escaping (StickerPackItem) -> Bool, isPane: Bool) { self.context = context - self.controllerInteraction = controllerInteraction + self.interaction = interaction self.getItemIsPreviewed = getItemIsPreviewed self.isPane = isPane @@ -279,7 +294,7 @@ public final class ChatMediaInputTrendingPane: ChatMediaInputPane { let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { cancelImpl?() })) - self?.controllerInteraction.presentController(controller, nil) + self?.interaction.presentController(controller, nil) return ActionDisposable { [weak controller] in Queue.mainQueue().async() { controller?.dismiss() @@ -306,7 +321,7 @@ public final class ChatMediaInputTrendingPane: ChatMediaInputPane { } var animateInAsReplacement = false - if let navigationController = strongSelf.controllerInteraction.navigationController() { + if let navigationController = strongSelf.interaction.getNavigationController() { for controller in navigationController.overlayControllers { if let controller = controller as? UndoOverlayController { controller.dismissWithCommitActionAndReplacementAnimation() @@ -316,7 +331,7 @@ public final class ChatMediaInputTrendingPane: ChatMediaInputPane { } let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - strongSelf.controllerInteraction.navigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: strongSelf.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in + strongSelf.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: strongSelf.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return true })) })) @@ -325,14 +340,14 @@ public final class ChatMediaInputTrendingPane: ChatMediaInputPane { if let strongSelf = self, let info = info as? StickerPackCollectionInfo { strongSelf.view.window?.endEditing(true) let packReference: StickerPackReference = .id(id: info.id.id, accessHash: info.accessHash) - let controller = StickerPackScreen(context: strongSelf.context, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: strongSelf.controllerInteraction.navigationController(), sendSticker: { fileReference, sourceNode, sourceRect in + let controller = StickerPackScreen(context: strongSelf.context, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: strongSelf.interaction.getNavigationController(), sendSticker: { fileReference, sourceNode, sourceRect in if let strongSelf = self { - return strongSelf.controllerInteraction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, []) + return strongSelf.interaction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, []) } else { return false } }) - strongSelf.controllerInteraction.presentController(controller, nil) + strongSelf.interaction.presentController(controller, nil) } }, getItemIsPreviewed: self.getItemIsPreviewed, openSearch: { [weak self] in diff --git a/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift index 51037faef1..800876877d 100644 --- a/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift +++ b/submodules/InvisibleInkDustNode/Sources/InvisibleInkDustNode.swift @@ -47,6 +47,322 @@ func generateMaskImage(size originalSize: CGSize, position: CGPoint, inverse: Bo }) } +public class InvisibleInkDustView: UIView { + private var currentParams: (size: CGSize, color: UIColor, textColor: UIColor, rects: [CGRect], wordRects: [CGRect])? + private var animColor: CGColor? + private let enableAnimations: Bool + + private weak var textNode: TextNode? + private let textMaskNode: ASDisplayNode + private let textSpotNode: ASImageNode + + private var emitterNode: ASDisplayNode + private var emitter: CAEmitterCell? + private var emitterLayer: CAEmitterLayer? + private let emitterMaskNode: ASDisplayNode + private let emitterSpotNode: ASImageNode + private let emitterMaskFillNode: ASDisplayNode + + private var staticNode: ASImageNode? + private var staticParams: (size: CGSize, color: UIColor, rects: [CGRect])? + + public var isRevealed = false + private var isExploding = false + + public init(textNode: TextNode?, enableAnimations: Bool) { + self.textNode = textNode + self.enableAnimations = enableAnimations + + self.emitterNode = ASDisplayNode() + self.emitterNode.isUserInteractionEnabled = false + self.emitterNode.clipsToBounds = true + + self.textMaskNode = ASDisplayNode() + self.textMaskNode.isUserInteractionEnabled = false + self.textSpotNode = ASImageNode() + self.textSpotNode.contentMode = .scaleToFill + self.textSpotNode.isUserInteractionEnabled = false + + self.emitterMaskNode = ASDisplayNode() + self.emitterSpotNode = ASImageNode() + self.emitterSpotNode.contentMode = .scaleToFill + self.emitterSpotNode.isUserInteractionEnabled = false + + self.emitterMaskFillNode = ASDisplayNode() + self.emitterMaskFillNode.backgroundColor = .white + self.emitterMaskFillNode.isUserInteractionEnabled = false + + super.init(frame: .zero) + + self.addSubnode(self.emitterNode) + + self.textMaskNode.addSubnode(self.textSpotNode) + self.emitterMaskNode.addSubnode(self.emitterSpotNode) + self.emitterMaskNode.addSubnode(self.emitterMaskFillNode) + + self.didLoad() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func didLoad() { + if self.enableAnimations { + let emitter = CAEmitterCell() + emitter.contents = UIImage(bundleImageName: "Components/TextSpeckle")?.cgImage + emitter.contentsScale = 1.8 + emitter.emissionRange = .pi * 2.0 + emitter.lifetime = 1.0 + emitter.scale = 0.5 + emitter.velocityRange = 20.0 + emitter.name = "dustCell" + emitter.alphaRange = 1.0 + emitter.setValue("point", forKey: "particleType") + emitter.setValue(3.0, forKey: "mass") + emitter.setValue(2.0, forKey: "massRange") + self.emitter = emitter + + let fingerAttractor = createEmitterBehavior(type: "simpleAttractor") + fingerAttractor.setValue("fingerAttractor", forKey: "name") + + let alphaBehavior = createEmitterBehavior(type: "valueOverLife") + alphaBehavior.setValue("color.alpha", forKey: "keyPath") + alphaBehavior.setValue([0.0, 0.0, 1.0, 0.0, -1.0], forKey: "values") + alphaBehavior.setValue(true, forKey: "additive") + + let behaviors = [fingerAttractor, alphaBehavior] + + let emitterLayer = CAEmitterLayer() + emitterLayer.masksToBounds = true + emitterLayer.allowsGroupOpacity = true + emitterLayer.lifetime = 1 + emitterLayer.emitterCells = [emitter] + emitterLayer.emitterPosition = CGPoint(x: 0, y: 0) + emitterLayer.seed = arc4random() + emitterLayer.emitterSize = CGSize(width: 1, height: 1) + emitterLayer.emitterShape = CAEmitterLayerEmitterShape(rawValue: "rectangles") + emitterLayer.setValue(behaviors, forKey: "emitterBehaviors") + + emitterLayer.setValue(4.0, forKeyPath: "emitterBehaviors.fingerAttractor.stiffness") + emitterLayer.setValue(false, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") + + self.emitterLayer = emitterLayer + + self.emitterNode.layer.addSublayer(emitterLayer) + } else { + let staticNode = ASImageNode() + self.staticNode = staticNode + self.addSubnode(staticNode) + } + + self.updateEmitter() + + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tap(_:)))) + } + + public func update(revealed: Bool, animated: Bool = true) { + guard self.isRevealed != revealed, let textNode = self.textNode else { + return + } + + self.isRevealed = revealed + + if revealed { + let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.3, curve: .linear) : .immediate + transition.updateAlpha(layer: self.layer, alpha: 0.0) + transition.updateAlpha(node: textNode, alpha: 1.0) + } else { + let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .linear) : .immediate + transition.updateAlpha(layer: self.layer, alpha: 1.0) + transition.updateAlpha(node: textNode, alpha: 0.0) + + if self.isExploding { + self.isExploding = false + self.emitterLayer?.setValue(false, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") + } + } + } + + @objc private func tap(_ gestureRecognizer: UITapGestureRecognizer) { + guard let (_, _, textColor, _, _) = self.currentParams, let textNode = self.textNode, !self.isRevealed else { + return + } + + self.isRevealed = true + + if self.enableAnimations { + self.isExploding = true + + let position = gestureRecognizer.location(in: self) + self.emitterLayer?.setValue(true, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") + self.emitterLayer?.setValue(position, forKeyPath: "emitterBehaviors.fingerAttractor.position") + + let maskSize = self.emitterNode.frame.size + Queue.concurrentDefaultQueue().async { + let textMaskImage = generateMaskImage(size: maskSize, position: position, inverse: false) + let emitterMaskImage = generateMaskImage(size: maskSize, position: position, inverse: true) + + Queue.mainQueue().async { + self.textSpotNode.image = textMaskImage + self.emitterSpotNode.image = emitterMaskImage + } + } + + Queue.mainQueue().after(0.1 * UIView.animationDurationFactor()) { + textNode.alpha = 1.0 + + textNode.view.mask = self.textMaskNode.view + self.textSpotNode.frame = CGRect(x: 0.0, y: 0.0, width: self.emitterMaskNode.frame.width * 3.0, height: self.emitterMaskNode.frame.height * 3.0) + + let xFactor = (position.x / self.emitterNode.frame.width - 0.5) * 2.0 + let yFactor = (position.y / self.emitterNode.frame.height - 0.5) * 2.0 + let maxFactor = max(abs(xFactor), abs(yFactor)) + + var scaleAddition = maxFactor * 4.0 + var durationAddition = -maxFactor * 0.2 + if self.emitterNode.frame.height > 0.0, self.emitterNode.frame.width / self.emitterNode.frame.height < 0.7 { + scaleAddition *= 5.0 + durationAddition *= 2.0 + } + + self.textSpotNode.layer.anchorPoint = CGPoint(x: position.x / self.emitterMaskNode.frame.width, y: position.y / self.emitterMaskNode.frame.height) + self.textSpotNode.position = position + self.textSpotNode.layer.animateScale(from: 0.3333, to: 10.5 + scaleAddition, duration: 0.55 + durationAddition, removeOnCompletion: false, completion: { _ in + textNode.view.mask = nil + }) + self.textSpotNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + + self.emitterNode.view.mask = self.emitterMaskNode.view + self.emitterSpotNode.frame = CGRect(x: 0.0, y: 0.0, width: self.emitterMaskNode.frame.width * 3.0, height: self.emitterMaskNode.frame.height * 3.0) + + self.emitterSpotNode.layer.anchorPoint = CGPoint(x: position.x / self.emitterMaskNode.frame.width, y: position.y / self.emitterMaskNode.frame.height) + self.emitterSpotNode.position = position + self.emitterSpotNode.layer.animateScale(from: 0.3333, to: 10.5 + scaleAddition, duration: 0.55 + durationAddition, removeOnCompletion: false, completion: { [weak self] _ in + self?.alpha = 0.0 + self?.emitterNode.view.mask = nil + + self?.emitter?.color = textColor.cgColor + }) + self.emitterMaskFillNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } + + Queue.mainQueue().after(0.8 * UIView.animationDurationFactor()) { + self.isExploding = false + self.emitterLayer?.setValue(false, forKeyPath: "emitterBehaviors.fingerAttractor.enabled") + self.textSpotNode.layer.removeAllAnimations() + + self.emitterSpotNode.layer.removeAllAnimations() + self.emitterMaskFillNode.layer.removeAllAnimations() + } + } else { + textNode.alpha = 1.0 + textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + + self.staticNode?.alpha = 0.0 + self.staticNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25) + } + } + + private func updateEmitter() { + guard let (size, color, _, lineRects, wordRects) = self.currentParams else { + return + } + + if self.enableAnimations { + self.emitter?.color = self.animColor ?? color.cgColor + self.emitterLayer?.setValue(wordRects, forKey: "emitterRects") + self.emitterLayer?.frame = CGRect(origin: CGPoint(), size: size) + + let radius = max(size.width, size.height) + self.emitterLayer?.setValue(max(size.width, size.height), forKeyPath: "emitterBehaviors.fingerAttractor.radius") + self.emitterLayer?.setValue(radius * -0.5, forKeyPath: "emitterBehaviors.fingerAttractor.falloff") + + var square: Float = 0.0 + for rect in wordRects { + square += Float(rect.width * rect.height) + } + + Queue.mainQueue().async { + self.emitter?.birthRate = min(100000, square * 0.35) + } + } else { + if let staticParams = self.staticParams, staticParams.size == size && staticParams.color == color && staticParams.rects == lineRects && self.staticNode?.image != nil { + return + } + self.staticParams = (size, color, lineRects) + + var combinedRect: CGRect? + var combinedRects: [CGRect] = [] + for rect in lineRects { + if let currentRect = combinedRect { + if abs(currentRect.minY - rect.minY) < 1.0 && abs(currentRect.maxY - rect.maxY) < 1.0 { + combinedRect = currentRect.union(rect) + } else { + combinedRects.append(currentRect.insetBy(dx: 0.0, dy: -1.0 + UIScreenPixel)) + combinedRect = rect + } + } else { + combinedRect = rect + } + } + if let combinedRect { + combinedRects.append(combinedRect.insetBy(dx: 0.0, dy: -1.0)) + } + + Queue.concurrentDefaultQueue().async { + var generator = ArbitraryRandomNumberGenerator(seed: 1) + let image = generateImage(size, rotatedContext: { size, context in + let bounds = CGRect(origin: .zero, size: size) + context.clear(bounds) + + context.setFillColor(color.cgColor) + for rect in combinedRects { + if rect.width > 10.0 { + let rate = Int(rect.width * rect.height * 0.25) + for _ in 0 ..< rate { + let location = CGPoint(x: .random(in: rect.minX ..< rect.maxX, using: &generator), y: .random(in: rect.minY ..< rect.maxY, using: &generator)) + context.fillEllipse(in: CGRect(origin: location, size: CGSize(width: 1.0, height: 1.0))) + } + } + } + }) + Queue.mainQueue().async { + self.staticNode?.image = image + } + } + self.staticNode?.frame = CGRect(origin: CGPoint(), size: size) + } + } + + public func update(size: CGSize, color: UIColor, textColor: UIColor, rects: [CGRect], wordRects: [CGRect]) { + self.currentParams = (size, color, textColor, rects, wordRects) + + let bounds = CGRect(origin: CGPoint(), size: size) + self.emitterNode.frame = bounds + self.emitterMaskNode.frame = bounds + self.emitterMaskFillNode.frame = bounds + self.textMaskNode.frame = CGRect(origin: CGPoint(x: 3.0, y: 3.0), size: size) + + self.staticNode?.frame = bounds + + self.updateEmitter() + } + + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + if let (_, _, _, rects, _) = self.currentParams, !self.isRevealed { + for rect in rects { + if rect.contains(point) { + return true + } + } + return false + } else { + return false + } + } +} + public class InvisibleInkDustNode: ASDisplayNode { private var currentParams: (size: CGSize, color: UIColor, textColor: UIColor, rects: [CGRect], wordRects: [CGRect])? private var animColor: CGColor? diff --git a/submodules/SettingsUI/Sources/Privacy and Security/GlobalAutoremoveScreen.swift b/submodules/SettingsUI/Sources/Privacy and Security/GlobalAutoremoveScreen.swift index 50fa263b07..93b2ac63e2 100644 --- a/submodules/SettingsUI/Sources/Privacy and Security/GlobalAutoremoveScreen.swift +++ b/submodules/SettingsUI/Sources/Privacy and Security/GlobalAutoremoveScreen.swift @@ -256,7 +256,7 @@ public func globalAutoremoveScreen(context: AccountContext, initialValue: Int32, return true } } - presentInCurrentControllerImpl?(UndoOverlayController(presentationData: presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text), elevatedLayout: false, animateInAsReplacement: animateAsReplacement, action: { _ in return false })) + presentInCurrentControllerImpl?(UndoOverlayController(presentationData: presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text, customUndoText: nil), elevatedLayout: false, animateInAsReplacement: animateAsReplacement, action: { _ in return false })) } updateTimeoutDisposable.set((context.engine.privacy.updateGlobalMessageRemovalTimeout(timeout: timeout == 0 ? nil : timeout) @@ -408,7 +408,7 @@ public func globalAutoremoveScreen(context: AccountContext, initialValue: Int32, return true } } - presentInCurrentControllerImpl?(UndoOverlayController(presentationData: presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text), elevatedLayout: false, animateInAsReplacement: animateAsReplacement, action: { _ in return false })) + presentInCurrentControllerImpl?(UndoOverlayController(presentationData: presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text, customUndoText: nil), elevatedLayout: false, animateInAsReplacement: animateAsReplacement, action: { _ in return false })) }) } }) diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index c944fc7b3b..8efb7f00da 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -376,6 +376,7 @@ swift_library( "//submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent", "//submodules/TelegramUI/Components/Stories/StorySetIndicatorComponent", "//submodules/Utils/VolumeButtons", + "//submodules/ChatContextQuery", ] + select({ "@build_bazel_rules_apple//apple:ios_armv7": [], "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift index 417756ab60..f425f2cdd4 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift @@ -32,6 +32,12 @@ import FeaturedStickersScreen import Pasteboard import StickerPackPreviewUI +public final class EmptyInputView: UIView, UIInputViewAudioFeedback { + public var enableInputClicksWhenVisible: Bool { + return true + } +} + public struct ChatMediaInputPaneScrollState { let absoluteOffset: CGFloat? let relativeChange: CGFloat @@ -68,6 +74,72 @@ public final class EntityKeyboardGifContent: Equatable { } public final class ChatEntityKeyboardInputNode: ChatInputNode { + public final class Interaction { + let sendSticker: (FileMediaReference, Bool, Bool, String?, Bool, UIView, CGRect, CALayer?, [ItemCollectionId]) -> Bool + let sendEmoji: (String, ChatTextInputTextCustomEmojiAttribute, Bool) -> Void + let sendGif: (FileMediaReference, UIView, CGRect, Bool, Bool) -> Bool + let sendBotContextResultAsGif: (ChatContextResultCollection, ChatContextResult, UIView, CGRect, Bool, Bool) -> Bool + let updateChoosingSticker: (Bool) -> Void + let switchToTextInput: () -> Void + let dismissTextInput: () -> Void + let insertText: (NSAttributedString) -> Void + let backwardsDeleteText: () -> Void + let presentController: (ViewController, Any?) -> Void + let presentGlobalOverlayController: (ViewController, Any?) -> Void + let getNavigationController: () -> NavigationController? + let requestLayout: (ContainedViewLayoutTransition) -> Void + + public init( + sendSticker: @escaping (FileMediaReference, Bool, Bool, String?, Bool, UIView, CGRect, CALayer?, [ItemCollectionId]) -> Bool, + sendEmoji: @escaping (String, ChatTextInputTextCustomEmojiAttribute, Bool) -> Void, + sendGif: @escaping (FileMediaReference, UIView, CGRect, Bool, Bool) -> Bool, + sendBotContextResultAsGif: @escaping (ChatContextResultCollection, ChatContextResult, UIView, CGRect, Bool, Bool) -> Bool, + updateChoosingSticker: @escaping (Bool) -> Void, + switchToTextInput: @escaping () -> Void, + dismissTextInput: @escaping () -> Void, + insertText: @escaping (NSAttributedString) -> Void, + backwardsDeleteText: @escaping () -> Void, + presentController: @escaping (ViewController, Any?) -> Void, + presentGlobalOverlayController: @escaping (ViewController, Any?) -> Void, + getNavigationController: @escaping () -> NavigationController?, + requestLayout: @escaping (ContainedViewLayoutTransition) -> Void + ) { + self.sendSticker = sendSticker + self.sendEmoji = sendEmoji + self.sendGif = sendGif + self.sendBotContextResultAsGif = sendBotContextResultAsGif + self.updateChoosingSticker = updateChoosingSticker + self.switchToTextInput = switchToTextInput + self.dismissTextInput = dismissTextInput + self.insertText = insertText + self.backwardsDeleteText = backwardsDeleteText + self.presentController = presentController + self.presentGlobalOverlayController = presentGlobalOverlayController + self.getNavigationController = getNavigationController + self.requestLayout = requestLayout + } + + public init(chatControllerInteraction: ChatControllerInteraction, panelInteraction: ChatPanelInterfaceInteraction) { + self.sendSticker = chatControllerInteraction.sendSticker + self.sendEmoji = chatControllerInteraction.sendEmoji + self.sendGif = chatControllerInteraction.sendGif + self.sendBotContextResultAsGif = chatControllerInteraction.sendBotContextResultAsGif + self.updateChoosingSticker = chatControllerInteraction.updateChoosingSticker + self.switchToTextInput = { [weak chatControllerInteraction] in + chatControllerInteraction?.updateInputMode { _ in + return .text + } + } + self.dismissTextInput = chatControllerInteraction.dismissTextInput + self.insertText = panelInteraction.insertText + self.backwardsDeleteText = panelInteraction.backwardsDeleteText + self.presentController = chatControllerInteraction.presentController + self.presentGlobalOverlayController = chatControllerInteraction.presentGlobalOverlayController + self.getNavigationController = chatControllerInteraction.navigationController + self.requestLayout = panelInteraction.requestLayout + } + } + public struct InputData: Equatable { public var emoji: EmojiPagerContentComponent? public var stickers: EmojiPagerContentComponent? @@ -111,7 +183,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { return hasPremium } - public static func inputData(context: AccountContext, interfaceInteraction: ChatPanelInterfaceInteraction, controllerInteraction: ChatControllerInteraction?, chatPeerId: PeerId?, areCustomEmojiEnabled: Bool) -> Signal { + public static func inputData(context: AccountContext, chatPeerId: PeerId?, areCustomEmojiEnabled: Bool, sendGif: ((FileMediaReference, UIView, CGRect, Bool, Bool) -> Bool)?) -> Signal { let animationCache = context.animationCache let animationRenderer = context.animationRenderer @@ -156,11 +228,10 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } let gifInputInteraction = GifPagerContentComponent.InputInteraction( - performItemAction: { [weak controllerInteraction] item, view, rect in - guard let controllerInteraction = controllerInteraction else { - return + performItemAction: { item, view, rect in + if let sendGif { + let _ = sendGif(item.file, view, rect, false, false) } - let _ = controllerInteraction.sendGif(item.file, view, rect, false, false) }, openGifContextMenu: { _, _, _, _, _ in }, @@ -287,8 +358,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } } - private let controllerInteraction: ChatControllerInteraction? - + private let interaction: ChatEntityKeyboardInputNode.Interaction? private var inputNodeInteraction: ChatMediaInputNodeInteraction? private let trendingGifsPromise = Promise(nil) @@ -303,7 +373,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { fileprivate var clipContentToTopPanel: Bool = false - var externalTopPanelContainerImpl: PagerExternalTopPanelContainer? + public var externalTopPanelContainerImpl: PagerExternalTopPanelContainer? public override var externalTopPanelContainer: UIView? { return self.externalTopPanelContainerImpl } @@ -589,14 +659,14 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { |> distinctUntilChanged } - public init(context: AccountContext, currentInputData: InputData, updatedInputData: Signal, defaultToEmojiTab: Bool, opaqueTopPanelBackground: Bool = false, controllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?, chatPeerId: PeerId?, stateContext: StateContext?) { + public init(context: AccountContext, currentInputData: InputData, updatedInputData: Signal, defaultToEmojiTab: Bool, opaqueTopPanelBackground: Bool = false, interaction: ChatEntityKeyboardInputNode.Interaction?, chatPeerId: PeerId?, stateContext: StateContext?) { self.context = context self.currentInputData = currentInputData self.defaultToEmojiTab = defaultToEmojiTab self.opaqueTopPanelBackground = opaqueTopPanelBackground self.stateContext = stateContext - self.controllerInteraction = controllerInteraction + self.interaction = interaction self.entityKeyboardView = ComponentHostView() @@ -612,13 +682,13 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { self.externalTopPanelContainerImpl = PagerExternalTopPanelContainer() var stickerPeekBehavior: EmojiContentPeekBehaviorImpl? - if let controllerInteraction = controllerInteraction { + if let interaction { let context = self.context stickerPeekBehavior = EmojiContentPeekBehaviorImpl( context: self.context, interaction: EmojiContentPeekBehaviorImpl.Interaction( - sendSticker: controllerInteraction.sendSticker, + sendSticker: interaction.sendSticker, sendEmoji: { file in var text = "." var emojiAttribute: ChatTextInputTextCustomEmojiAttribute? @@ -639,7 +709,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } if let emojiAttribute { - controllerInteraction.sendEmoji(text, emojiAttribute, true) + interaction.sendEmoji(text, emojiAttribute, true) } }, setStatus: { [weak self] file in @@ -659,7 +729,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: presentationData.strings.EmojiStatus_AppliedText, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) strongSelf.currentUndoOverlayController = controller - controllerInteraction.presentController(controller, nil) + interaction.presentController(controller, nil) }, copyEmoji: { [weak self] file in guard let strongSelf = self else { @@ -692,28 +762,28 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: presentationData.strings.EmojiPreview_CopyEmoji, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) + let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: presentationData.strings.Conversation_EmojiCopied, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) strongSelf.currentUndoOverlayController = controller - controllerInteraction.presentController(controller, nil) + interaction.presentController(controller, nil) } }, - presentController: controllerInteraction.presentController, - presentGlobalOverlayController: controllerInteraction.presentGlobalOverlayController, - navigationController: controllerInteraction.navigationController, + presentController: interaction.presentController, + presentGlobalOverlayController: interaction.presentGlobalOverlayController, + navigationController: interaction.getNavigationController, updateIsPreviewing: { [weak self] value in self?.previewingStickersPromise.set(value) } ), chatPeerId: chatPeerId, present: { c, a in - controllerInteraction.presentGlobalOverlayController(c, a) + interaction.presentGlobalOverlayController(c, a) } ) } var premiumToastCounter = 0 self.emojiInputInteraction = EmojiPagerContentComponent.InputInteraction( - performItemAction: { [weak self, weak interfaceInteraction, weak controllerInteraction] groupId, item, _, _, _, _ in + performItemAction: { [weak self, weak interaction] groupId, item, _, _, _, _ in let _ = ( combineLatest( ChatEntityKeyboardInputNode.hasPremium(context: context, chatPeerId: chatPeerId, premiumIfSavedMessages: true), @@ -721,7 +791,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { ) |> take(1) |> deliverOnMainQueue).start(next: { hasPremium, hasGlobalPremium in - guard let strongSelf = self, let controllerInteraction = controllerInteraction, let interfaceInteraction = interfaceInteraction else { + guard let strongSelf = self, let interaction else { return } @@ -732,8 +802,8 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { context.account.postbox.combinedView(keys: [viewKey]) ) |> take(1) - |> deliverOnMainQueue).start(next: { [weak interfaceInteraction, weak controllerInteraction] emojiPacksView, views in - guard let controllerInteraction = controllerInteraction else { + |> deliverOnMainQueue).start(next: { [weak interaction] emojiPacksView, views in + guard let interaction else { return } guard let view = views.views[viewKey] as? OrderedItemListView else { @@ -743,8 +813,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { return } - let _ = interfaceInteraction - let _ = controllerInteraction + let _ = interaction var installedCollectionIds = Set() for (id, _, _) in emojiPacksView.collectionInfos { @@ -831,15 +900,15 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { actionTitle = presentationData.strings.EmojiInput_PremiumEmojiToast_Action } - let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: text, undoText: actionTitle, customAction: { [weak controllerInteraction] in - guard let controllerInteraction = controllerInteraction else { + let controller = UndoOverlayController(presentationData: presentationData, content: .sticker(context: context, file: file, loop: true, title: nil, text: text, undoText: actionTitle, customAction: { [weak interaction] in + guard let interaction else { return } if suggestSavedMessages { let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) |> deliverOnMainQueue).start(next: { peer in - guard let peer = peer, let navigationController = controllerInteraction.navigationController() else { + guard let peer = peer, let navigationController = interaction.getNavigationController() else { return } @@ -865,29 +934,28 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { replaceImpl = { [weak controller] c in controller?.replace(with: c) } - controllerInteraction.navigationController()?.pushViewController(controller) + interaction.getNavigationController()?.pushViewController(controller) } }), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return false }) strongSelf.currentUndoOverlayController = controller - controllerInteraction.presentController(controller, nil) + interaction.presentController(controller, nil) return } if let emojiAttribute = emojiAttribute { AudioServicesPlaySystemSound(0x450) - interfaceInteraction.insertText(NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: emojiAttribute])) + interaction.insertText(NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: emojiAttribute])) } } else if case let .staticEmoji(staticEmoji) = item.content { AudioServicesPlaySystemSound(0x450) - interfaceInteraction.insertText(NSAttributedString(string: staticEmoji, attributes: [:])) + interaction.insertText(NSAttributedString(string: staticEmoji, attributes: [:])) } }) }, - deleteBackwards: { [weak interfaceInteraction] in - guard let interfaceInteraction = interfaceInteraction else { - return + deleteBackwards: { [weak interaction] in + if let interaction { + interaction.backwardsDeleteText() } - interfaceInteraction.backwardsDeleteText() }, openStickerSettings: { }, @@ -895,8 +963,8 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { }, openSearch: { }, - addGroupAction: { [weak self, weak controllerInteraction] groupId, isPremiumLocked, scrollToGroup in - guard let controllerInteraction = controllerInteraction, let collectionId = groupId.base as? ItemCollectionId else { + addGroupAction: { [weak self, weak interaction] groupId, isPremiumLocked, scrollToGroup in + guard let interaction, let collectionId = groupId.base as? ItemCollectionId else { return } @@ -909,7 +977,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { replaceImpl = { [weak controller] c in controller?.replace(with: c) } - controllerInteraction.navigationController()?.pushViewController(controller) + interaction.getNavigationController()?.pushViewController(controller) return } @@ -933,12 +1001,12 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } }) }, - clearGroup: { [weak controllerInteraction] groupId in - guard let controllerInteraction = controllerInteraction else { + clearGroup: { [weak interaction] groupId in + guard let interaction else { return } if groupId == AnyHashable("recent") { - controllerInteraction.dismissTextInput() + interaction.dismissTextInput() let presentationData = context.sharedContext.currentPresentationData.with { $0 } let actionSheet = ActionSheetController(theme: ActionSheetControllerTheme(presentationTheme: presentationData.theme, fontSize: presentationData.listsFontSize)) var items: [ActionSheetItem] = [] @@ -951,7 +1019,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { actionSheet?.dismissAnimated() }) ])]) - controllerInteraction.presentController(actionSheet, nil) + interaction.presentController(actionSheet, nil) } else if groupId == AnyHashable("featuredTop") { let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedEmojiPacks) let _ = (context.account.postbox.combinedView(keys: [viewKey]) @@ -968,33 +1036,33 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { }) } }, - pushController: { [weak controllerInteraction] controller in - guard let controllerInteraction = controllerInteraction else { + pushController: { [weak interaction] controller in + guard let interaction else { return } - controllerInteraction.navigationController()?.pushViewController(controller) + interaction.getNavigationController()?.pushViewController(controller) }, - presentController: { [weak controllerInteraction] controller in - guard let controllerInteraction = controllerInteraction else { + presentController: { [weak interaction] controller in + guard let interaction else { return } - controllerInteraction.presentController(controller, nil) + interaction.presentController(controller, nil) }, - presentGlobalOverlayController: { [weak controllerInteraction] controller in - guard let controllerInteraction = controllerInteraction else { + presentGlobalOverlayController: { [weak interaction] controller in + guard let interaction else { return } - controllerInteraction.presentGlobalOverlayController(controller, nil) + interaction.presentGlobalOverlayController(controller, nil) }, - navigationController: { [weak controllerInteraction] in - return controllerInteraction?.navigationController() + navigationController: { [weak interaction] in + return interaction?.getNavigationController() }, requestUpdate: { [weak self] transition in guard let strongSelf = self else { return } if !transition.animation.isImmediate { - strongSelf.interfaceInteraction?.requestLayout(transition.containedViewLayoutTransition) + strongSelf.interaction?.requestLayout(transition.containedViewLayoutTransition) } }, updateSearchQuery: { [weak self] query in @@ -1231,9 +1299,9 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { ) self.stickerInputInteraction = EmojiPagerContentComponent.InputInteraction( - performItemAction: { [weak controllerInteraction, weak interfaceInteraction] groupId, item, view, rect, layer, _ in + performItemAction: { [weak interaction] groupId, item, view, rect, layer, _ in let _ = (ChatEntityKeyboardInputNode.hasPremium(context: context, chatPeerId: chatPeerId, premiumIfSavedMessages: false) |> take(1) |> deliverOnMainQueue).start(next: { hasPremium in - guard let controllerInteraction = controllerInteraction, let interfaceInteraction = interfaceInteraction else { + guard let interaction else { return } guard let file = item.itemFile else { @@ -1244,8 +1312,8 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedStickerPacks) let _ = (context.account.postbox.combinedView(keys: [viewKey]) |> take(1) - |> deliverOnMainQueue).start(next: { [weak controllerInteraction] views in - guard let controllerInteraction = controllerInteraction else { + |> deliverOnMainQueue).start(next: { [weak interaction] views in + guard let interaction else { return } guard let view = views.views[viewKey] as? OrderedItemListView else { @@ -1253,14 +1321,14 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } for featuredStickerPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { if featuredStickerPack.topItems.contains(where: { $0.file.fileId == file.fileId }) { - controllerInteraction.navigationController()?.pushViewController(FeaturedStickersScreen( + interaction.getNavigationController()?.pushViewController(FeaturedStickersScreen( context: context, highlightedPackId: featuredStickerPack.info.id, - sendSticker: { [weak controllerInteraction] fileReference, sourceNode, sourceRect in - guard let controllerInteraction = controllerInteraction else { + sendSticker: { [weak interaction] fileReference, sourceNode, sourceRect in + guard let interaction else { return false } - return controllerInteraction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, []) + return interaction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, []) } )) @@ -1271,7 +1339,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } else { if file.isPremiumSticker && !hasPremium { let controller = PremiumIntroScreen(context: context, source: .stickers) - controllerInteraction.navigationController()?.pushViewController(controller) + interaction.getNavigationController()?.pushViewController(controller) return } @@ -1279,37 +1347,36 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { if let id = groupId.base as? ItemCollectionId, context.sharedContext.currentStickerSettings.with({ $0 }).dynamicPackOrder { bubbleUpEmojiOrStickersets.append(id) } - let _ = interfaceInteraction.sendSticker(.standalone(media: file), false, view, rect, layer, bubbleUpEmojiOrStickersets) + let _ = interaction.sendSticker(.standalone(media: file), false, false, nil, false, view, rect, layer, bubbleUpEmojiOrStickersets) } }) }, - deleteBackwards: { [weak interfaceInteraction] in - guard let interfaceInteraction = interfaceInteraction else { - return + deleteBackwards: { [weak interaction] in + if let interaction { + interaction.backwardsDeleteText() } - interfaceInteraction.backwardsDeleteText() }, - openStickerSettings: { [weak controllerInteraction] in - guard let controllerInteraction = controllerInteraction else { + openStickerSettings: { [weak interaction] in + guard let interaction else { return } let controller = context.sharedContext.makeInstalledStickerPacksController(context: context, mode: .modal) controller.navigationPresentation = .modal - controllerInteraction.navigationController()?.pushViewController(controller) + interaction.getNavigationController()?.pushViewController(controller) }, - openFeatured: { [weak controllerInteraction] in - guard let controllerInteraction = controllerInteraction else { + openFeatured: { [weak interaction] in + guard let interaction else { return } - controllerInteraction.navigationController()?.pushViewController(FeaturedStickersScreen( + interaction.getNavigationController()?.pushViewController(FeaturedStickersScreen( context: context, highlightedPackId: nil, - sendSticker: { [weak controllerInteraction] fileReference, sourceNode, sourceRect in - guard let controllerInteraction = controllerInteraction else { + sendSticker: { [weak interaction] fileReference, sourceNode, sourceRect in + guard let interaction else { return false } - return controllerInteraction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, []) + return interaction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, []) } )) }, @@ -1318,14 +1385,14 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { pagerView.openSearch() } }, - addGroupAction: { groupId, isPremiumLocked, _ in - guard let controllerInteraction = controllerInteraction, let collectionId = groupId.base as? ItemCollectionId else { + addGroupAction: { [weak interaction] groupId, isPremiumLocked, _ in + guard let interaction, let collectionId = groupId.base as? ItemCollectionId else { return } if isPremiumLocked { let controller = PremiumIntroScreen(context: context, source: .stickers) - controllerInteraction.navigationController()?.pushViewController(controller) + interaction.getNavigationController()?.pushViewController(controller) return } @@ -1363,12 +1430,12 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } }) }, - clearGroup: { [weak controllerInteraction] groupId in - guard let controllerInteraction = controllerInteraction else { + clearGroup: { [weak interaction] groupId in + guard let interaction else { return } if groupId == AnyHashable("recent") { - controllerInteraction.dismissTextInput() + interaction.dismissTextInput() let presentationData = context.sharedContext.currentPresentationData.with { $0 } let actionSheet = ActionSheetController(theme: ActionSheetControllerTheme(presentationTheme: presentationData.theme, fontSize: presentationData.listsFontSize)) var items: [ActionSheetItem] = [] @@ -1381,7 +1448,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { actionSheet?.dismissAnimated() }) ])]) - controllerInteraction.presentController(actionSheet, nil) + interaction.presentController(actionSheet, nil) } else if groupId == AnyHashable("featuredTop") { let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedStickerPacks) let _ = (context.account.postbox.combinedView(keys: [viewKey]) @@ -1399,26 +1466,26 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } else if groupId == AnyHashable("peerSpecific") { } }, - pushController: { [weak controllerInteraction] controller in - guard let controllerInteraction = controllerInteraction else { + pushController: { [weak interaction] controller in + guard let interaction else { return } - controllerInteraction.navigationController()?.pushViewController(controller) + interaction.getNavigationController()?.pushViewController(controller) }, - presentController: { [weak controllerInteraction] controller in - guard let controllerInteraction = controllerInteraction else { + presentController: { [weak interaction] controller in + guard let interaction else { return } - controllerInteraction.presentController(controller, nil) + interaction.presentController(controller, nil) }, - presentGlobalOverlayController: { [weak controllerInteraction] controller in - guard let controllerInteraction = controllerInteraction else { + presentGlobalOverlayController: { [weak interaction] controller in + guard let interaction else { return } - controllerInteraction.presentGlobalOverlayController(controller, nil) + interaction.presentGlobalOverlayController(controller, nil) }, - navigationController: { [weak controllerInteraction] in - return controllerInteraction?.navigationController() + navigationController: { [weak interaction] in + return interaction?.getNavigationController() }, requestUpdate: { _ in }, @@ -1634,15 +1701,15 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { }) self.gifInputInteraction = GifPagerContentComponent.InputInteraction( - performItemAction: { [weak controllerInteraction] item, view, rect in - guard let controllerInteraction = controllerInteraction else { + performItemAction: { [weak interaction] item, view, rect in + guard let interaction else { return } if let (collection, result) = item.contextResult { - let _ = controllerInteraction.sendBotContextResultAsGif(collection, result, view, rect, false, false) + let _ = interaction.sendBotContextResultAsGif(collection, result, view, rect, false, false) } else { - let _ = controllerInteraction.sendGif(item.file, view, rect, false, false) + let _ = interaction.sendGif(item.file, view, rect, false, false) } }, openGifContextMenu: { [weak self] item, sourceView, sourceRect, gesture, isSaved in @@ -1675,11 +1742,8 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { ) self.switchToTextInput = { [weak self] in - guard let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction else { - return - } - controllerInteraction.updateInputMode { _ in - return .text + if let self { + self.interaction?.switchToTextInput() } } @@ -1707,8 +1771,8 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { self.choosingStickerDisposable = (self.choosingSticker |> deliverOnMainQueue).start(next: { [weak self] value in - if let strongSelf = self { - strongSelf.controllerInteraction?.updateChoosingSticker(value) + if let self { + self.interaction?.updateChoosingSticker(value) } }) } @@ -1771,7 +1835,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } let context = self.context - let controllerInteraction = self.controllerInteraction + let interaction = self.interaction let inputNodeInteraction = self.inputNodeInteraction! let trendingGifsPromise = self.trendingGifsPromise @@ -1883,8 +1947,8 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } strongSelf.reorderItems(category: category, items: items) }, - makeSearchContainerNode: { [weak self, weak controllerInteraction] content in - guard let self, let controllerInteraction = controllerInteraction else { + makeSearchContainerNode: { [weak self, weak interaction] content in + guard let self, let interaction else { return nil } @@ -1901,7 +1965,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { context: context, theme: presentationData.theme, strings: presentationData.strings, - controllerInteraction: controllerInteraction, + interaction: interaction, inputNodeInteraction: inputNodeInteraction, mode: mappedMode, trendingGifsPromise: trendingGifsPromise, @@ -2072,7 +2136,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { if self.context.sharedContext.currentStickerSettings.with({ $0 }).dynamicPackOrder { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - self.controllerInteraction?.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_reorder", scale: 0.05, colors: [:], title: presentationData.strings.StickerPacksSettings_DynamicOrderOff, text: presentationData.strings.StickerPacksSettings_DynamicOrderOffInfo, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { action in + self.interaction?.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_reorder", scale: 0.05, colors: [:], title: presentationData.strings.StickerPacksSettings_DynamicOrderOff, text: presentationData.strings.StickerPacksSettings_DynamicOrderOffInfo, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { action in return false }), nil) @@ -2110,12 +2174,14 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { var items: [ContextMenuItem] = [] items.append(.action(ContextMenuActionItem(text: presentationData.strings.MediaPicker_Send, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Resend"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in + }, action: { [weak self] _, f in f(.default) - if isSaved { - let _ = self?.controllerInteraction?.sendGif(file, sourceView, sourceRect, false, false) - } else if let (collection, result) = contextResult { - let _ = self?.controllerInteraction?.sendBotContextResultAsGif(collection, result, sourceView, sourceRect, false, false) + if let self { + if isSaved { + let _ = self.interaction?.sendGif(file, sourceView, sourceRect, false, false) + } else if let (collection, result) = contextResult { + let _ = self.interaction?.sendBotContextResultAsGif(collection, result, sourceView, sourceRect, false, false) + } } }))) @@ -2131,12 +2197,14 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { if peerId != self?.context.account.peerId && peerId.namespace != Namespaces.Peer.SecretChat { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_SendMessage_SendSilently, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Menu/SilentIcon"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in + }, action: { [weak self] _, f in f(.default) - if isSaved { - let _ = self?.controllerInteraction?.sendGif(file, sourceView, sourceRect, true, false) - } else if let (collection, result) = contextResult { - let _ = self?.controllerInteraction?.sendBotContextResultAsGif(collection, result, sourceView, sourceRect, true, false) + if let self { + if isSaved { + let _ = self.interaction?.sendGif(file, sourceView, sourceRect, true, false) + } else if let (collection, result) = contextResult { + let _ = self.interaction?.sendBotContextResultAsGif(collection, result, sourceView, sourceRect, true, false) + } } }))) } @@ -2144,10 +2212,11 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { if isSaved { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_SendMessage_ScheduleMessage, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Menu/ScheduleIcon"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in + }, action: { [weak self] _, f in f(.default) - - let _ = self?.controllerInteraction?.sendGif(file, sourceView, sourceRect, false, true) + if let self { + let _ = self.interaction?.sendGif(file, sourceView, sourceRect, false, true) + } }))) } } @@ -2157,18 +2226,17 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { if isSaved || isGifSaved { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_ContextMenuDelete, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.actionSheet.destructiveActionTextColor) - }, action: { _, f in + }, action: { [weak self] _, f in f(.dismissWithoutContent) - guard let strongSelf = self else { - return + if let self { + let _ = removeSavedGif(postbox: self.context.account.postbox, mediaId: file.media.fileId).start() } - let _ = removeSavedGif(postbox: strongSelf.context.account.postbox, mediaId: file.media.fileId).start() }))) } else if canSaveGif && !isGifSaved { items.append(.action(ContextMenuActionItem(text: presentationData.strings.Preview_SaveGif, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in + }, action: { [weak self] _, f in f(.dismissWithoutContent) guard let strongSelf = self else { @@ -2178,13 +2246,13 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { let context = strongSelf.context let presentationData = context.sharedContext.currentPresentationData.with { $0 } let _ = (toggleGifSaved(account: context.account, fileReference: file, saved: true) - |> deliverOnMainQueue).start(next: { result in + |> deliverOnMainQueue).start(next: { [weak self] result in guard let strongSelf = self else { return } switch result { case .generic: - strongSelf.controllerInteraction?.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: nil, text: presentationData.strings.Gallery_GifSaved, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), nil) + strongSelf.interaction?.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: nil, text: presentationData.strings.Gallery_GifSaved, customUndoText: nil, timeout: nil), 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 @@ -2193,14 +2261,14 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } else { text = presentationData.strings.Premium_MaxSavedGifsText("\(premiumLimit)").string } - strongSelf.controllerInteraction?.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: presentationData.strings.Premium_MaxSavedGifsTitle("\(limit)").string, text: text, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { action in + strongSelf.interaction?.presentController(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_gif", scale: 0.075, colors: [:], title: presentationData.strings.Premium_MaxSavedGifsTitle("\(limit)").string, text: text, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: false, action: { action in guard let strongSelf = self else { return false } if case .info = action { let controller = PremiumIntroScreen(context: context, source: .savedGifs) - strongSelf.controllerInteraction?.navigationController()?.pushViewController(controller) + strongSelf.interaction?.getNavigationController()?.pushViewController(controller) return true } return false @@ -2211,7 +2279,7 @@ public 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.interaction?.presentGlobalOverlayController(contextController, nil) }) } } @@ -2281,7 +2349,6 @@ public final class EntityInputView: UIInputView, AttachmentTextInputPanelInputVi } 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 @@ -2428,7 +2495,7 @@ public final class EntityInputView: UIInputView, AttachmentTextInputPanelInputVi gifs: nil, availableGifSearchEmojies: [] ), - updatedInputData: EmojiPagerContentComponent.emojiInputData(context: context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, isStandalone: true, isStatusSelection: false, isReactionSelection: false, isEmojiSelection: false, hasTrending: false, topReactionItems: [], areUnicodeEmojiEnabled: true, areCustomEmojiEnabled: areCustomEmojiEnabled, chatPeerId: nil, forceHasPremium: forceHasPremium) |> map { emojiComponent -> ChatEntityKeyboardInputNode.InputData in + updatedInputData: EmojiPagerContentComponent.emojiInputData(context: context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, isStandalone: true, isStatusSelection: false, isReactionSelection: false, isEmojiSelection: false, hasTrending: false, topReactionItems: [], areUnicodeEmojiEnabled: true, areCustomEmojiEnabled: areCustomEmojiEnabled, chatPeerId: nil, forceHasPremium: forceHasPremium, hideBackground: hideBackground) |> map { emojiComponent -> ChatEntityKeyboardInputNode.InputData in return ChatEntityKeyboardInputNode.InputData( emoji: emojiComponent, stickers: nil, @@ -2437,9 +2504,8 @@ public final class EntityInputView: UIInputView, AttachmentTextInputPanelInputVi ) }, defaultToEmojiTab: true, - opaqueTopPanelBackground: true, - controllerInteraction: nil, - interfaceInteraction: nil, + opaqueTopPanelBackground: !hideBackground, + interaction: nil, chatPeerId: nil, stateContext: nil ) @@ -2450,7 +2516,9 @@ public final class EntityInputView: UIInputView, AttachmentTextInputPanelInputVi inputNode.switchToTextInput = { [weak self] in self?.switchToKeyboard?() } - inputNode.backgroundColor = self.presentationData.theme.chat.inputMediaPanel.backgroundColor + if !hideBackground { + inputNode.backgroundColor = self.presentationData.theme.chat.inputMediaPanel.backgroundColor + } self.addSubnode(inputNode) } } diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/GifPaneSearchContentNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/GifPaneSearchContentNode.swift index f6211b993c..f509da0549 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/GifPaneSearchContentNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/GifPaneSearchContentNode.swift @@ -14,7 +14,7 @@ import ChatPresentationInterfaceState final class GifPaneSearchContentNode: ASDisplayNode & PaneSearchContentNode { private let context: AccountContext - private let controllerInteraction: ChatControllerInteraction + private let interaction: ChatEntityKeyboardInputNode.Interaction private let inputNodeInteraction: ChatMediaInputNodeInteraction private var theme: PresentationTheme @@ -44,9 +44,9 @@ final class GifPaneSearchContentNode: ASDisplayNode & PaneSearchContentNode { private var hasInitialText = false - init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, controllerInteraction: ChatControllerInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction, trendingPromise: Promise) { + init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, interaction: ChatEntityKeyboardInputNode.Interaction, inputNodeInteraction: ChatMediaInputNodeInteraction, trendingPromise: Promise) { self.context = context - self.controllerInteraction = controllerInteraction + self.interaction = interaction self.inputNodeInteraction = inputNodeInteraction self.trendingPromise = trendingPromise @@ -226,9 +226,9 @@ final class GifPaneSearchContentNode: ASDisplayNode & PaneSearchContentNode { multiplexedNode.fileSelected = { [weak self] file, sourceNode, sourceRect in if let (collection, result) = file.contextResult { - let _ = self?.controllerInteraction.sendBotContextResultAsGif(collection, result, sourceNode.view, sourceRect, false, false) + let _ = self?.interaction.sendBotContextResultAsGif(collection, result, sourceNode.view, sourceRect, false, false) } else { - let _ = self?.controllerInteraction.sendGif(file.file, sourceNode.view, sourceRect, false, false) + let _ = self?.interaction.sendGif(file.file, sourceNode.view, sourceRect, false, false) } } diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift index 1772b436da..d55f665f4f 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift @@ -36,7 +36,7 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer private let context: AccountContext private let mode: ChatMediaInputSearchMode public private(set) var contentNode: PaneSearchContentNode & ASDisplayNode - private let controllerInteraction: ChatControllerInteraction + private let interaction: ChatEntityKeyboardInputNode.Interaction private let inputNodeInteraction: ChatMediaInputNodeInteraction private let peekBehavior: EmojiContentPeekBehavior? @@ -53,17 +53,17 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer return self.contentNode.ready } - public init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, controllerInteraction: ChatControllerInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction, mode: ChatMediaInputSearchMode, trendingGifsPromise: Promise, cancel: @escaping () -> Void, peekBehavior: EmojiContentPeekBehavior?) { + public init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, interaction: ChatEntityKeyboardInputNode.Interaction, inputNodeInteraction: ChatMediaInputNodeInteraction, mode: ChatMediaInputSearchMode, trendingGifsPromise: Promise, cancel: @escaping () -> Void, peekBehavior: EmojiContentPeekBehavior?) { self.context = context self.mode = mode - self.controllerInteraction = controllerInteraction + self.interaction = interaction self.inputNodeInteraction = inputNodeInteraction self.peekBehavior = peekBehavior switch mode { case .gif: - self.contentNode = GifPaneSearchContentNode(context: context, theme: theme, strings: strings, controllerInteraction: controllerInteraction, inputNodeInteraction: inputNodeInteraction, trendingPromise: trendingGifsPromise) + self.contentNode = GifPaneSearchContentNode(context: context, theme: theme, strings: strings, interaction: interaction, inputNodeInteraction: inputNodeInteraction, trendingPromise: trendingGifsPromise) case .sticker, .trending: - self.contentNode = StickerPaneSearchContentNode(context: context, theme: theme, strings: strings, controllerInteraction: controllerInteraction, inputNodeInteraction: inputNodeInteraction) + self.contentNode = StickerPaneSearchContentNode(context: context, theme: theme, strings: strings, interaction: interaction, inputNodeInteraction: inputNodeInteraction) } self.backgroundNode = ASDisplayNode() diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift index e19958b27a..17baaf0fc4 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift @@ -19,7 +19,6 @@ import UndoUI import ChatControllerInteraction import FeaturedStickersScreen import ChatPresentationInterfaceState -import FeaturedStickersScreen private enum StickerSearchEntryId: Equatable, Hashable { case sticker(String?, Int64) @@ -136,9 +135,9 @@ private func preparedChatMediaInputGridEntryTransition(context: AccountContext, final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { private let context: AccountContext - private let controllerInteraction: ChatControllerInteraction + private let interaction: ChatEntityKeyboardInputNode.Interaction private let inputNodeInteraction: ChatMediaInputNodeInteraction - private var interaction: StickerPaneSearchInteraction? + private var searchInteraction: StickerPaneSearchInteraction? private var theme: PresentationTheme private var strings: PresentationStrings @@ -168,15 +167,21 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { private let installDisposable = MetaDisposable() - init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, controllerInteraction: ChatControllerInteraction, inputNodeInteraction: ChatMediaInputNodeInteraction) { + init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, interaction: ChatEntityKeyboardInputNode.Interaction, inputNodeInteraction: ChatMediaInputNodeInteraction) { self.context = context - self.controllerInteraction = controllerInteraction + self.interaction = interaction self.inputNodeInteraction = inputNodeInteraction self.theme = theme self.strings = strings - self.trendingPane = ChatMediaInputTrendingPane(context: context, controllerInteraction: controllerInteraction, getItemIsPreviewed: { [weak inputNodeInteraction] item in + let trendingPaneInteraction = ChatMediaInputTrendingPane.Interaction( + sendSticker: interaction.sendSticker, + presentController: interaction.presentController, + getNavigationController: interaction.getNavigationController + ) + + self.trendingPane = ChatMediaInputTrendingPane(context: context, interaction: trendingPaneInteraction, getItemIsPreviewed: { [weak inputNodeInteraction] item in return inputNodeInteraction?.previewedStickerPackItemFile?.id == item.file.id }, isPane: false) @@ -211,18 +216,18 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { self?.deactivateSearchBar?() } - self.interaction = StickerPaneSearchInteraction(open: { [weak self] info in + self.searchInteraction = StickerPaneSearchInteraction(open: { [weak self] info in if let strongSelf = self { strongSelf.view.window?.endEditing(true) let packReference: StickerPackReference = .id(id: info.id.id, accessHash: info.accessHash) - let controller = StickerPackScreen(context: strongSelf.context, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: strongSelf.controllerInteraction.navigationController(), sendSticker: { [weak self] fileReference, sourceNode, sourceRect in + let controller = StickerPackScreen(context: strongSelf.context, mainStickerPack: packReference, stickerPacks: [packReference], parentNavigationController: strongSelf.interaction.getNavigationController(), sendSticker: { [weak self] fileReference, sourceNode, sourceRect in if let strongSelf = self { - return strongSelf.controllerInteraction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, []) + return strongSelf.interaction.sendSticker(fileReference, false, false, nil, false, sourceNode, sourceRect, nil, []) } else { return false } }) - strongSelf.controllerInteraction.presentController(controller, nil) + strongSelf.interaction.presentController(controller, nil) } }, install: { [weak self] info, items, install in guard let strongSelf = self else { @@ -264,7 +269,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { cancelImpl?() })) - self?.controllerInteraction.presentController(controller, nil) + self?.interaction.presentController(controller, nil) return ActionDisposable { [weak controller] in Queue.mainQueue().async() { controller?.dismiss() @@ -291,7 +296,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { } var animateInAsReplacement = false - if let navigationController = strongSelf.controllerInteraction.navigationController() { + if let navigationController = strongSelf.interaction.getNavigationController() { for controller in navigationController.overlayControllers { if let controller = controller as? UndoOverlayController { controller.dismissWithCommitActionAndReplacementAnimation() @@ -301,7 +306,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { } let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - strongSelf.controllerInteraction.navigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: strongSelf.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in + strongSelf.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: strongSelf.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in return true })) })) @@ -312,7 +317,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { } }, sendSticker: { [weak self] file, sourceView, sourceRect in if let strongSelf = self { - let _ = strongSelf.controllerInteraction.sendSticker(file, false, false, nil, false, sourceView, sourceRect, nil, []) + let _ = strongSelf.interaction.sendSticker(file, false, false, nil, false, sourceView, sourceRect, nil, []) } }, getItemIsPreviewed: { item in return inputNodeInteraction.previewedStickerPackItemFile?.id == item.file.id @@ -451,7 +456,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { self.searchDisposable.set((signal |> deliverOn(self.queue)).start(next: { [weak self] result in Queue.mainQueue().async { - guard let strongSelf = self, let interaction = strongSelf.interaction else { + guard let strongSelf = self, let interaction = strongSelf.searchInteraction else { return } diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift index 10c5f1abd9..047dc22cbb 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift @@ -2633,6 +2633,7 @@ public final class EmojiPagerContentComponent: Component { public let itemContentUniqueId: ContentId? public let searchState: SearchState public let warpContentsOnEdges: Bool + public let hideBackground: Bool public let displaySearchWithPlaceholder: String? public let searchCategories: EmojiSearchCategories? public let searchInitiallyHidden: Bool @@ -2655,6 +2656,7 @@ public final class EmojiPagerContentComponent: Component { itemContentUniqueId: ContentId?, searchState: SearchState, warpContentsOnEdges: Bool, + hideBackground: Bool, displaySearchWithPlaceholder: String?, searchCategories: EmojiSearchCategories?, searchInitiallyHidden: Bool, @@ -2676,6 +2678,7 @@ public final class EmojiPagerContentComponent: Component { self.itemContentUniqueId = itemContentUniqueId self.searchState = searchState self.warpContentsOnEdges = warpContentsOnEdges + self.hideBackground = hideBackground self.displaySearchWithPlaceholder = displaySearchWithPlaceholder self.searchCategories = searchCategories self.searchInitiallyHidden = searchInitiallyHidden @@ -2700,6 +2703,7 @@ public final class EmojiPagerContentComponent: Component { itemContentUniqueId: itemContentUniqueId, searchState: searchState, warpContentsOnEdges: self.warpContentsOnEdges, + hideBackground: self.hideBackground, displaySearchWithPlaceholder: self.displaySearchWithPlaceholder, searchCategories: self.searchCategories, searchInitiallyHidden: self.searchInitiallyHidden, @@ -2751,6 +2755,9 @@ public final class EmojiPagerContentComponent: Component { if lhs.warpContentsOnEdges != rhs.warpContentsOnEdges { return false } + if lhs.hideBackground != rhs.hideBackground { + return false + } if lhs.displaySearchWithPlaceholder != rhs.displaySearchWithPlaceholder { return false } @@ -3619,6 +3626,7 @@ public final class EmojiPagerContentComponent: Component { private var isSearchActivated: Bool = false private let backgroundView: BlurredBackgroundView + private var fadingMaskLayer: FadingMaskLayer? private var vibrancyClippingView: UIView private var vibrancyEffectView: UIVisualEffectView? public private(set) var mirrorContentClippingView: UIView? @@ -6185,7 +6193,7 @@ public final class EmojiPagerContentComponent: Component { self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(ContentAnimation(type: .groupExpanded(id: groupId)))) } - public func pagerUpdateBackground(backgroundFrame: CGRect, transition: Transition) { + public func pagerUpdateBackground(backgroundFrame: CGRect, topPanelHeight: CGFloat, transition: Transition) { guard let component = self.component, let keyboardChildEnvironment = self.keyboardChildEnvironment, let pagerEnvironment = self.pagerEnvironment else { return } @@ -6232,7 +6240,21 @@ public final class EmojiPagerContentComponent: Component { } } - if component.warpContentsOnEdges { + if component.hideBackground { + self.backgroundView.isHidden = true + + let maskLayer: FadingMaskLayer + if let current = self.fadingMaskLayer { + maskLayer = current + } else { + maskLayer = FadingMaskLayer() + self.fadingMaskLayer = maskLayer + } + if self.layer.mask == nil { + self.layer.mask = maskLayer + } + maskLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: (topPanelHeight - 34.0) * 0.75), size: backgroundFrame.size) + } else if component.warpContentsOnEdges { self.backgroundView.isHidden = true } else { self.backgroundView.isHidden = false @@ -7005,7 +7027,8 @@ public final class EmojiPagerContentComponent: Component { topicColor: Int32? = nil, hasSearch: Bool = true, forceHasPremium: Bool = false, - premiumIfSavedMessages: Bool = true + premiumIfSavedMessages: Bool = true, + hideBackground: Bool = false ) -> Signal { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let isPremiumDisabled = premiumConfiguration.isPremiumDisabled @@ -7990,6 +8013,7 @@ public final class EmojiPagerContentComponent: Component { itemContentUniqueId: nil, searchState: .empty(hasResults: false), warpContentsOnEdges: isReactionSelection || isStatusSelection || isProfilePhotoEmojiSelection || isGroupPhotoEmojiSelection, + hideBackground: hideBackground, displaySearchWithPlaceholder: displaySearchWithPlaceholder, searchCategories: searchCategories, searchInitiallyHidden: searchInitiallyHidden, @@ -8015,7 +8039,8 @@ public final class EmojiPagerContentComponent: Component { forceHasPremium: Bool, searchIsPlaceholderOnly: Bool = true, isProfilePhotoEmojiSelection: Bool = false, - isGroupPhotoEmojiSelection: Bool = false + isGroupPhotoEmojiSelection: Bool = false, + hideBackground: Bool = false ) -> Signal { let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) let isPremiumDisabled = premiumConfiguration.isPremiumDisabled @@ -8456,6 +8481,7 @@ public final class EmojiPagerContentComponent: Component { itemContentUniqueId: nil, searchState: .empty(hasResults: false), warpContentsOnEdges: isProfilePhotoEmojiSelection || isGroupPhotoEmojiSelection, + hideBackground: hideBackground, displaySearchWithPlaceholder: hasSearch ? strings.StickersSearch_SearchStickersPlaceholder : nil, searchCategories: searchCategories, searchInitiallyHidden: true, @@ -8522,3 +8548,24 @@ func generateTopicIcon(backgroundColors: [UIColor], strokeColors: [UIColor], tit context.translateBy(x: -lineOrigin.x, y: -lineOrigin.y) }) } + +private final class FadingMaskLayer: SimpleLayer { + let gradientLayer = SimpleLayer() + let fillLayer = SimpleLayer() + + override func layoutSublayers() { + let gradientHeight: CGFloat = 66.0 + if self.gradientLayer.contents == nil { + self.addSublayer(self.gradientLayer) + self.addSublayer(self.fillLayer) + + let gradientImage = generateGradientImage(size: CGSize(width: 1.0, height: gradientHeight), colors: [UIColor.white.withAlphaComponent(0.0), UIColor.white.withAlphaComponent(0.0), UIColor.white, UIColor.white], locations: [0.0, 0.4, 0.9, 1.0], direction: .vertical) + self.gradientLayer.contents = gradientImage?.cgImage + self.gradientLayer.contentsGravity = .resize + self.fillLayer.backgroundColor = UIColor.white.cgColor + } + + self.gradientLayer.frame = CGRect(origin: .zero, size: CGSize(width: self.bounds.width, height: gradientHeight)) + self.fillLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: gradientHeight), size: CGSize(width: self.bounds.width, height: self.bounds.height - gradientHeight)) + } +} diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift index bd542f9918..8dc367bd79 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift @@ -451,6 +451,7 @@ public final class EmojiSearchContent: ASDisplayNode, EntitySearchContainerNode itemContentUniqueId: EmojiPagerContentComponent.ContentId(id: "main", version: 0), searchState: .empty(hasResults: false), warpContentsOnEdges: false, + hideBackground: false, displaySearchWithPlaceholder: self.presentationData.strings.EmojiSearch_SearchEmojiPlaceholder, searchCategories: nil, searchInitiallyHidden: false, diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift index d2ef6d3095..aee3b16aee 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift @@ -960,7 +960,7 @@ public final class GifPagerContentComponent: Component { } } - public func pagerUpdateBackground(backgroundFrame: CGRect, transition: Transition) { + public func pagerUpdateBackground(backgroundFrame: CGRect, topPanelHeight: CGFloat, transition: Transition) { guard let theme = self.theme else { return } diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorVideoExport.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorVideoExport.swift index ff430baf65..f51277bd76 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorVideoExport.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorVideoExport.swift @@ -282,6 +282,19 @@ public final class MediaEditorVideoExport { self.outputPath = outputPath self.setup() + + let _ = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil, using: { [weak self] _ in + guard let self else { + return + } + self.resume() + }) + let _ = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil, using: { [weak self] _ in + guard let self else { + return + } + self.pause() + }) } private func setup() { diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/BUILD b/submodules/TelegramUI/Components/MediaEditorScreen/BUILD index 2c3f064635..6c8dc47e21 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/BUILD +++ b/submodules/TelegramUI/Components/MediaEditorScreen/BUILD @@ -32,12 +32,14 @@ swift_library( "//submodules/Components/LottieAnimationComponent:LottieAnimationComponent", "//submodules/Components/BundleIconComponent:BundleIconComponent", "//submodules/TelegramUI/Components/MessageInputPanelComponent", + "//submodules/TelegramUI/Components/ChatInputNode", "//submodules/TelegramUI/Components/ChatEntityKeyboardInputNode", "//submodules/TooltipUI", "//submodules/Components/BlurredBackgroundComponent", "//submodules/AvatarNode", "//submodules/TelegramUI/Components/ShareWithPeersScreen", "//submodules/TelegramUI/Components/CameraButtonComponent", + "//submodules/ChatPresentationInterfaceState", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index 4a91bc5a8a..1d15e5eca3 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -25,6 +25,9 @@ import PresentationDataUtils import ContextUI import BundleIconComponent import CameraButtonComponent +import UndoUI +import ChatEntityKeyboardInputNode +import ChatPresentationInterfaceState enum DrawingScreenType { case drawing @@ -39,12 +42,21 @@ private let saveButtonTag = GenericComponentViewTag() final class MediaEditorScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment + public final class ExternalState { + public fileprivate(set) var derivedInputHeight: CGFloat = 0.0 + + public init() { + } + } + let context: AccountContext + let externalState: ExternalState let isDisplayingTool: Bool let isInteractingWithEntities: Bool let isSavingAvailable: Bool let hasAppeared: Bool let isDismissing: Bool + let bottomSafeInset: CGFloat let mediaEditor: MediaEditor? let privacy: MediaEditorResultPrivacy let selectedEntity: DrawingEntity? @@ -54,11 +66,13 @@ final class MediaEditorScreenComponent: Component { init( context: AccountContext, + externalState: ExternalState, isDisplayingTool: Bool, isInteractingWithEntities: Bool, isSavingAvailable: Bool, hasAppeared: Bool, isDismissing: Bool, + bottomSafeInset: CGFloat, mediaEditor: MediaEditor?, privacy: MediaEditorResultPrivacy, selectedEntity: DrawingEntity?, @@ -67,11 +81,13 @@ final class MediaEditorScreenComponent: Component { openTools: @escaping () -> Void ) { self.context = context + self.externalState = externalState self.isDisplayingTool = isDisplayingTool self.isInteractingWithEntities = isInteractingWithEntities self.isSavingAvailable = isSavingAvailable self.hasAppeared = hasAppeared self.isDismissing = isDismissing + self.bottomSafeInset = bottomSafeInset self.mediaEditor = mediaEditor self.privacy = privacy self.selectedEntity = selectedEntity @@ -99,6 +115,9 @@ final class MediaEditorScreenComponent: Component { if lhs.isDismissing != rhs.isDismissing { return false } + if lhs.bottomSafeInset != rhs.bottomSafeInset { + return false + } if lhs.privacy != rhs.privacy { return false } @@ -206,6 +225,7 @@ final class MediaEditorScreenComponent: Component { private let inputPanel = ComponentView() private let inputPanelExternalState = MessageInputPanelComponent.ExternalState() + private let inputPanelBackground = ComponentView() private let scrubber = ComponentView() @@ -221,6 +241,17 @@ final class MediaEditorScreenComponent: Component { private var isDismissed = false + private var isEditingCaption = false + private var currentInputMode: MessageInputPanelComponent.InputMode = .keyboard + + private var didInitializeInputMediaNodeDataPromise = false + private var inputMediaNodeData: ChatEntityKeyboardInputNode.InputData? + private var inputMediaNodeDataPromise = Promise() + private var inputMediaNodeDataDisposable: Disposable? + private var inputMediaNodeStateContext = ChatEntityKeyboardInputNode.StateContext() + private var inputMediaInteraction: ChatEntityKeyboardInputNode.Interaction? + private var inputMediaNode: ChatEntityKeyboardInputNode? + private var component: MediaEditorScreenComponent? private weak var state: State? private var environment: ViewControllerComponentContainer.Environment? @@ -235,13 +266,114 @@ final class MediaEditorScreenComponent: Component { self.fadeView.alpha = 0.0 self.addSubview(self.fadeView) + + self.inputMediaNodeDataDisposable = (self.inputMediaNodeDataPromise.get() + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let self else { + return + } + self.inputMediaNodeData = value + }) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + deinit { + self.inputMediaNodeDataDisposable?.dispose() + } + + private func setupIfNeeded() { + guard let component = self.component else { + return + } + + if !self.didInitializeInputMediaNodeDataPromise { + self.didInitializeInputMediaNodeDataPromise = true + + let context = component.context + self.inputMediaNodeDataPromise.set( + EmojiPagerContentComponent.emojiInputData( + context: context, + animationCache: context.animationCache, + animationRenderer: context.animationRenderer, + isStandalone: true, + isStatusSelection: false, + isReactionSelection: false, + isEmojiSelection: false, + hasTrending: false, + topReactionItems: [], + areUnicodeEmojiEnabled: true, + areCustomEmojiEnabled: true, + chatPeerId: nil, + forceHasPremium: false, + hideBackground: true + ) |> map { emoji -> ChatEntityKeyboardInputNode.InputData in + return ChatEntityKeyboardInputNode.InputData( + emoji: emoji, + stickers: nil, + gifs: nil, + availableGifSearchEmojies: [] + ) + } + ) + + self.inputMediaInteraction = ChatEntityKeyboardInputNode.Interaction( + sendSticker: { _, _, _, _, _, _, _, _, _ in + return false + }, + sendEmoji: { [weak self] text, attribute, bool1 in + if let self { + let _ = self + } + }, + sendGif: { _, _, _, _, _ in + return false + }, + sendBotContextResultAsGif: { _, _, _, _, _, _ in + return false + }, + updateChoosingSticker: { _ in }, + switchToTextInput: { [weak self] in + if let self { + self.currentInputMode = .keyboard + self.state?.updated(transition: .immediate) + } + }, + dismissTextInput: { + + }, + insertText: { [weak self] text in + if let self { + self.inputPanelExternalState.insertText(text) + } + }, + backwardsDeleteText: { [weak self] in + if let self { + self.inputPanelExternalState.deleteBackward() + } + }, + presentController: { [weak self] c, a in + if let self { + self.environment?.controller()?.present(c, in: .window(.root), with: a) + } + }, + presentGlobalOverlayController: { [weak self] c, a in + if let self { + self.environment?.controller()?.presentInGlobalOverlay(c, with: a) + } + }, + getNavigationController: { return nil }, + requestLayout: { _ in + + } + ) + } + } + @objc private func fadePressed() { + self.currentInputMode = .keyboard self.endEditing(true) } @@ -347,7 +479,6 @@ final class MediaEditorScreenComponent: Component { transition.setScale(view: view, scale: 0.1) } - if case .camera = source { if let view = self.inputPanel.view { view.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 44.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true) @@ -453,7 +584,6 @@ final class MediaEditorScreenComponent: Component { } } - private var isEditingCaption = false func update(component: MediaEditorScreenComponent, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { guard !self.isDismissed else { return availableSize @@ -464,6 +594,8 @@ final class MediaEditorScreenComponent: Component { self.component = component self.state = state + self.setupIfNeeded() + let isTablet: Bool if case .regular = environment.metrics.widthClass { isTablet = true @@ -757,15 +889,27 @@ final class MediaEditorScreenComponent: Component { timeoutValue = "\(timeout ?? 1)" timeoutSelected = timeout != nil } - - + var inputPanelAvailableWidth = previewSize.width + var inputPanelAvailableHeight = 115.0 if case .regular = environment.metrics.widthClass { if (self.inputPanelExternalState.isEditing || self.inputPanelExternalState.hasText) { inputPanelAvailableWidth += 200.0 } } + if environment.inputHeight > 0.0 || self.currentInputMode == .emoji { + inputPanelAvailableHeight = 200.0 + } + let nextInputMode: MessageInputPanelComponent.InputMode + switch self.currentInputMode { + case .keyboard: + nextInputMode = .emoji + case .emoji: + nextInputMode = .keyboard + default: + nextInputMode = .emoji + } self.inputPanel.parentState = state let inputPanelSize = self.inputPanel.update( transition: transition, @@ -777,6 +921,7 @@ final class MediaEditorScreenComponent: Component { style: .editor, placeholder: "Add a caption...", alwaysDarkWhenHasText: false, + nextInputMode: nextInputMode, areVoiceMessagesAvailable: false, presentController: { [weak self] c in guard let self, let _ = self.component else { @@ -788,6 +933,7 @@ final class MediaEditorScreenComponent: Component { guard let self else { return } + self.currentInputMode = .keyboard self.endEditing(true) }, setMediaRecordingActive: nil, @@ -795,11 +941,34 @@ final class MediaEditorScreenComponent: Component { stopAndPreviewMediaRecording: nil, discardMediaRecordingPreview: nil, attachmentAction: nil, + inputModeAction: { [weak self] in + if let self { + switch self.currentInputMode { + case .keyboard: + self.currentInputMode = .emoji + case .emoji: + self.currentInputMode = .keyboard + default: + self.currentInputMode = .emoji + } + self.state?.updated(transition: .immediate) + } + }, timeoutAction: { [weak self] view in guard let self, let controller = self.environment?.controller() as? MediaEditorScreen else { return } - controller.presentTimeoutSetup(sourceView: view) + let context = controller.context + let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) + |> deliverOnMainQueue).start(next: { [weak controller] peer in + let hasPremium: Bool + if case let .user(user) = peer { + hasPremium = user.isPremium + } else { + hasPremium = false + } + controller?.presentTimeoutSetup(sourceView: view, hasPremium: hasPremium) + }) }, forwardAction: nil, presentVoiceMessagesUnavailableTooltip: nil, @@ -811,10 +980,11 @@ final class MediaEditorScreenComponent: Component { timeoutValue: timeoutValue, timeoutSelected: timeoutSelected, displayGradient: false, - bottomInset: 0.0 + bottomInset: 0.0, + hideKeyboard: self.currentInputMode == .emoji )), environment: {}, - containerSize: CGSize(width: inputPanelAvailableWidth, height: 200.0) + containerSize: CGSize(width: inputPanelAvailableWidth, height: inputPanelAvailableHeight) ) let fadeTransition = Transition(animation: .curve(duration: 0.3, curve: .easeInOut)) @@ -836,6 +1006,29 @@ final class MediaEditorScreenComponent: Component { } } + var inputHeight = environment.inputHeight + if self.inputPanelExternalState.isEditing { + if self.currentInputMode == .emoji || inputHeight.isZero { + inputHeight = environment.deviceMetrics.standardInputHeight(inLandscape: false) + } + } + + let inputPanelBackgroundSize = self.inputPanelBackground.update( + transition: transition, + component: AnyComponent(BlurredGradientComponent(position: .bottom, tag: nil)), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: environment.deviceMetrics.standardInputHeight(inLandscape: false) + 100.0) + ) + if let inputPanelBackgroundView = self.inputPanelBackground.view { + if inputPanelBackgroundView.superview == nil { + self.addSubview(inputPanelBackgroundView) + } + let isVisible = inputHeight > 44.0 + transition.setFrame(view: inputPanelBackgroundView, frame: CGRect(origin: CGPoint(x: 0.0, y: isVisible ? availableSize.height - inputPanelBackgroundSize.height : availableSize.height), size: inputPanelBackgroundSize)) + transition.setAlpha(view: inputPanelBackgroundView, alpha: isVisible ? 1.0 : 0.0, delay: isVisible ? 0.0 : 0.4) + } + + var isEditingTextEntity = false var sizeSliderVisible = false var sizeValue: CGFloat? @@ -845,11 +1038,9 @@ final class MediaEditorScreenComponent: Component { sizeValue = textEntity.fontSize } - var inputPanelOffset: CGFloat = 0.0 var inputPanelBottomInset: CGFloat = scrubberBottomInset - if environment.inputHeight > 0.0 { - inputPanelBottomInset = environment.inputHeight - environment.safeInsets.bottom - inputPanelOffset = inputPanelBottomInset + if inputHeight > 0.0 { + inputPanelBottomInset = inputHeight - environment.safeInsets.bottom } let inputPanelFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - inputPanelSize.width) / 2.0), y: availableSize.height - environment.safeInsets.bottom - inputPanelBottomInset - inputPanelSize.height - 3.0), size: inputPanelSize) if let inputPanelView = self.inputPanel.view { @@ -910,7 +1101,7 @@ final class MediaEditorScreenComponent: Component { ) } else { privacyButtonFrame = CGRect( - origin: CGPoint(x: 16.0, y: environment.safeInsets.top + 20.0 - inputPanelOffset), + origin: CGPoint(x: 16.0, y: environment.safeInsets.top + 20.0), size: privacyButtonSize ) } @@ -969,7 +1160,7 @@ final class MediaEditorScreenComponent: Component { containerSize: CGSize(width: 44.0, height: 44.0) ) let saveButtonFrame = CGRect( - origin: CGPoint(x: availableSize.width - 20.0 - saveButtonSize.width, y: environment.safeInsets.top + 20.0 - inputPanelOffset), + origin: CGPoint(x: availableSize.width - 20.0 - saveButtonSize.width, y: environment.safeInsets.top + 20.0), size: saveButtonSize ) if let saveButtonView = self.saveButton.view { @@ -1042,7 +1233,7 @@ final class MediaEditorScreenComponent: Component { containerSize: CGSize(width: 44.0, height: 44.0) ) let muteButtonFrame = CGRect( - origin: CGPoint(x: availableSize.width - 20.0 - muteButtonSize.width - 50.0, y: environment.safeInsets.top + 20.0 - inputPanelOffset), + origin: CGPoint(x: availableSize.width - 20.0 - muteButtonSize.width - 50.0, y: environment.safeInsets.top + 20.0), size: muteButtonSize ) if let muteButtonView = self.muteButton.view { @@ -1080,7 +1271,7 @@ final class MediaEditorScreenComponent: Component { containerSize: CGSize(width: 44.0, height: 44.0) ) let settingsButtonFrame = CGRect( - origin: CGPoint(x: floorToScreenPixels((availableSize.width - settingsButtonSize.width) / 2.0), y: environment.safeInsets.top + 20.0 - inputPanelOffset), + origin: CGPoint(x: floorToScreenPixels((availableSize.width - settingsButtonSize.width) / 2.0), y: environment.safeInsets.top + 20.0), size: settingsButtonSize ) if let settingsButtonView = self.settingsButton.view { @@ -1172,7 +1363,7 @@ final class MediaEditorScreenComponent: Component { environment: {}, containerSize: CGSize(width: 30.0, height: 240.0) ) - let bottomInset: CGFloat = environment.inputHeight > 0.0 ? environment.inputHeight : environment.safeInsets.bottom + let bottomInset: CGFloat = inputHeight > 0.0 ? inputHeight : environment.safeInsets.bottom let textSizeFrame = CGRect( origin: CGPoint(x: 0.0, y: environment.safeInsets.top + (availableSize.height - environment.safeInsets.top - bottomInset) / 2.0 - textSizeSize.height / 2.0), size: textSizeSize @@ -1185,6 +1376,78 @@ final class MediaEditorScreenComponent: Component { transition.setBounds(view: textSizeView, bounds: CGRect(origin: .zero, size: textSizeFrame.size)) transition.setAlpha(view: textSizeView, alpha: sizeSliderVisible && !component.isInteractingWithEntities ? 1.0 : 0.0) } + + if case .emoji = self.currentInputMode, let inputData = self.inputMediaNodeData { + let inputMediaNode: ChatEntityKeyboardInputNode + if let current = self.inputMediaNode { + inputMediaNode = current + } else { + inputMediaNode = ChatEntityKeyboardInputNode( + context: component.context, + currentInputData: inputData, + updatedInputData: self.inputMediaNodeDataPromise.get(), + defaultToEmojiTab: true, + opaqueTopPanelBackground: false, + interaction: self.inputMediaInteraction, + chatPeerId: nil, + stateContext: self.inputMediaNodeStateContext + ) + inputMediaNode.externalTopPanelContainerImpl = nil + if let inputPanelView = self.inputPanel.view { + self.insertSubview(inputMediaNode.view, belowSubview: inputPanelView) + } + self.inputMediaNode = inputMediaNode + } + + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: defaultDarkPresentationTheme) + let presentationInterfaceState = ChatPresentationInterfaceState( + chatWallpaper: .builtin(WallpaperSettings()), + theme: presentationData.theme, + strings: presentationData.strings, + dateTimeFormat: presentationData.dateTimeFormat, + nameDisplayOrder: presentationData.nameDisplayOrder, + limitsConfiguration: component.context.currentLimitsConfiguration.with { $0 }, + fontSize: presentationData.chatFontSize, + bubbleCorners: presentationData.chatBubbleCorners, + accountPeerId: component.context.account.peerId, + mode: .standard(previewing: false), + chatLocation: .peer(id: component.context.account.peerId), + subject: nil, + peerNearbyData: nil, + greetingData: nil, + pendingUnpinnedAllMessages: false, + activeGroupCallInfo: nil, + hasActiveGroupCall: false, + importState: nil, + threadData: nil, + isGeneralThreadClosed: nil + ) + + let heightAndOverflow = inputMediaNode.updateLayout(width: availableSize.width, leftInset: 0.0, rightInset: 0.0, bottomInset: component.bottomSafeInset, standardInputHeight: environment.deviceMetrics.standardInputHeight(inLandscape: false), inputHeight: environment.inputHeight, maximumHeight: availableSize.height, inputPanelHeight: 0.0, transition: .immediate, interfaceState: presentationInterfaceState, layoutMetrics: environment.metrics, deviceMetrics: environment.deviceMetrics, isVisible: true, isExpanded: false) + let inputNodeHeight = heightAndOverflow.0 + let inputNodeFrame = CGRect(origin: CGPoint(x: 0.0, y: availableSize.height - inputNodeHeight), size: CGSize(width: availableSize.width, height: inputNodeHeight)) + transition.setFrame(layer: inputMediaNode.layer, frame: inputNodeFrame) + } else if let inputMediaNode = self.inputMediaNode { + self.inputMediaNode = nil + + var targetFrame = inputMediaNode.frame + if inputHeight > 0.0 { + targetFrame.origin.y = availableSize.height - inputHeight + } else { + targetFrame.origin.y = availableSize.height + } + transition.setFrame(view: inputMediaNode.view, frame: targetFrame, completion: { [weak inputMediaNode] _ in + if let inputMediaNode { + Queue.mainQueue().after(0.3) { + inputMediaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false, completion: { [weak inputMediaNode] _ in + inputMediaNode?.view.removeFromSuperview() + }) + } + } + }) + } + + component.externalState.derivedInputHeight = inputHeight return availableSize } @@ -1263,6 +1526,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate private let backgroundDimView: UIView fileprivate let containerView: UIView + fileprivate let componentExternalState = MediaEditorScreenComponent.ExternalState() fileprivate let componentHost: ComponentView fileprivate let storyPreview: ComponentView fileprivate let toolValue: ComponentView @@ -2231,9 +2495,9 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate } let bottomInset = layout.size.height - previewSize.height - topInset - var inputHeight = layout.inputHeight ?? 0.0 + var layoutInputHeight = layout.inputHeight ?? 0.0 if self.stickerScreen != nil { - inputHeight = 0.0 + layoutInputHeight = 0.0 } let environment = ViewControllerComponentContainer.Environment( @@ -2245,12 +2509,12 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate bottom: bottomInset, right: layout.safeInsets.right ), - inputHeight: inputHeight, + inputHeight: layoutInputHeight, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, orientation: nil, isVisible: true, - theme: self.presentationData.theme, + theme: defaultDarkPresentationTheme, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, controller: { [weak self] in @@ -2267,11 +2531,13 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate component: AnyComponent( MediaEditorScreenComponent( context: self.context, + externalState: self.componentExternalState, isDisplayingTool: self.isDisplayingTool, isInteractingWithEntities: self.isInteractingWithEntities, isSavingAvailable: controller.isSavingAvailable, hasAppeared: self.hasAppeared, isDismissing: self.isDismissing, + bottomSafeInset: layout.intrinsicInsets.bottom, mediaEditor: self.mediaEditor, privacy: controller.state.privacy, selectedEntity: self.isDisplayingTool ? nil : self.entitiesView.selectedEntityView?.entity, @@ -2309,7 +2575,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate } } self.stickerScreen = controller - self.controller?.present(controller, in: .current) + self.controller?.present(controller, in: .window(.root)) return case .text: let textEntity = DrawingTextEntity(text: NSAttributedString(), style: .regular, animation: .none, font: .sanFrancisco, alignment: .center, fontSize: 1.0, color: DrawingColor(color: .white)) @@ -2352,7 +2618,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate self?.interaction?.activate() self?.entitiesView.selectEntity(nil) } - self.controller?.present(controller, in: .current) + self.controller?.present(controller, in: .window(.root)) self.animateOutToTool() } } @@ -2368,7 +2634,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate self.animateInFromTool() } } - self.controller?.present(controller, in: .current) + self.controller?.present(controller, in: .window(.root)) self.animateOutToTool() } } @@ -2388,6 +2654,8 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate transition.setFrame(view: componentView, frame: CGRect(origin: CGPoint(x: 0.0, y: self.dismissOffset), size: componentSize)) } + let inputHeight = self.componentExternalState.derivedInputHeight + let storyPreviewSize = self.storyPreview.update( transition: transition, component: AnyComponent( @@ -2441,7 +2709,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate if self.entitiesView.selectedEntityView != nil || self.isDisplayingTool { bottomInputOffset = inputHeight / 2.0 } else { - bottomInputOffset = inputHeight - bottomInset - 17.0 + bottomInputOffset = 0.0 //inputHeight - bottomInset - 17.0 } } } @@ -2461,6 +2729,10 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate self.interaction?.containerLayoutUpdated(layout: layout, transition: transition) + var layout = layout + layout.intrinsicInsets.bottom = bottomInset + 60.0 + controller.presentationContext.containerLayoutUpdated(layout, transition: transition.containedViewLayoutTransition) + if isFirstTime { self.animateIn() } @@ -2575,6 +2847,8 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate super.init(navigationBarPresentationData: nil) + self.automaticallyControlPresentationContextLayout = false + self.navigationPresentation = .flatModal self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) @@ -2719,7 +2993,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate }) } - func presentTimeoutSetup(sourceView: UIView) { + func presentTimeoutSetup(sourceView: UIView, hasPremium: Bool) { self.hapticFeedback.impact(.light) var items: [ContextMenuItem] = [] @@ -2755,18 +3029,34 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate switch self.state.privacy { case .story: items.append(.action(ContextMenuActionItem(text: "6 Hours", icon: { theme in - return currentValue == 3600 * 6 ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil - }, action: { _, a in + if !hasPremium { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/TextLockIcon"), color: theme.contextMenu.secondaryColor) + } else { + return currentValue == 3600 * 6 ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil + } + }, action: { [weak self] _, a in a(.default) - updateTimeout(3600 * 6, false) + if hasPremium { + updateTimeout(3600 * 6, false) + } else { + self?.presentTimeoutPremiumSuggestion(3600 * 6) + } }))) items.append(.action(ContextMenuActionItem(text: "12 Hours", icon: { theme in - return currentValue == 3600 * 12 ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil - }, action: { _, a in + if !hasPremium { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/TextLockIcon"), color: theme.contextMenu.secondaryColor) + } else { + return currentValue == 3600 * 12 ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil + } + }, action: { [weak self] _, a in a(.default) - updateTimeout(3600 * 12, false) + if hasPremium { + updateTimeout(3600 * 12, false) + } else { + self?.presentTimeoutPremiumSuggestion(3600 * 12) + } }))) items.append(.action(ContextMenuActionItem(text: "24 Hours", icon: { theme in return currentValue == 86400 && !currentArchived ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil @@ -2776,11 +3066,19 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate updateTimeout(86400, false) }))) items.append(.action(ContextMenuActionItem(text: "48 Hours", icon: { theme in - return currentValue == 86400 * 2 ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil - }, action: { _, a in + if !hasPremium { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Accessory Panels/TextLockIcon"), color: theme.contextMenu.secondaryColor) + } else { + return currentValue == 86400 * 2 ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil + } + }, action: { [weak self] _, a in a(.default) - updateTimeout(86400 * 2, false) + if hasPremium { + updateTimeout(86400 * 2, false) + } else { + self?.presentTimeoutPremiumSuggestion(86400 * 2) + } }))) items.append(.action(ContextMenuActionItem(text: "Keep Always", icon: { theme in return currentArchived ? generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.contextMenu.primaryColor) : nil @@ -2790,7 +3088,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate updateTimeout(86400, true) }))) items.append(.separator) - items.append(.action(ContextMenuActionItem(text: "Select 'Keep Always' to always show the story in your profile.", textLayout: .multiline, textFont: .small, icon: { theme in + items.append(.action(ContextMenuActionItem(text: "Select 'Keep Always' to show the story on your page.", textLayout: .multiline, textFont: .small, icon: { theme in return nil }, action: { _, _ in }))) @@ -2837,6 +3135,23 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate self.present(contextController, in: .window(.root)) } + private func presentTimeoutPremiumSuggestion(_ timeout: Int32) { + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + + let timeoutString = presentationData.strings.MuteExpires_Hours(max(1, timeout / (60 * 60))) + let text = "Subscribe to **Telegram Premium** to make your stories disappear \(timeoutString)." + + let context = self.context + let controller = UndoOverlayController(presentationData: presentationData, content: .autoDelete(isOn: true, title: nil, text: text, customUndoText: "More"), elevatedLayout: false, position: .bottom, animateInAsReplacement: false, action: { [weak self] action in + if case .undo = action, let self { + let controller = context.sharedContext.makePremiumIntroController(context: context, source: .settings) + self.push(controller) + } + return false } + ) + self.present(controller, in: .current) + } + func maybePresentDiscardAlert() { self.hapticFeedback.impact(.light) if "".isEmpty { @@ -3531,3 +3846,84 @@ private final class ToolValueComponent: Component { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } + +public final class BlurredGradientComponent: Component { + public enum Position { + case top + case bottom + } + + let position: Position + let tag: AnyObject? + + public init( + position: Position, + tag: AnyObject? + ) { + self.position = position + self.tag = tag + } + + public static func ==(lhs: BlurredGradientComponent, rhs: BlurredGradientComponent) -> Bool { + if lhs.position != rhs.position { + return false + } + return true + } + + public final class View: BlurredBackgroundView, ComponentTaggedView { + private var component: BlurredGradientComponent? + + public func matches(tag: Any) -> Bool { + if let component = self.component, let componentTag = component.tag { + let tag = tag as AnyObject + if componentTag === tag { + return true + } + } + return false + } + + private var gradientMask = UIImageView() + private var gradientForeground = SimpleGradientLayer() + + public func update(component: BlurredGradientComponent, availableSize: CGSize, transition: Transition) -> CGSize { + self.component = component + + self.isUserInteractionEnabled = false + + self.updateColor(color: UIColor(rgb: 0x000000, alpha: component.position == .top ? 0.15 : 0.25), transition: transition.containedViewLayoutTransition) + + if self.mask == nil { + self.mask = self.gradientMask + self.gradientMask.image = generateGradientImage( + size: CGSize(width: 1.0, height: availableSize.height), + colors: [UIColor(rgb: 0xffffff, alpha: 1.0), UIColor(rgb: 0xffffff, alpha: 1.0), UIColor(rgb: 0xffffff, alpha: 0.0)], + locations: component.position == .top ? [0.0, 0.8, 1.0] : [1.0, 0.20, 0.0], + direction: .vertical + ) + + self.gradientForeground.colors = [UIColor(rgb: 0x000000, alpha: 0.35).cgColor, UIColor(rgb: 0x000000, alpha: 0.0).cgColor] + self.gradientForeground.startPoint = CGPoint(x: 0.5, y: component.position == .top ? 0.0 : 1.0) + self.gradientForeground.endPoint = CGPoint(x: 0.5, y: component.position == .top ? 1.0 : 0.0) + + self.layer.addSublayer(self.gradientForeground) + } + + transition.setFrame(view: self.gradientMask, frame: CGRect(origin: .zero, size: availableSize)) + transition.setFrame(layer: self.gradientForeground, frame: CGRect(origin: .zero, size: availableSize)) + + self.update(size: availableSize, transition: transition.containedViewLayoutTransition) + + return availableSize + } + } + + public func makeView() -> View { + return View(color: nil, enableBlur: true) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StoryPreviewComponent.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StoryPreviewComponent.swift index c1ee68e04d..54d8fb7528 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StoryPreviewComponent.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StoryPreviewComponent.swift @@ -251,6 +251,7 @@ final class StoryPreviewComponent: Component { style: .story, placeholder: "Reply Privately...", alwaysDarkWhenHasText: false, + nextInputMode: nil, areVoiceMessagesAvailable: false, presentController: { _ in }, @@ -261,6 +262,7 @@ final class StoryPreviewComponent: Component { stopAndPreviewMediaRecording: nil, discardMediaRecordingPreview: nil, attachmentAction: { }, + inputModeAction: nil, timeoutAction: nil, forwardAction: nil, presentVoiceMessagesUnavailableTooltip: nil, @@ -272,7 +274,8 @@ final class StoryPreviewComponent: Component { timeoutValue: nil, timeoutSelected: false, displayGradient: false, - bottomInset: 0.0 + bottomInset: 0.0, + hideKeyboard: false )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 200.0) diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD b/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD index 5a3fae25d2..aec71a56c8 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD @@ -24,6 +24,9 @@ swift_library( "//submodules/Components/HierarchyTrackingLayer", "//submodules/TelegramUI/Components/AudioWaveformComponent", "//submodules/MediaPlayer:UniversalMediaPlayer", + "//submodules/ChatContextQuery", + "//submodules/TextFormat", + "//submodules/TelegramUI/Components/Stories/PeerListItemComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/ContextResultPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/ContextResultPanelComponent.swift new file mode 100644 index 0000000000..2e27824caf --- /dev/null +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/ContextResultPanelComponent.swift @@ -0,0 +1,375 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import ComponentDisplayAdapters +import TelegramCore +import AccountContext +import TelegramPresentationData +import PeerListItemComponent + +extension ChatPresentationInputQueryResult { + var count: Int { + switch self { + case let .stickers(stickers): + return stickers.count + case let .hashtags(hashtags): + return hashtags.count + case let .mentions(peers): + return peers.count + case let .commands(commands): + return commands.count + default: + return 0 + } + } + +} + +final class ContextResultPanelComponent: Component { + final class ExternalState { + fileprivate(set) var minimizedHeight: CGFloat = 0.0 + fileprivate(set) var effectiveHeight: CGFloat = 0.0 + + init() { + } + } + + enum ResultAction { + case mention(EnginePeer) + case hashtag(String) + } + + let externalState: ExternalState + let context: AccountContext + let theme: PresentationTheme + let strings: PresentationStrings + let results: ChatPresentationInputQueryResult + let action: (ResultAction) -> Void + + init( + externalState: ExternalState, + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + results: ChatPresentationInputQueryResult, + action: @escaping (ResultAction) -> Void + ) { + self.externalState = externalState + self.context = context + self.theme = theme + self.strings = strings + self.results = results + self.action = action + } + + static func ==(lhs: ContextResultPanelComponent, rhs: ContextResultPanelComponent) -> Bool { + if lhs.externalState !== rhs.externalState { + return false + } + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.results != rhs.results { + return false + } + return true + } + + private struct ItemLayout: Equatable { + var containerSize: CGSize + var bottomInset: CGFloat + var topInset: CGFloat + var sideInset: CGFloat + var itemHeight: CGFloat + var itemCount: Int + + var contentSize: CGSize + + init(containerSize: CGSize, bottomInset: CGFloat, topInset: CGFloat, sideInset: CGFloat, itemHeight: CGFloat, itemCount: Int) { + self.containerSize = containerSize + self.bottomInset = bottomInset + self.topInset = topInset + self.sideInset = sideInset + self.itemHeight = itemHeight + self.itemCount = itemCount + + self.contentSize = CGSize(width: containerSize.width, height: topInset + CGFloat(itemCount) * itemHeight + bottomInset) + } + + func visibleItems(for rect: CGRect) -> Range? { + let offsetRect = rect.offsetBy(dx: 0.0, dy: -self.topInset) + var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemHeight))) + minVisibleRow = max(0, minVisibleRow) + let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemHeight))) + + let minVisibleIndex = minVisibleRow + let maxVisibleIndex = maxVisibleRow + + if maxVisibleIndex >= minVisibleIndex { + return minVisibleIndex ..< (maxVisibleIndex + 1) + } else { + return nil + } + } + + func itemFrame(for index: Int) -> CGRect { + return CGRect(origin: CGPoint(x: 0.0, y: self.topInset + CGFloat(index) * self.itemHeight), size: CGSize(width: self.containerSize.width, height: self.itemHeight)) + } + } + + private final class ScrollView: UIScrollView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let result = super.hitTest(point, with: event) + if result === self { + return nil + } + return super.hitTest(point, with: event) + } + + override func touchesShouldCancel(in view: UIView) -> Bool { + return true + } + } + + final class View: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate { + private let backgroundView: BlurredBackgroundView + private let scrollView: UIScrollView + + private var itemLayout: ItemLayout? + + private let measureItem = ComponentView() + + private var visibleItems: [AnyHashable: ComponentView] = [:] + + private var ignoreScrolling = false + + private var component: ContextResultPanelComponent? + private weak var state: EmptyComponentState? + + override init(frame: CGRect) { + self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) + + self.scrollView = ScrollView() + self.scrollView.canCancelContentTouches = true + self.scrollView.delaysContentTouches = false + self.scrollView.showsVerticalScrollIndicator = true + self.scrollView.contentInsetAdjustmentBehavior = .never + self.scrollView.alwaysBounceVertical = true + self.scrollView.indicatorStyle = .white + + super.init(frame: frame) + + self.clipsToBounds = true + self.scrollView.delegate = self + + self.addSubview(self.backgroundView) + self.addSubview(self.scrollView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func animateIn(transition: Transition) { + let offset = self.scrollView.contentOffset.y * -1.0 + 10.0 + Transition.immediate.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: -offset)) + transition.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: 0.0)) + } + + func animateOut(transition: Transition, completion: @escaping () -> Void) { + let offset = self.scrollView.contentOffset.y * -1.0 + 10.0 + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) + transition.setBoundsOrigin(view: self, origin: CGPoint(x: 0.0, y: -offset), completion: { _ in + completion() + }) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateScrolling(transition: .immediate) + } + } + + private func updateScrolling(transition: Transition) { + guard let component = self.component, let itemLayout = self.itemLayout else { + return + } + + let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -200.0) + + var synchronousLoad = false + if let hint = transition.userData(PeerListItemComponent.TransitionHint.self) { + synchronousLoad = hint.synchronousLoad + } + + var validIds: [AnyHashable] = [] + if let range = itemLayout.visibleItems(for: visibleBounds), case let .mentions(peers) = component.results { + for index in range.lowerBound ..< range.upperBound { + guard index < peers.count else { + continue + } + + let itemFrame = itemLayout.itemFrame(for: index) + + var itemTransition = transition + let peer = peers[index] + validIds.append(peer.id) + + let visibleItem: ComponentView + if let current = self.visibleItems[peer.id] { + visibleItem = current + } else { + if !transition.animation.isImmediate { + itemTransition = .immediate + } + visibleItem = ComponentView() + self.visibleItems[peer.id] = visibleItem + } + + let _ = visibleItem.update( + transition: itemTransition, + component: AnyComponent(PeerListItemComponent( + context: component.context, + theme: component.theme, + strings: component.strings, + style: .compact, + sideInset: itemLayout.sideInset, + title: peer.displayTitle(strings: component.strings, displayOrder: .firstLast), + peer: peer, + subtitle: peer.addressName.flatMap { "@\($0)" }, + subtitleAccessory: .none, + selectionState: .none, + hasNext: index != peers.count - 1, + action: { [weak self] peer in + guard let self, let component = self.component else { + return + } + component.action(.mention(peer)) + } + )), + environment: {}, + containerSize: itemFrame.size + ) + if let itemView = visibleItem.view { + var animateIn = false + if itemView.superview == nil { + animateIn = true + self.scrollView.addSubview(itemView) + } + itemTransition.setFrame(view: itemView, frame: itemFrame) + + if animateIn, synchronousLoad { + itemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + } + } + + var removeIds: [AnyHashable] = [] + for (id, visibleItem) in self.visibleItems { + if !validIds.contains(id) { + removeIds.append(id) + if let itemView = visibleItem.view { + itemView.removeFromSuperview() + } + } + } + for id in removeIds { + self.visibleItems.removeValue(forKey: id) + } + + let backgroundSize = CGSize(width: self.scrollView.frame.width, height: self.scrollView.frame.height + 20.0) + transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: 0.0, y: max(0.0, self.scrollView.contentOffset.y * -1.0)), size: backgroundSize)) + self.backgroundView.update(size: backgroundSize, cornerRadius: 11.0, transition: transition.containedViewLayoutTransition) + } + + func update(component: ContextResultPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + //let itemUpdated = self.component?.results != component.results + + self.component = component + self.state = state + + let minimizedHeight = min(availableSize.height, 500.0) + + let sideInset: CGFloat = 3.0 + self.backgroundView.updateColor(color: UIColor(white: 0.0, alpha: 0.7), transition: transition.containedViewLayoutTransition) + + let measureItemSize = self.measureItem.update( + transition: .immediate, + component: AnyComponent(PeerListItemComponent( + context: component.context, + theme: component.theme, + strings: component.strings, + style: .compact, + sideInset: sideInset, + title: "AAAAAAAAAAAA", + peer: nil, + subtitle: "BBBBBBB", + subtitleAccessory: .none, + selectionState: .none, + hasNext: true, + action: { _ in + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width, height: 1000.0) + ) + + let itemLayout = ItemLayout( + containerSize: CGSize(width: availableSize.width, height: minimizedHeight), + bottomInset: 0.0, + topInset: 0.0, + sideInset: sideInset, + itemHeight: measureItemSize.height, + itemCount: component.results.count + ) + self.itemLayout = itemLayout + + let scrollContentSize = itemLayout.contentSize + + self.ignoreScrolling = true + + transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: minimizedHeight))) + + let visibleTopContentHeight = min(scrollContentSize.height, measureItemSize.height * 3.5 + 19.0) + let topInset = availableSize.height - visibleTopContentHeight + + let scrollContentInsets = UIEdgeInsets(top: topInset, left: 0.0, bottom: 19.0, right: 0.0) + let scrollIndicatorInsets = UIEdgeInsets(top: topInset + 17.0, left: 0.0, bottom: 19.0, right: 0.0) + if self.scrollView.contentInset != scrollContentInsets { + self.scrollView.contentInset = scrollContentInsets + } + if self.scrollView.scrollIndicatorInsets != scrollIndicatorInsets { + self.scrollView.scrollIndicatorInsets = scrollIndicatorInsets + } + if self.scrollView.contentSize != scrollContentSize { + self.scrollView.contentSize = scrollContentSize + } + + self.ignoreScrolling = false + self.updateScrolling(transition: transition) + +// component.externalState.minimizedHeight = minimizedHeight + +// let effectiveHeight: CGFloat = minimizedHeight * dismissFraction + (1.0 - dismissFraction) * (60.0 + component.safeInsets.bottom + 1.0) +// component.externalState.effectiveHeight = min(minimizedHeight, max(0.0, effectiveHeight)) + + return availableSize + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/InputContextQueries.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/InputContextQueries.swift new file mode 100644 index 0000000000..bfc41b5b60 --- /dev/null +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/InputContextQueries.swift @@ -0,0 +1,135 @@ +import Foundation +import SwiftSignalKit +import TextFieldComponent +import ChatContextQuery +import AccountContext + +func textInputStateContextQueryRangeAndType(inputState: TextFieldComponent.InputState) -> [(NSRange, PossibleContextQueryTypes, NSRange?)] { + return textInputStateContextQueryRangeAndType(inputText: inputState.inputText, selectionRange: inputState.selectionRange) +} + +func inputContextQueries(_ inputState: TextFieldComponent.InputState) -> [ChatPresentationInputQuery] { + let inputString: NSString = inputState.inputText.string as NSString + var result: [ChatPresentationInputQuery] = [] + for (possibleQueryRange, possibleTypes, additionalStringRange) in textInputStateContextQueryRangeAndType(inputText: inputState.inputText, selectionRange: inputState.selectionRange) { + let query = inputString.substring(with: possibleQueryRange) + if possibleTypes == [.emoji] { + result.append(.emoji(query.basicEmoji.0)) + } else if possibleTypes == [.hashtag] { + result.append(.hashtag(query)) + } else if possibleTypes == [.mention] { + let types: ChatInputQueryMentionTypes = [.members] +// if possibleQueryRange.lowerBound == 1 { +// types.insert(.contextBots) +// } + result.append(.mention(query: query, types: types)) + } else if possibleTypes == [.command] { + result.append(.command(query)) + } else if possibleTypes == [.contextRequest], let additionalStringRange = additionalStringRange { + let additionalString = inputString.substring(with: additionalStringRange) + result.append(.contextRequest(addressName: query, query: additionalString)) + } +// else if possibleTypes == [.emojiSearch], !query.isEmpty, let inputLanguage = chatPresentationInterfaceState.interfaceState.inputLanguage { +// result.append(.emojiSearch(query: query, languageCode: inputLanguage, range: possibleQueryRange)) +// } + } + return result +} + +func contextQueryResultState(context: AccountContext, inputState: TextFieldComponent.InputState, currentQueryStates: inout [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)]) -> [ChatPresentationInputQueryKind: ChatContextQueryUpdate] { + let inputQueries = inputContextQueries(inputState).filter({ query in + switch query { + case .contextRequest, .command, .emoji: + return false + default: + return true + } + }) + + var updates: [ChatPresentationInputQueryKind: ChatContextQueryUpdate] = [:] + + for query in inputQueries { + let previousQuery = currentQueryStates[query.kind]?.0 + if previousQuery != query { + let signal = updatedContextQueryResultStateForQuery(context: context, inputQuery: query, previousQuery: previousQuery) + updates[query.kind] = .update(query, signal) + } + } + + for currentQueryKind in currentQueryStates.keys { + var found = false + inner: for query in inputQueries { + if query.kind == currentQueryKind { + found = true + break inner + } + } + if !found { + updates[currentQueryKind] = .remove + } + } + + return updates +} + +private func updatedContextQueryResultStateForQuery(context: AccountContext, inputQuery: ChatPresentationInputQuery, previousQuery: ChatPresentationInputQuery?) -> Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> { + switch inputQuery { + case let .hashtag(query): + var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete() + if let previousQuery = previousQuery { + switch previousQuery { + case .hashtag: + break + default: + signal = .single({ _ in return .hashtags([]) }) + } + } else { + signal = .single({ _ in return .hashtags([]) }) + } + + let hashtags: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = context.engine.messages.recentlyUsedHashtags() + |> map { hashtags -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in + let normalizedQuery = query.lowercased() + var result: [String] = [] + for hashtag in hashtags { + if hashtag.lowercased().hasPrefix(normalizedQuery) { + result.append(hashtag) + } + } + return { _ in return .hashtags(result) } + } + |> castError(ChatContextQueryError.self) + + return signal |> then(hashtags) + case let .mention(query, _): + var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = .complete() + if let previousQuery = previousQuery { + switch previousQuery { + case .mention: + break + default: + signal = .single({ _ in return .mentions([]) }) + } + } else { + signal = .single({ _ in return .mentions([]) }) + } + + let normalizedQuery = query.lowercased() + let peers: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> = context.engine.contacts.searchLocalPeers(query: normalizedQuery) + |> map { peersAndPresences -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in + let peers = peersAndPresences.filter { peer in + if let peer = peer.peer, case .user = peer { + return true + } else { + return false + } + }.compactMap { $0.peer } + return { _ in return .mentions(peers) } + } + |> castError(ChatContextQueryError.self) + + return signal |> then(peers) + default: + return .complete() + } +} diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift index 538712a97e..5220c19806 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import Display import ComponentFlow +import SwiftSignalKit import AppBundle import TextFieldComponent import BundleIconComponent @@ -9,16 +10,28 @@ import AccountContext import TelegramPresentationData import ChatPresentationInterfaceState import LottieComponent +import ChatContextQuery +import TextFormat public final class MessageInputPanelComponent: Component { public enum Style { case story case editor } + + public enum InputMode: Hashable { + case keyboard + case stickers + case emoji + } + public final class ExternalState { public fileprivate(set) var isEditing: Bool = false public fileprivate(set) var hasText: Bool = false + public fileprivate(set) var insertText: (NSAttributedString) -> Void = { _ in } + public fileprivate(set) var deleteBackward: () -> Void = { } + public init() { } } @@ -30,6 +43,7 @@ public final class MessageInputPanelComponent: Component { public let style: Style public let placeholder: String public let alwaysDarkWhenHasText: Bool + public let nextInputMode: InputMode? public let areVoiceMessagesAvailable: Bool public let presentController: (ViewController) -> Void public let sendMessageAction: () -> Void @@ -38,6 +52,7 @@ public final class MessageInputPanelComponent: Component { public let stopAndPreviewMediaRecording: (() -> Void)? public let discardMediaRecordingPreview: (() -> Void)? public let attachmentAction: (() -> Void)? + public let inputModeAction: (() -> Void)? public let timeoutAction: ((UIView) -> Void)? public let forwardAction: (() -> Void)? public let presentVoiceMessagesUnavailableTooltip: ((UIView) -> Void)? @@ -50,6 +65,7 @@ public final class MessageInputPanelComponent: Component { public let timeoutSelected: Bool public let displayGradient: Bool public let bottomInset: CGFloat + public let hideKeyboard: Bool public init( externalState: ExternalState, @@ -59,6 +75,7 @@ public final class MessageInputPanelComponent: Component { style: Style, placeholder: String, alwaysDarkWhenHasText: Bool, + nextInputMode: InputMode?, areVoiceMessagesAvailable: Bool, presentController: @escaping (ViewController) -> Void, sendMessageAction: @escaping () -> Void, @@ -67,6 +84,7 @@ public final class MessageInputPanelComponent: Component { stopAndPreviewMediaRecording: (() -> Void)?, discardMediaRecordingPreview: (() -> Void)?, attachmentAction: (() -> Void)?, + inputModeAction: (() -> Void)?, timeoutAction: ((UIView) -> Void)?, forwardAction: (() -> Void)?, presentVoiceMessagesUnavailableTooltip: ((UIView) -> Void)?, @@ -78,13 +96,15 @@ public final class MessageInputPanelComponent: Component { timeoutValue: String?, timeoutSelected: Bool, displayGradient: Bool, - bottomInset: CGFloat + bottomInset: CGFloat, + hideKeyboard: Bool ) { self.externalState = externalState self.context = context self.theme = theme self.strings = strings self.style = style + self.nextInputMode = nextInputMode self.placeholder = placeholder self.alwaysDarkWhenHasText = alwaysDarkWhenHasText self.areVoiceMessagesAvailable = areVoiceMessagesAvailable @@ -95,6 +115,7 @@ public final class MessageInputPanelComponent: Component { self.stopAndPreviewMediaRecording = stopAndPreviewMediaRecording self.discardMediaRecordingPreview = discardMediaRecordingPreview self.attachmentAction = attachmentAction + self.inputModeAction = inputModeAction self.timeoutAction = timeoutAction self.forwardAction = forwardAction self.presentVoiceMessagesUnavailableTooltip = presentVoiceMessagesUnavailableTooltip @@ -107,6 +128,7 @@ public final class MessageInputPanelComponent: Component { self.timeoutSelected = timeoutSelected self.displayGradient = displayGradient self.bottomInset = bottomInset + self.hideKeyboard = hideKeyboard } public static func ==(lhs: MessageInputPanelComponent, rhs: MessageInputPanelComponent) -> Bool { @@ -125,6 +147,9 @@ public final class MessageInputPanelComponent: Component { if lhs.style != rhs.style { return false } + if lhs.nextInputMode != rhs.nextInputMode { + return false + } if lhs.placeholder != rhs.placeholder { return false } @@ -164,6 +189,9 @@ public final class MessageInputPanelComponent: Component { if (lhs.forwardAction == nil) != (rhs.forwardAction == nil) { return false } + if lhs.hideKeyboard != rhs.hideKeyboard { + return false + } return true } @@ -199,6 +227,12 @@ public final class MessageInputPanelComponent: Component { private var currentMediaInputIsVoice: Bool = true private var mediaCancelFraction: CGFloat = 0.0 + private var contextQueryStates: [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)] = [:] + private var contextQueryResults: [ChatPresentationInputQueryKind: ChatPresentationInputQueryResult] = [:] + + private var contextQueryResultPanel: ComponentView? + private var contextQueryResultPanelExternalState: ContextResultPanelComponent.ExternalState? + private var component: MessageInputPanelComponent? private weak var state: EmptyComponentState? @@ -256,9 +290,56 @@ public final class MessageInputPanelComponent: Component { } } + public func updateContextQueries() { + guard let component = self.component, let textFieldView = self.textField.view as? TextFieldComponent.View else { + return + } + let context = component.context + let inputState = textFieldView.getInputState() + + let contextQueryUpdates = contextQueryResultState(context: context, inputState: inputState, currentQueryStates: &self.contextQueryStates) + + for (kind, update) in contextQueryUpdates { + switch update { + case .remove: + if let (_, disposable) = self.contextQueryStates[kind] { + disposable.dispose() + self.contextQueryStates.removeValue(forKey: kind) + self.contextQueryResults[kind] = nil + } + case let .update(query, signal): + let currentQueryAndDisposable = self.contextQueryStates[kind] + currentQueryAndDisposable?.1.dispose() + + var inScope = true + var inScopeResult: ((ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?)? + self.contextQueryStates[kind] = (query, (signal + |> deliverOnMainQueue).start(next: { [weak self] result in + if let self { + if Thread.isMainThread && inScope { + inScope = false + inScopeResult = result + } else { + self.contextQueryResults[kind] = result(self.contextQueryResults[kind]) + self.state?.updated(transition: .immediate) + } + } + })) + inScope = false + if let inScopeResult = inScopeResult { + self.contextQueryResults[kind] = inScopeResult(self.contextQueryResults[kind]) + } + } + } + } + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let result = super.hitTest(point, with: event) + if result == nil, let contextQueryResultPanel = self.contextQueryResultPanel?.view, let panelResult = contextQueryResultPanel.hitTest(self.convert(point, to: contextQueryResultPanel), with: event), panelResult !== contextQueryResultPanel { + return panelResult + } + return result } @@ -276,6 +357,7 @@ public final class MessageInputPanelComponent: Component { let baseFieldHeight: CGFloat = 40.0 + let previousComponent = self.component self.component = component self.state = state @@ -317,9 +399,16 @@ public final class MessageInputPanelComponent: Component { let textFieldSize = self.textField.update( transition: .immediate, component: AnyComponent(TextFieldComponent( + context: component.context, strings: component.strings, externalState: self.textFieldExternalState, - placeholder: "" + fontSize: 17.0, + textColor: UIColor(rgb: 0xffffff), + insets: UIEdgeInsets(top: 9.0, left: 8.0, bottom: 10.0, right: 48.0), + hideKeyboard: component.hideKeyboard, + present: { c in + component.presentController(c) + } )), environment: {}, containerSize: availableTextFieldSize @@ -644,36 +733,100 @@ public final class MessageInputPanelComponent: Component { } var fieldIconNextX = fieldBackgroundFrame.maxX - 4.0 - if case .story = component.style { - let stickerButtonSize = self.stickerButton.update( - transition: transition, - component: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Text/AccessoryIconStickers", - tintColor: .white - )), - action: { [weak self] in - guard let self else { - return - } - self.component?.attachmentAction?() + + var inputModeVisible = false + if component.style == .story || self.textFieldExternalState.isEditing { + inputModeVisible = true + } + + let animationName: String + var animationPlay = false + + if let inputMode = component.nextInputMode { + switch inputMode { + case .keyboard: + if let previousInputMode = previousComponent?.nextInputMode { + if case .stickers = previousInputMode { + animationName = "input_anim_stickerToKey" + animationPlay = true + } else if case .emoji = previousInputMode { + animationName = "input_anim_smileToKey" + animationPlay = true + } else { + animationName = "input_anim_stickerToKey" } - ).minSize(CGSize(width: 32.0, height: 32.0))), - environment: {}, - containerSize: CGSize(width: 32.0, height: 32.0) - ) - if let stickerButtonView = self.stickerButton.view { - if stickerButtonView.superview == nil { - self.addSubview(stickerButtonView) + } else { + animationName = "input_anim_stickerToKey" } - let stickerIconFrame = CGRect(origin: CGPoint(x: fieldIconNextX - stickerButtonSize.width, y: fieldBackgroundFrame.minY + floor((fieldBackgroundFrame.height - stickerButtonSize.height) * 0.5)), size: stickerButtonSize) - transition.setPosition(view: stickerButtonView, position: stickerIconFrame.center) - transition.setBounds(view: stickerButtonView, bounds: CGRect(origin: CGPoint(), size: stickerIconFrame.size)) - - transition.setAlpha(view: stickerButtonView, alpha: (self.textFieldExternalState.hasText || hasMediaRecording || hasMediaEditing) ? 0.0 : 1.0) - transition.setScale(view: stickerButtonView, scale: (self.textFieldExternalState.hasText || hasMediaRecording || hasMediaEditing) ? 0.1 : 1.0) - + case .stickers: + if let previousInputMode = previousComponent?.nextInputMode { + if case .keyboard = previousInputMode { + animationName = "input_anim_keyToSticker" + animationPlay = true + } else if case .emoji = previousInputMode { + animationName = "input_anim_smileToSticker" + animationPlay = true + } else { + animationName = "input_anim_keyToSticker" + } + } else { + animationName = "input_anim_keyToSticker" + } + case .emoji: + if let previousInputMode = previousComponent?.nextInputMode { + if case .keyboard = previousInputMode { + animationName = "input_anim_keyToSmile" + animationPlay = true + } else if case .stickers = previousInputMode { + animationName = "input_anim_stickerToSmile" + animationPlay = true + } else { + animationName = "input_anim_keyToSmile" + } + } else { + animationName = "input_anim_keyToSmile" + } + } + } else { + animationName = "" + } + + let stickerButtonSize = self.stickerButton.update( + transition: transition, + component: AnyComponent(Button( + content: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent(name: animationName), + color: .white + )), + action: { [weak self] in + guard let self else { + return + } + self.component?.inputModeAction?() + } + ).minSize(CGSize(width: 32.0, height: 32.0))), + environment: {}, + containerSize: CGSize(width: 32.0, height: 32.0) + ) + if let stickerButtonView = self.stickerButton.view as? Button.View { + if stickerButtonView.superview == nil { + self.addSubview(stickerButtonView) + } + let stickerIconFrame = CGRect(origin: CGPoint(x: fieldIconNextX - stickerButtonSize.width, y: fieldFrame.maxY - 4.0 - stickerButtonSize.height), size: stickerButtonSize) + transition.setPosition(view: stickerButtonView, position: stickerIconFrame.center) + transition.setBounds(view: stickerButtonView, bounds: CGRect(origin: CGPoint(), size: stickerIconFrame.size)) + + transition.setAlpha(view: stickerButtonView, alpha: (hasMediaRecording || hasMediaEditing || !inputModeVisible) ? 0.0 : 1.0) + transition.setScale(view: stickerButtonView, scale: (hasMediaRecording || hasMediaEditing || !inputModeVisible) ? 0.1 : 1.0) + + if inputModeVisible { fieldIconNextX -= stickerButtonSize.width + 2.0 + + if let animationView = stickerButtonView.content as? LottieComponent.View { + if animationPlay { + animationView.playOnce() + } + } } } @@ -723,14 +876,13 @@ public final class MessageInputPanelComponent: Component { if timeoutButtonView.superview == nil { self.addSubview(timeoutButtonView) } - let timeoutIconFrame = CGRect(origin: CGPoint(x: fieldIconNextX - timeoutButtonSize.width, y: fieldFrame.maxY - 4.0 - timeoutButtonSize.height), size: timeoutButtonSize) + let originX = fieldBackgroundFrame.maxX - 4.0 + let timeoutIconFrame = CGRect(origin: CGPoint(x: originX - timeoutButtonSize.width, y: fieldFrame.maxY - 4.0 - timeoutButtonSize.height), size: timeoutButtonSize) transition.setPosition(view: timeoutButtonView, position: timeoutIconFrame.center) transition.setBounds(view: timeoutButtonView, bounds: CGRect(origin: CGPoint(), size: timeoutIconFrame.size)) transition.setAlpha(view: timeoutButtonView, alpha: self.textFieldExternalState.isEditing ? 0.0 : 1.0) transition.setScale(view: timeoutButtonView, scale: self.textFieldExternalState.isEditing ? 0.1 : 1.0) - - fieldIconNextX -= timeoutButtonSize.width + 2.0 } } @@ -748,6 +900,16 @@ public final class MessageInputPanelComponent: Component { component.externalState.isEditing = self.textFieldExternalState.isEditing component.externalState.hasText = self.textFieldExternalState.hasText + component.externalState.insertText = { [weak self] text in + if let self, let view = self.textField.view as? TextFieldComponent.View { + view.insertText(text) + } + } + component.externalState.deleteBackward = { [weak self] in + if let self, let view = self.textField.view as? TextFieldComponent.View { + view.deleteBackward() + } + } if hasMediaRecording { if let dismissingMediaRecordingPanel = self.dismissingMediaRecordingPanel { @@ -894,6 +1056,94 @@ public final class MessageInputPanelComponent: Component { } } + self.updateContextQueries() + + if let result = self.contextQueryResults[.mention], result.count > 0 && self.textFieldExternalState.isEditing { + let availablePanelHeight: CGFloat = 413.0 + + var animateIn = false + let panel: ComponentView + let externalState: ContextResultPanelComponent.ExternalState + var transition = transition + if let current = self.contextQueryResultPanel, let currentState = self.contextQueryResultPanelExternalState { + panel = current + externalState = currentState + } else { + panel = ComponentView() + externalState = ContextResultPanelComponent.ExternalState() + self.contextQueryResultPanel = panel + self.contextQueryResultPanelExternalState = externalState + animateIn = true + transition = .immediate + } + let panelLeftInset: CGFloat = max(insets.left, 7.0) + let panelRightInset: CGFloat = max(insets.right, 41.0) + let panelSize = panel.update( + transition: transition, + component: AnyComponent(ContextResultPanelComponent( + externalState: externalState, + context: component.context, + theme: component.theme, + strings: component.strings, + results: result, + action: { [weak self] action in + if let self, case let .mention(peer) = action, let textView = self.textField.view as? TextFieldComponent.View { + let inputState = textView.getInputState() + + var mentionQueryRange: NSRange? + inner: for (range, type, _) in textInputStateContextQueryRangeAndType(inputState: inputState) { + if type == [.mention] { + mentionQueryRange = range + break inner + } + } + + if let range = mentionQueryRange { + let inputText = NSMutableAttributedString(attributedString: inputState.inputText) + if let addressName = peer.addressName, !addressName.isEmpty { + let replacementText = addressName + " " + inputText.replaceCharacters(in: range, with: replacementText) + + let selectionPosition = range.lowerBound + (replacementText as NSString).length + textView.updateText(inputText, selectionRange: selectionPosition ..< selectionPosition) + } else if !peer.compactDisplayTitle.isEmpty { + let replacementText = NSMutableAttributedString() + replacementText.append(NSAttributedString(string: peer.compactDisplayTitle, attributes: [ChatTextInputAttributes.textMention: ChatTextInputTextMentionAttribute(peerId: peer.id)])) + replacementText.append(NSAttributedString(string: " ")) + + let updatedRange = NSRange(location: range.location - 1, length: range.length + 1) + inputText.replaceCharacters(in: updatedRange, with: replacementText) + + let selectionPosition = updatedRange.lowerBound + replacementText.length + textView.updateText(inputText, selectionRange: selectionPosition ..< selectionPosition) + } + } + } + } + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - panelLeftInset - panelRightInset, height: availablePanelHeight) + ) + + let panelFrame = CGRect(origin: CGPoint(x: insets.left, y: -panelSize.height + 33.0), size: panelSize) + if let panelView = panel.view as? ContextResultPanelComponent.View { + if panelView.superview == nil { + self.insertSubview(panelView, at: 0) + } + transition.setFrame(view: panelView, frame: panelFrame) + + if animateIn { + panelView.animateIn(transition: .spring(duration: 0.4)) + } + } + + } else if let contextQueryResultPanel = self.contextQueryResultPanel?.view as? ContextResultPanelComponent.View { + self.contextQueryResultPanel = nil + contextQueryResultPanel.animateOut(transition: .spring(duration: 0.4), completion: { [weak contextQueryResultPanel] in + contextQueryResultPanel?.removeFromSuperview() + }) + } + return size } } diff --git a/submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD new file mode 100644 index 0000000000..5ac20783c9 --- /dev/null +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/BUILD @@ -0,0 +1,30 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "PeerListItemComponent", + module_name = "PeerListItemComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + "//submodules/TelegramPresentationData", + "//submodules/AsyncDisplayKit", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/AccountContext", + "//submodules/TelegramCore", + "//submodules/Components/MultilineTextComponent", + "//submodules/AvatarNode", + "//submodules/CheckNode", + "//submodules/TelegramStringFormatting", + "//submodules/AppBundle", + ], + visibility = [ + "//visibility:public", + ], +) + diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/PeerListItemComponent.swift b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift similarity index 76% rename from submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/PeerListItemComponent.swift rename to submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift index c657021b8a..b2d08aed27 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/PeerListItemComponent.swift +++ b/submodules/TelegramUI/Components/Stories/PeerListItemComponent/Sources/PeerListItemComponent.swift @@ -16,39 +16,53 @@ import AppBundle private let avatarFont = avatarPlaceholderFont(size: 15.0) private let readIconImage: UIImage? = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/MenuReadIcon"), color: .white)?.withRenderingMode(.alwaysTemplate) -final class PeerListItemComponent: Component { - final class TransitionHint { - let synchronousLoad: Bool +public final class PeerListItemComponent: Component { + public final class TransitionHint { + public let synchronousLoad: Bool - init(synchronousLoad: Bool) { + public init(synchronousLoad: Bool) { self.synchronousLoad = synchronousLoad } } - enum SelectionState: Equatable { + public enum Style { + case generic + case compact + } + + public enum SelectionState: Equatable { case none case editing(isSelected: Bool, isTinted: Bool) } + public enum SubtitleAccessory: Equatable { + case none + case checks + } + let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings + let style: Style let sideInset: CGFloat let title: String let peer: EnginePeer? let subtitle: String? + let subtitleAccessory: SubtitleAccessory let selectionState: SelectionState let hasNext: Bool let action: (EnginePeer) -> Void - init( + public init( context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, + style: Style, sideInset: CGFloat, title: String, peer: EnginePeer?, subtitle: String?, + subtitleAccessory: SubtitleAccessory, selectionState: SelectionState, hasNext: Bool, action: @escaping (EnginePeer) -> Void @@ -56,16 +70,18 @@ final class PeerListItemComponent: Component { self.context = context self.theme = theme self.strings = strings + self.style = style self.sideInset = sideInset self.title = title self.peer = peer self.subtitle = subtitle + self.subtitleAccessory = subtitleAccessory self.selectionState = selectionState self.hasNext = hasNext self.action = action } - static func ==(lhs: PeerListItemComponent, rhs: PeerListItemComponent) -> Bool { + public static func ==(lhs: PeerListItemComponent, rhs: PeerListItemComponent) -> Bool { if lhs.context !== rhs.context { return false } @@ -75,6 +91,9 @@ final class PeerListItemComponent: Component { if lhs.strings !== rhs.strings { return false } + if lhs.style != rhs.style { + return false + } if lhs.sideInset != rhs.sideInset { return false } @@ -87,6 +106,9 @@ final class PeerListItemComponent: Component { if lhs.subtitle != rhs.subtitle { return false } + if lhs.subtitleAccessory != rhs.subtitleAccessory { + return false + } if lhs.selectionState != rhs.selectionState { return false } @@ -96,7 +118,7 @@ final class PeerListItemComponent: Component { return true } - final class View: UIView { + public final class View: UIView { private let containerButton: HighlightTrackingButton private let title = ComponentView() @@ -110,15 +132,15 @@ final class PeerListItemComponent: Component { private var component: PeerListItemComponent? private weak var state: EmptyComponentState? - var avatarFrame: CGRect { + public var avatarFrame: CGRect { return self.avatarNode.frame } - var titleFrame: CGRect? { + public var titleFrame: CGRect? { return self.title.view?.frame } - var labelFrame: CGRect? { + public var labelFrame: CGRect? { guard var value = self.label.view?.frame else { return nil } @@ -186,9 +208,26 @@ final class PeerListItemComponent: Component { let contextInset: CGFloat = 0.0 - let height: CGFloat = 60.0 + let height: CGFloat + let titleFont: UIFont + let subtitleFont: UIFont + switch component.style { + case .generic: + titleFont = Font.semibold(17.0) + subtitleFont = Font.regular(15.0) + height = 60.0 + case .compact: + titleFont = Font.semibold(14.0) + subtitleFont = Font.regular(14.0) + height = 42.0 + } + + let verticalInset: CGFloat = 1.0 - var leftInset: CGFloat = 62.0 + component.sideInset + var leftInset: CGFloat = 53.0 + component.sideInset + if case .generic = component.style { + leftInset += 9.0 + } let rightInset: CGFloat = contextInset * 2.0 + 8.0 + component.sideInset var avatarLeftInset: CGFloat = component.sideInset + 10.0 @@ -230,7 +269,7 @@ final class PeerListItemComponent: Component { } } - let avatarSize: CGFloat = 40.0 + let avatarSize: CGFloat = component.style == .compact ? 30.0 : 40.0 let avatarFrame = CGRect(origin: CGPoint(x: avatarLeftInset, y: floor((height - verticalInset * 2.0 - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize)) if self.avatarNode.bounds.isEmpty { @@ -251,22 +290,14 @@ final class PeerListItemComponent: Component { let labelData: (String, Bool) if let subtitle = component.subtitle { labelData = (subtitle, false) - } else if case .legacyGroup = component.peer { - labelData = (component.strings.Group_Status, false) - } else if case let .channel(channel) = component.peer { - if case .group = channel.info { - labelData = (component.strings.Group_Status, false) - } else { - labelData = (component.strings.Channel_Status, false) - } } else { - labelData = (component.strings.Group_Status, false) + labelData = ("", false) } let labelSize = self.label.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: labelData.0, font: Font.regular(15.0), textColor: labelData.1 ? component.theme.list.itemAccentColor : component.theme.list.itemSecondaryTextColor)) + text: .plain(NSAttributedString(string: labelData.0, font: subtitleFont, textColor: labelData.1 ? component.theme.list.itemAccentColor : component.theme.list.itemSecondaryTextColor)) )), environment: {}, containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0) @@ -281,14 +312,19 @@ final class PeerListItemComponent: Component { let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: component.title, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor)) + text: .plain(NSAttributedString(string: component.title, font: titleFont, textColor: component.theme.list.itemPrimaryTextColor)) )), environment: {}, containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0) ) let titleSpacing: CGFloat = 1.0 - let centralContentHeight: CGFloat = titleSize.height + labelSize.height + titleSpacing + let centralContentHeight: CGFloat + if labelSize.height > 0.0, case .generic = component.style { + centralContentHeight = titleSize.height + labelSize.height + titleSpacing + } else { + centralContentHeight = titleSize.height + } let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: titleSize) if let titleView = self.title.view { @@ -315,26 +351,40 @@ final class PeerListItemComponent: Component { if let labelView = self.label.view { var iconLabelOffset: CGFloat = 0.0 - let iconView: UIImageView - if let current = self.iconView { - iconView = current - } else { - iconView = UIImageView(image: readIconImage) - iconView.tintColor = component.theme.list.itemSecondaryTextColor - self.iconView = iconView - self.containerButton.addSubview(iconView) - } - - if let image = iconView.image { - iconLabelOffset = image.size.width + 4.0 - transition.setFrame(view: iconView, frame: CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY + titleSpacing + 3.0 + floor((labelSize.height - image.size.height) * 0.5)), size: image.size)) + if case .checks = component.subtitleAccessory { + let iconView: UIImageView + if let current = self.iconView { + iconView = current + } else { + iconView = UIImageView(image: readIconImage) + iconView.tintColor = component.theme.list.itemSecondaryTextColor + self.iconView = iconView + self.containerButton.addSubview(iconView) + } + + if let image = iconView.image { + iconLabelOffset = image.size.width + 4.0 + transition.setFrame(view: iconView, frame: CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY + titleSpacing + 3.0 + floor((labelSize.height - image.size.height) * 0.5)), size: image.size)) + } + } else if let iconView = self.iconView { + self.iconView = nil + iconView.removeFromSuperview() } if labelView.superview == nil { labelView.isUserInteractionEnabled = false self.containerButton.addSubview(labelView) } - transition.setFrame(view: labelView, frame: CGRect(origin: CGPoint(x: titleFrame.minX + iconLabelOffset, y: titleFrame.maxY + titleSpacing), size: labelSize)) + + let labelFrame: CGRect + switch component.style { + case .generic: + labelFrame = CGRect(origin: CGPoint(x: titleFrame.minX + iconLabelOffset, y: titleFrame.maxY + titleSpacing), size: labelSize) + case .compact: + labelFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + 4.0, y: floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: labelSize) + } + + transition.setFrame(view: labelView, frame: labelFrame) } if themeUpdated { @@ -350,11 +400,11 @@ final class PeerListItemComponent: Component { } } - func makeView() -> View { + public func makeView() -> View { return View(frame: CGRect()) } - func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD index 928b85104d..6e24a1c4c4 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -17,6 +17,7 @@ swift_library( "//submodules/Components/ComponentDisplayAdapters", "//submodules/TelegramUI/Components/MessageInputPanelComponent", "//submodules/TelegramUI/Components/ChatEntityKeyboardInputNode", + "//submodules/TelegramUI/Components/ChatInputNode", "//submodules/AccountContext", "//submodules/SSignalKit/SwiftSignalKit", "//submodules/AppBundle", @@ -45,6 +46,7 @@ swift_library( "//submodules/TelegramUI/Components/LegacyInstantVideoController", "//submodules/TelegramUI/Components/EntityKeyboard", "//submodules/TelegramUI/Components/Stories/StoryFooterPanelComponent", + "//submodules/TelegramUI/Components/Stories/PeerListItemComponent", "//submodules/TelegramUI/Components/ShareWithPeersScreen", "//submodules/TelegramUI/Components/MediaEditorScreen", "//submodules/TelegramUI/Components/PlainButtonComponent", diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index ba4b1f1230..b0d69e55c0 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -241,6 +241,7 @@ public final class StoryItemSetContainerComponent: Component { var captionItem: CaptionItem? + let inputBackground = ComponentView() let inputPanel = ComponentView() let footerPanel = ComponentView() let inputPanelExternalState = MessageInputPanelComponent.ExternalState() @@ -1013,6 +1014,13 @@ public final class StoryItemSetContainerComponent: Component { } } + let nextInputMode: MessageInputPanelComponent.InputMode + if self.inputPanelExternalState.hasText { + nextInputMode = .emoji + } else { + nextInputMode = .stickers + } + self.inputPanel.parentState = state let inputPanelSize = self.inputPanel.update( transition: inputPanelTransition, @@ -1024,6 +1032,7 @@ public final class StoryItemSetContainerComponent: Component { style: .story, placeholder: "Reply Privately...", alwaysDarkWhenHasText: component.metrics.widthClass == .regular, + nextInputMode: nextInputMode, areVoiceMessagesAvailable: component.slice.additionalPeerData.areVoiceMessagesAvailable, presentController: { [weak self] c in guard let self, let component = self.component else { @@ -1068,6 +1077,13 @@ public final class StoryItemSetContainerComponent: Component { } self.sendMessageContext.presentAttachmentMenu(view: self, subject: .default) }, + inputModeAction: { [weak self] in + guard let self else { + return + } + self.sendMessageContext.toggleInputMode() + self.state?.updated(transition: Transition(animation: .curve(duration: 0.5, curve: .spring))) + }, timeoutAction: nil, forwardAction: component.slice.item.storyItem.isPublic ? { [weak self] in guard let self else { @@ -1106,7 +1122,8 @@ public final class StoryItemSetContainerComponent: Component { timeoutValue: nil, timeoutSelected: false, displayGradient: component.inputHeight != 0.0 && component.metrics.widthClass != .regular, - bottomInset: component.inputHeight != 0.0 ? 0.0 : bottomContentInset + bottomInset: component.inputHeight != 0.0 ? 0.0 : bottomContentInset, + hideKeyboard: false )), environment: {}, containerSize: CGSize(width: inputPanelAvailableWidth, height: 200.0) diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift index 04039aa915..cf2b93a188 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift @@ -34,9 +34,17 @@ import ChatPresentationInterfaceState import Postbox final class StoryItemSetContainerSendMessage { + enum InputMode { + case text + case emoji + case sticker + } + weak var attachmentController: AttachmentController? weak var shareController: ShareController? + var inputMode: InputMode = .text + var audioRecorderValue: ManagedAudioRecorder? var audioRecorder = Promise() var recordedAudioPreview: ChatRecordedMediaPreview? @@ -55,6 +63,9 @@ final class StoryItemSetContainerSendMessage { self.enqueueMediaMessageDisposable.dispose() } + func toggleInputMode() { + } + func performSendMessageAction( view: StoryItemSetContainerComponent.View ) { diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift index 0db897e77a..1d9ce0595b 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetViewListComponent.swift @@ -11,6 +11,7 @@ import SwiftSignalKit import TelegramStringFormatting import ShimmerEffect import StoryFooterPanelComponent +import PeerListItemComponent final class StoryItemSetViewListComponent: Component { final class ExternalState { @@ -429,10 +430,12 @@ final class StoryItemSetViewListComponent: Component { context: component.context, theme: component.theme, strings: component.strings, + style: .generic, sideInset: itemLayout.sideInset, title: item.peer.displayTitle(strings: component.strings, displayOrder: .firstLast), peer: item.peer, subtitle: dateText, + subtitleAccessory: .checks, selectionState: .none, hasNext: index != viewListState.totalCount - 1, action: { [weak self] peer in @@ -627,10 +630,12 @@ final class StoryItemSetViewListComponent: Component { context: component.context, theme: component.theme, strings: component.strings, + style: .generic, sideInset: sideInset, title: "AAAAAAAAAAAA", peer: nil, subtitle: "BBBBBBB", + subtitleAccessory: .checks, selectionState: .none, hasNext: true, action: { _ in diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift index 3872f92bd5..912f5888d8 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift @@ -99,7 +99,8 @@ public final class StoryContentContextImpl: StoryContentContext { } let additionalPeerData: StoryContentContextState.AdditionalPeerData if let cachedPeerDataView = views.views[PostboxViewKey.cachedPeerData(peerId: peerId)] as? CachedPeerDataView, let cachedUserData = cachedPeerDataView.cachedPeerData as? CachedUserData { - additionalPeerData = StoryContentContextState.AdditionalPeerData(areVoiceMessagesAvailable: cachedUserData.voiceMessagesAvailable) + let _ = cachedUserData + additionalPeerData = StoryContentContextState.AdditionalPeerData(areVoiceMessagesAvailable: false) //cachedUserData.voiceMessagesAvailable) } else { additionalPeerData = StoryContentContextState.AdditionalPeerData(areVoiceMessagesAvailable: true) } diff --git a/submodules/TelegramUI/Components/TextFieldComponent/BUILD b/submodules/TelegramUI/Components/TextFieldComponent/BUILD index 0ddc577f52..b41c5ef8e3 100644 --- a/submodules/TelegramUI/Components/TextFieldComponent/BUILD +++ b/submodules/TelegramUI/Components/TextFieldComponent/BUILD @@ -14,6 +14,10 @@ swift_library( "//submodules/ComponentFlow", "//submodules/TextFormat", "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/InvisibleInkDustNode", + "//submodules/TelegramUI/Components/EmojiTextAttachmentView", + "//submodules/ChatTextLinkEditUI" ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift index 9f8c4fdeee..e2797b9590 100644 --- a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift +++ b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift @@ -4,6 +4,17 @@ import Display import ComponentFlow import TextFormat import TelegramPresentationData +import InvisibleInkDustNode +import EmojiTextAttachmentView +import AccountContext +import TextFormat +import ChatTextLinkEditUI + +public final class EmptyInputView: UIView, UIInputViewAudioFeedback { + public var enableInputClicksWhenVisible: Bool { + return true + } +} public final class TextFieldComponent: Component { public final class ExternalState { @@ -27,18 +38,33 @@ public final class TextFieldComponent: Component { } } + public let context: AccountContext public let strings: PresentationStrings public let externalState: ExternalState - public let placeholder: String + public let fontSize: CGFloat + public let textColor: UIColor + public let insets: UIEdgeInsets + public let hideKeyboard: Bool + public let present: (ViewController) -> Void public init( + context: AccountContext, strings: PresentationStrings, externalState: ExternalState, - placeholder: String + fontSize: CGFloat, + textColor: UIColor, + insets: UIEdgeInsets, + hideKeyboard: Bool, + present: @escaping (ViewController) -> Void ) { + self.context = context self.strings = strings self.externalState = externalState - self.placeholder = placeholder + self.fontSize = fontSize + self.textColor = textColor + self.insets = insets + self.hideKeyboard = hideKeyboard + self.present = present } public static func ==(lhs: TextFieldComponent, rhs: TextFieldComponent) -> Bool { @@ -48,7 +74,16 @@ public final class TextFieldComponent: Component { if lhs.externalState !== rhs.externalState { return false } - if lhs.placeholder != rhs.placeholder { + if lhs.fontSize != rhs.fontSize { + return false + } + if lhs.textColor != rhs.textColor { + return false + } + if lhs.insets != rhs.insets { + return false + } + if lhs.hideKeyboard != rhs.hideKeyboard { return false } return true @@ -60,14 +95,21 @@ public final class TextFieldComponent: Component { } public final class View: UIView, UITextViewDelegate, UIScrollViewDelegate { - private let placeholder = ComponentView() - private let textContainer: NSTextContainer private let textStorage: NSTextStorage private let layoutManager: NSLayoutManager private let textView: UITextView - private var inputState = InputState(inputText: NSAttributedString(), selectionRange: 0 ..< 0) + private var spoilerView: InvisibleInkDustView? + private var customEmojiContainerView: CustomEmojiContainerView? + private var emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)? + + //private var inputState = InputState(inputText: NSAttributedString(), selectionRange: 0 ..< 0) + + private var inputState: InputState { + let selectionRange: Range = self.textView.selectedRange.location ..< (self.textView.selectedRange.location + self.textView.selectedRange.length) + return InputState(inputText: stateAttributedStringForText(self.textView.attributedText ?? NSAttributedString()), selectionRange: selectionRange) + } private var component: TextFieldComponent? private weak var state: EmptyComponentState? @@ -88,7 +130,6 @@ public final class TextFieldComponent: Component { self.textView = UITextView(frame: CGRect(), textContainer: self.textContainer) self.textView.translatesAutoresizingMaskIntoConstraints = false - self.textView.textContainerInset = UIEdgeInsets(top: 9.0, left: 8.0, bottom: 10.0, right: 8.0) self.textView.backgroundColor = nil self.textView.layer.isOpaque = false self.textView.keyboardAppearance = .dark @@ -115,58 +156,116 @@ public final class TextFieldComponent: Component { fatalError("init(coder:) has not been implemented") } - public func textViewDidChange(_ textView: UITextView) { + private func updateInputState(_ f: (InputState) -> InputState) { + guard let component = self.component else { + return + } + + let inputState = f(self.inputState) + + self.textView.attributedText = textAttributedStringForStateText(inputState.inputText, fontSize: component.fontSize, textColor: component.textColor, accentTextColor: component.textColor, writingDirection: nil, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(component.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) + self.textView.selectedRange = NSMakeRange(inputState.selectionRange.lowerBound, inputState.selectionRange.count) + + refreshChatTextInputAttributes(textView: self.textView, primaryTextColor: component.textColor, accentTextColor: component.textColor, baseFontSize: component.fontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(component.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) + + self.updateEntities() + } + + public func insertText(_ text: NSAttributedString) { + self.updateInputState { state in + return state.insertText(text) + } self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) } + public func deleteBackward() { + self.textView.deleteBackward() + } + + public func updateText(_ text: NSAttributedString, selectionRange: Range) { + self.updateInputState { _ in + return TextFieldComponent.InputState(inputText: text, selectionRange: selectionRange) + } + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + } + + public func textViewDidChange(_ textView: UITextView) { + guard let component = self.component else { + return + } + refreshChatTextInputAttributes(textView: self.textView, primaryTextColor: component.textColor, accentTextColor: component.textColor, baseFontSize: component.fontSize, spoilersRevealed: self.spoilersRevealed, availableEmojis: Set(component.context.animatedEmojiStickers.keys), emojiViewProvider: self.emojiViewProvider) + refreshChatTextInputTypingAttributes(self.textView, textColor: component.textColor, baseFontSize: component.fontSize) + + self.updateEntities() + + self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) + } + + public func textViewDidChangeSelection(_ textView: UITextView) { + guard let _ = self.component else { + return + } + } + public func textViewDidBeginEditing(_ textView: UITextView) { - self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textFocusChanged))) + self.state?.updated(transition: Transition(animation: .curve(duration: 0.5, curve: .spring)).withUserData(AnimationHint(kind: .textFocusChanged))) } public func textViewDidEndEditing(_ textView: UITextView) { - self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textFocusChanged))) + self.state?.updated(transition: Transition(animation: .curve(duration: 0.5, curve: .spring)).withUserData(AnimationHint(kind: .textFocusChanged))) } @available(iOS 16.0, *) public func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + let filteredActions: Set = Set([ + "com.apple.menu.format", + "com.apple.menu.replace" + ]) + let suggestedActions = suggestedActions.filter { + if let action = $0 as? UIMenu, filteredActions.contains(action.identifier.rawValue) { + return false + } else { + return true + } + } guard let component = self.component, !textView.attributedText.string.isEmpty && textView.selectedRange.length > 0 else { return UIMenu(children: suggestedActions) } - + let strings = component.strings var actions: [UIAction] = [ - UIAction(title: strings.TextFormat_Bold, image: nil) { [weak self] (action) in + UIAction(title: strings.TextFormat_Bold, image: nil) { [weak self] action in if let self { self.toggleAttribute(key: ChatTextInputAttributes.bold) } }, - UIAction(title: strings.TextFormat_Italic, image: nil) { [weak self] (action) in + UIAction(title: strings.TextFormat_Italic, image: nil) { [weak self] action in if let self { self.toggleAttribute(key: ChatTextInputAttributes.italic) } }, - UIAction(title: strings.TextFormat_Monospace, image: nil) { [weak self] (action) in + UIAction(title: strings.TextFormat_Monospace, image: nil) { [weak self] action in if let self { self.toggleAttribute(key: ChatTextInputAttributes.monospace) } }, - UIAction(title: strings.TextFormat_Link, image: nil) { [weak self] (action) in + UIAction(title: strings.TextFormat_Link, image: nil) { [weak self] action in if let self { - let _ = self + self.openLinkEditing() } }, - UIAction(title: strings.TextFormat_Strikethrough, image: nil) { [weak self] (action) in + UIAction(title: strings.TextFormat_Strikethrough, image: nil) { [weak self] action in if let self { self.toggleAttribute(key: ChatTextInputAttributes.strikethrough) } }, - UIAction(title: strings.TextFormat_Underline, image: nil) { [weak self] (action) in + UIAction(title: strings.TextFormat_Underline, image: nil) { [weak self] action in if let self { self.toggleAttribute(key: ChatTextInputAttributes.underline) } } ] - actions.append(UIAction(title: strings.TextFormat_Spoiler, image: nil) { [weak self] (action) in + actions.append(UIAction(title: strings.TextFormat_Spoiler, image: nil) { [weak self] action in if let self { self.toggleAttribute(key: ChatTextInputAttributes.spoiler) } @@ -174,27 +273,64 @@ public final class TextFieldComponent: Component { var updatedActions = suggestedActions let formatMenu = UIMenu(title: strings.TextFormat_Format, image: nil, children: actions) - updatedActions.insert(formatMenu, at: 3) + updatedActions.insert(formatMenu, at: 1) return UIMenu(children: updatedActions) } private func toggleAttribute(key: NSAttributedString.Key) { + self.updateInputState { state in + return state.addFormattingAttribute(attribute: key) + } + } + + private func openLinkEditing() { + guard let component = self.component else { + return + } + let selectionRange = self.inputState.selectionRange + let text = self.inputState.inputText.attributedSubstring(from: NSRange(location: selectionRange.startIndex, length: selectionRange.count)) + var link: String? + text.enumerateAttributes(in: NSMakeRange(0, text.length)) { attributes, _, _ in + if let linkAttribute = attributes[ChatTextInputAttributes.textUrl] as? ChatTextInputTextUrlAttribute { + link = linkAttribute.url + } + } + let controller = chatTextLinkEditController(sharedContext: component.context.sharedContext, account: component.context.account, text: text.string, link: link, apply: { [weak self] link in + if let self { + if let link = link { + self.updateInputState { state in + return state.addLinkAttribute(selectionRange: selectionRange, url: link) + } + } +// strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { +// return $0.updatedInputMode({ _ in return inputMode }).updatedInterfaceState({ +// $0.withUpdatedEffectiveInputState(ChatTextInputState(inputText: $0.effectiveInputState.inputText, selectionRange: selectionRange.endIndex ..< selectionRange.endIndex)) +// }) +// }) + } + }) + component.present(controller) } public func scrollViewDidScroll(_ scrollView: UIScrollView) { //print("didScroll \(scrollView.bounds)") } + public func getInputState() -> TextFieldComponent.InputState { + return self.inputState + } + public func getAttributedText() -> NSAttributedString { Keyboard.applyAutocorrection(textView: self.textView) - return NSAttributedString(string: self.textView.text ?? "") - //return self.inputState.inputText + return self.inputState.inputText } public func setAttributedText(_ string: NSAttributedString) { - self.textView.text = string.string + self.updateInputState { _ in + return TextFieldComponent.InputState(inputText: string, selectionRange: string.length ..< string.length) + } self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(AnimationHint(kind: .textChanged))) } @@ -202,90 +338,167 @@ public final class TextFieldComponent: Component { self.textView.becomeFirstResponder() } + var spoilersRevealed = false + func updateEntities() { -// var spoilerRects: [CGRect] = [] -// var customEmojiRects: [CGRect: ChatTextInputTextCustomEmojiAttribute] = [] -// -// if !spoilerRects.isEmpty { -// let dustNode: InvisibleInkDustNode -// if let current = self.dustNode { -// dustNode = current -// } else { -// dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: self.context?.sharedContext.energyUsageSettings.fullTranslucency ?? true) -// dustNode.alpha = self.spoilersRevealed ? 0.0 : 1.0 -// dustNode.isUserInteractionEnabled = false -// textInputNode.textView.addSubview(dustNode.view) -// self.dustNode = dustNode -// } -// dustNode.frame = CGRect(origin: CGPoint(), size: textInputNode.textView.contentSize) -// dustNode.update(size: textInputNode.textView.contentSize, color: textColor, textColor: textColor, rects: rects, wordRects: rects) -// } else if let dustNode = self.dustNode { -// 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(fontSize: fontSize, textColor: textColor, emojiRects: customEmojiRects) -// } else if let customEmojiContainerView = self.customEmojiContainerView { -// customEmojiContainerView.removeFromSuperview() -// self.customEmojiContainerView = nil -// } + guard let component = self.component else { + return + } + + var spoilerRects: [CGRect] = [] + var customEmojiRects: [(CGRect, ChatTextInputTextCustomEmojiAttribute)] = [] + + let textView = self.textView + if let attributedText = textView.attributedText { + let beginning = textView.beginningOfDocument + attributedText.enumerateAttributes(in: NSMakeRange(0, attributedText.length), options: [], using: { attributes, range, _ in + if let _ = attributes[ChatTextInputAttributes.spoiler] { + func addSpoiler(startIndex: Int, endIndex: Int) { + if let start = textView.position(from: beginning, offset: startIndex), let end = textView.position(from: start, offset: endIndex - startIndex), let textRange = textView.textRange(from: start, to: end) { + let textRects = textView.selectionRects(for: textRange) + for textRect in textRects { + if textRect.rect.width > 1.0 && textRect.rect.size.height > 1.0 { + spoilerRects.append(textRect.rect.insetBy(dx: 1.0, dy: 1.0).offsetBy(dx: 0.0, dy: 1.0)) + } + } + } + } + + var startIndex: Int? + var currentIndex: Int? + + let nsString = (attributedText.string as NSString) + nsString.enumerateSubstrings(in: range, options: .byComposedCharacterSequences) { substring, range, _, _ in + if let substring = substring, substring.rangeOfCharacter(from: .whitespacesAndNewlines) != nil { + if let currentStartIndex = startIndex { + startIndex = nil + let endIndex = range.location + addSpoiler(startIndex: currentStartIndex, endIndex: endIndex) + } + } else if startIndex == nil { + startIndex = range.location + } + currentIndex = range.location + range.length + } + + if let currentStartIndex = startIndex, let currentIndex = currentIndex { + startIndex = nil + let endIndex = currentIndex + addSpoiler(startIndex: currentStartIndex, endIndex: endIndex) + } + } + + if let value = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute { + if let start = textView.position(from: beginning, offset: range.location), let end = textView.position(from: start, offset: range.length), let textRange = textView.textRange(from: start, to: end) { + let textRects = textView.selectionRects(for: textRange) + for textRect in textRects { + customEmojiRects.append((textRect.rect, value)) + break + } + } + } + }) + } + + if !spoilerRects.isEmpty { + let spoilerView: InvisibleInkDustView + if let current = self.spoilerView { + spoilerView = current + } else { + spoilerView = InvisibleInkDustView(textNode: nil, enableAnimations: component.context.sharedContext.energyUsageSettings.fullTranslucency) + spoilerView.alpha = self.spoilersRevealed ? 0.0 : 1.0 + spoilerView.isUserInteractionEnabled = false + self.textView.addSubview(spoilerView) + self.spoilerView = spoilerView + } + spoilerView.frame = CGRect(origin: CGPoint(), size: self.textView.contentSize) + spoilerView.update(size: self.textView.contentSize, color: component.textColor, textColor: component.textColor, rects: spoilerRects, wordRects: spoilerRects) + } else if let spoilerView = self.spoilerView { + spoilerView.removeFromSuperview() + self.spoilerView = 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 + self.textView.addSubview(customEmojiContainerView) + self.customEmojiContainerView = customEmojiContainerView + } + + customEmojiContainerView.update(fontSize: component.fontSize, textColor: component.textColor, emojiRects: customEmojiRects) + } else if let customEmojiContainerView = self.customEmojiContainerView { + customEmojiContainerView.removeFromSuperview() + self.customEmojiContainerView = nil + } } func update(component: TextFieldComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state + if self.emojiViewProvider == nil { + self.emojiViewProvider = { [weak self] emoji in + guard let component = self?.component else { + return UIView() + } + let pointSize = floor(24.0 * 1.3) + return EmojiTextAttachmentView(context: component.context, userLocation: .other, emoji: emoji, file: emoji.file, cache: component.context.animationCache, renderer: component.context.animationRenderer, placeholderColor: UIColor.white.withAlphaComponent(0.12), pointSize: CGSize(width: pointSize, height: pointSize)) + } + } + + if self.textView.textContainerInset != component.insets { + self.textView.textContainerInset = component.insets + } self.textContainer.size = CGSize(width: availableSize.width - self.textView.textContainerInset.left - self.textView.textContainerInset.right, height: 10000000.0) self.layoutManager.ensureLayout(for: self.textContainer) let boundingRect = self.layoutManager.boundingRect(forGlyphRange: NSRange(location: 0, length: self.textStorage.length), in: self.textContainer) - let size = CGSize(width: availableSize.width, height: min(200.0, ceil(boundingRect.height) + self.textView.textContainerInset.top + self.textView.textContainerInset.bottom)) + let size = CGSize(width: availableSize.width, height: min(availableSize.height, ceil(boundingRect.height) + self.textView.textContainerInset.top + self.textView.textContainerInset.bottom)) + + let wasEditing = component.externalState.isEditing + let isEditing = self.textView.isFirstResponder let refreshScrolling = self.textView.bounds.size != size self.textView.frame = CGRect(origin: CGPoint(), size: size) + self.textView.panGestureRecognizer.isEnabled = isEditing if refreshScrolling { - self.textView.setContentOffset(CGPoint(x: 0.0, y: max(0.0, self.textView.contentSize.height - self.textView.bounds.height)), animated: false) - } - - let placeholderSize = self.placeholder.update( - transition: .immediate, - component: AnyComponent(Text(text: component.placeholder, font: Font.regular(17.0), color: UIColor(white: 1.0, alpha: 0.25))), - environment: {}, - containerSize: availableSize - ) - if let placeholderView = self.placeholder.view { - if placeholderView.superview == nil { - placeholderView.layer.anchorPoint = CGPoint() - placeholderView.isUserInteractionEnabled = false - self.insertSubview(placeholderView, belowSubview: self.textView) + if isEditing { + if wasEditing { + self.textView.setContentOffset(CGPoint(x: 0.0, y: max(0.0, self.textView.contentSize.height - self.textView.bounds.height)), animated: false) + } + } else { + self.textView.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: true) } - - let placeholderFrame = CGRect(origin: CGPoint(x: self.textView.textContainerInset.left + 5.0, y: self.textView.textContainerInset.top), size: placeholderSize) - placeholderView.bounds = CGRect(origin: CGPoint(), size: placeholderFrame.size) - transition.setPosition(view: placeholderView, position: placeholderFrame.origin) - - placeholderView.isHidden = self.textStorage.length != 0 } component.externalState.hasText = self.textStorage.length != 0 - component.externalState.isEditing = self.textView.isFirstResponder + component.externalState.isEditing = isEditing + + if component.hideKeyboard { + if self.textView.inputView == nil { + self.textView.inputView = EmptyInputView() + if self.textView.isFirstResponder { + self.textView.reloadInputViews() + } + } + } else { + if self.textView.inputView != nil { + self.textView.inputView = nil + if self.textView.isFirstResponder { + self.textView.reloadInputViews() + } + } + } return size } @@ -299,3 +512,89 @@ public final class TextFieldComponent: Component { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } + +extension TextFieldComponent.InputState { + public func insertText(_ text: NSAttributedString) -> TextFieldComponent.InputState { + let inputText = NSMutableAttributedString(attributedString: self.inputText) + let range = self.selectionRange + + inputText.replaceCharacters(in: NSMakeRange(range.lowerBound, range.count), with: text) + + let selectionPosition = range.lowerBound + (text.string as NSString).length + return TextFieldComponent.InputState(inputText: inputText, selectionRange: selectionPosition ..< selectionPosition) + } + + public func addFormattingAttribute(attribute: NSAttributedString.Key) -> TextFieldComponent.InputState { + if !self.selectionRange.isEmpty { + let nsRange = NSRange(location: self.selectionRange.lowerBound, length: self.selectionRange.count) + var addAttribute = true + var attributesToRemove: [NSAttributedString.Key] = [] + self.inputText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, stop in + for (key, _) in attributes { + if key == attribute && range == nsRange { + addAttribute = false + attributesToRemove.append(key) + } + } + } + + let result = NSMutableAttributedString(attributedString: self.inputText) + for attribute in attributesToRemove { + result.removeAttribute(attribute, range: nsRange) + } + if addAttribute { + result.addAttribute(attribute, value: true as Bool, range: nsRange) + } + return TextFieldComponent.InputState(inputText: result, selectionRange: self.selectionRange) + } else { + return self + } + } + + public func clearFormattingAttributes() -> TextFieldComponent.InputState { + if !self.selectionRange.isEmpty { + let nsRange = NSRange(location: self.selectionRange.lowerBound, length: self.selectionRange.count) + var attributesToRemove: [NSAttributedString.Key] = [] + self.inputText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, stop in + for (key, _) in attributes { + attributesToRemove.append(key) + } + } + + let result = NSMutableAttributedString(attributedString: self.inputText) + for attribute in attributesToRemove { + result.removeAttribute(attribute, range: nsRange) + } + return TextFieldComponent.InputState(inputText: result, selectionRange: self.selectionRange) + } else { + return self + } + } + + public func addLinkAttribute(selectionRange: Range, url: String) -> TextFieldComponent.InputState { + if !selectionRange.isEmpty { + let nsRange = NSRange(location: selectionRange.lowerBound, length: selectionRange.count) + var linkRange = nsRange + var attributesToRemove: [(NSAttributedString.Key, NSRange)] = [] + self.inputText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, stop in + for (key, _) in attributes { + if key == ChatTextInputAttributes.textUrl { + attributesToRemove.append((key, range)) + linkRange = linkRange.union(range) + } else { + attributesToRemove.append((key, nsRange)) + } + } + } + + let result = NSMutableAttributedString(attributedString: self.inputText) + for (attribute, range) in attributesToRemove { + result.removeAttribute(attribute, range: range) + } + result.addAttribute(ChatTextInputAttributes.textUrl, value: ChatTextInputTextUrlAttribute(url: url), range: nsRange) + return TextFieldComponent.InputState(inputText: result, selectionRange: selectionRange) + } else { + return self + } + } +} diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index c29df3b8e3..19f410a3f1 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -98,6 +98,7 @@ import StoryContainerScreen import StoryContentComponent import MoreHeaderButton import VolumeButtons +import ChatContextQuery #if DEBUG import os.signpost @@ -18572,7 +18573,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G text = strongSelf.presentationData.strings.Conversation_AutoremoveOff } if let text = text { - strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text), elevatedLayout: false, action: { _ in return false }), in: .current) + strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .current) } }) }) diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index f3270ec72e..0ad798447e 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -2282,13 +2282,7 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { return false } } - - private final class EmptyInputView: UIView, UIInputViewAudioFeedback { - var enableInputClicksWhenVisible: Bool { - return true - } - } - + private let emptyInputView = EmptyInputView() private func chatPresentationInterfaceStateInputView(_ state: ChatPresentationInterfaceState) -> UIView? { switch state.inputMode { @@ -2634,13 +2628,16 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { peerId = id } + guard let interfaceInteraction = self.interfaceInteraction else { + return nil + } + let inputNode = ChatEntityKeyboardInputNode( context: self.context, currentInputData: inputMediaNodeData, updatedInputData: self.inputMediaNodeDataPromise.get(), defaultToEmojiTab: !self.chatPresentationInterfaceState.interfaceState.effectiveInputState.inputText.string.isEmpty || self.chatPresentationInterfaceState.interfaceState.forwardMessageIds != nil || self.openStickersBeginWithEmoji, - controllerInteraction: self.controllerInteraction, - interfaceInteraction: self.interfaceInteraction, + interaction: ChatEntityKeyboardInputNode.Interaction(chatControllerInteraction: self.controllerInteraction, panelInteraction: interfaceInteraction), chatPeerId: peerId, stateContext: self.inputMediaNodeStateContext ) @@ -2650,12 +2647,23 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { } func loadInputPanels(theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize) { - if !self.didInitializeInputMediaNodeDataPromise, let interfaceInteraction = self.interfaceInteraction { + if !self.didInitializeInputMediaNodeDataPromise { self.didInitializeInputMediaNodeDataPromise = true - - let areCustomEmojiEnabled = self.chatPresentationInterfaceState.customEmojiAvailable - - self.inputMediaNodeDataPromise.set(ChatEntityKeyboardInputNode.inputData(context: self.context, interfaceInteraction: interfaceInteraction, controllerInteraction: self.controllerInteraction, chatPeerId: self.chatLocation.peerId, areCustomEmojiEnabled: areCustomEmojiEnabled)) + + self.inputMediaNodeDataPromise.set( + ChatEntityKeyboardInputNode.inputData( + context: self.context, + chatPeerId: self.chatLocation.peerId, + areCustomEmojiEnabled: self.chatPresentationInterfaceState.customEmojiAvailable, + sendGif: { [weak self] fileReference, sourceView, sourceRect, silentPosting, schedule in + if let self { + return self.controllerInteraction.sendGif(fileReference, sourceView, sourceRect, silentPosting, schedule) + } else { + return false + } + } + ) + ) } self.textInputPanelNode?.loadTextInputNodeIfNeeded() diff --git a/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift b/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift index a0588bc94f..b662dad3c7 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceInputContexts.swift @@ -9,182 +9,7 @@ import ChatInterfaceState import ChatPresentationInterfaceState import SwiftSignalKit import TextFormat - -struct PossibleContextQueryTypes: OptionSet { - var rawValue: Int32 - - init() { - self.rawValue = 0 - } - - init(rawValue: Int32) { - self.rawValue = rawValue - } - - static let emoji = PossibleContextQueryTypes(rawValue: (1 << 0)) - static let hashtag = PossibleContextQueryTypes(rawValue: (1 << 1)) - static let mention = PossibleContextQueryTypes(rawValue: (1 << 2)) - static let command = PossibleContextQueryTypes(rawValue: (1 << 3)) - static let contextRequest = PossibleContextQueryTypes(rawValue: (1 << 4)) - static let emojiSearch = PossibleContextQueryTypes(rawValue: (1 << 5)) -} - -private func makeScalar(_ c: Character) -> Character { - return c -} - -private let spaceScalar = " " as UnicodeScalar -private let newlineScalar = "\n" as UnicodeScalar -private let hashScalar = "#" as UnicodeScalar -private let atScalar = "@" as UnicodeScalar -private let slashScalar = "/" as UnicodeScalar -private let colonScalar = ":" as UnicodeScalar -private let alphanumerics = CharacterSet.alphanumerics - -private func scalarCanPrependQueryControl(_ c: UnicodeScalar?) -> Bool { - if let c = c { - if c == " " || c == "\n" || c == "." || c == "," { - return true - } - return false - } else { - return true - } -} - -func textInputStateContextQueryRangeAndType(_ inputState: ChatTextInputState) -> [(NSRange, PossibleContextQueryTypes, NSRange?)] { - if inputState.selectionRange.count != 0 { - return [] - } - - let inputText = inputState.inputText - let inputString: NSString = inputText.string as NSString - var results: [(NSRange, PossibleContextQueryTypes, NSRange?)] = [] - let inputLength = inputString.length - - if inputLength != 0 { - if inputString.hasPrefix("@") && inputLength != 1 { - let startIndex = 1 - var index = startIndex - var contextAddressRange: NSRange? - - while true { - if index == inputLength { - break - } - if let c = UnicodeScalar(inputString.character(at: index)) { - if c == " " { - if index != startIndex { - contextAddressRange = NSRange(location: startIndex, length: index - startIndex) - index += 1 - } - break - } else { - if !((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_") { - break - } - } - - if index == inputLength { - break - } else { - index += 1 - } - } else { - index += 1 - } - } - - if let contextAddressRange = contextAddressRange { - results.append((contextAddressRange, [.contextRequest], NSRange(location: index, length: inputLength - index))) - } - } - - let maxIndex = min(inputState.selectionRange.lowerBound, inputLength) - if maxIndex == 0 { - return results - } - var index = maxIndex - 1 - - var possibleQueryRange: NSRange? - - let string = (inputString as String) - let trimmedString = string.trimmingTrailingSpaces() - if string.count < 3, trimmedString.isSingleEmoji { - if inputText.attribute(ChatTextInputAttributes.customEmoji, at: 0, effectiveRange: nil) == nil { - return [(NSRange(location: 0, length: inputString.length - (string.count - trimmedString.count)), [.emoji], nil)] - } - } else { - /*let activeString = inputText.attributedSubstring(from: NSRange(location: 0, length: inputState.selectionRange.upperBound)) - if let lastCharacter = activeString.string.last, String(lastCharacter).isSingleEmoji { - let matchLength = (String(lastCharacter) as NSString).length - - if activeString.attribute(ChatTextInputAttributes.customEmoji, at: activeString.length - matchLength, effectiveRange: nil) == nil { - return [(NSRange(location: inputState.selectionRange.upperBound - matchLength, length: matchLength), [.emojiSearch], nil)] - } - }*/ - } - - var possibleTypes = PossibleContextQueryTypes([.command, .mention, .hashtag, .emojiSearch]) - var definedType = false - - while true { - var previousC: UnicodeScalar? - if index != 0 { - previousC = UnicodeScalar(inputString.character(at: index - 1)) - } - if let c = UnicodeScalar(inputString.character(at: index)) { - if c == spaceScalar || c == newlineScalar { - possibleTypes = [] - } else if c == hashScalar { - if scalarCanPrependQueryControl(previousC) { - possibleTypes = possibleTypes.intersection([.hashtag]) - definedType = true - index += 1 - possibleQueryRange = NSRange(location: index, length: maxIndex - index) - } - break - } else if c == atScalar { - if scalarCanPrependQueryControl(previousC) { - possibleTypes = possibleTypes.intersection([.mention]) - definedType = true - index += 1 - possibleQueryRange = NSRange(location: index, length: maxIndex - index) - } - break - } else if c == slashScalar { - if scalarCanPrependQueryControl(previousC) { - possibleTypes = possibleTypes.intersection([.command]) - definedType = true - index += 1 - possibleQueryRange = NSRange(location: index, length: maxIndex - index) - } - break - } else if c == colonScalar { - if scalarCanPrependQueryControl(previousC) { - possibleTypes = possibleTypes.intersection([.emojiSearch]) - definedType = true - index += 1 - possibleQueryRange = NSRange(location: index, length: maxIndex - index) - } - break - } - } - - if index == 0 { - break - } else { - index -= 1 - possibleQueryRange = NSRange(location: index, length: maxIndex - index) - } - } - - if let possibleQueryRange = possibleQueryRange, definedType && !possibleTypes.isEmpty { - results.append((possibleQueryRange, possibleTypes, nil)) - } - } - return results -} +import ChatContextQuery func serviceTasksForChatPresentationIntefaceState(context: AccountContext, chatPresentationInterfaceState: ChatPresentationInterfaceState, updateState: @escaping ((ChatPresentationInterfaceState) -> ChatPresentationInterfaceState) -> Void) -> [AnyHashable: () -> Disposable] { var missingEmoji = Set() diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift index 8aa2b1dab7..0168ad77c0 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextQueries.swift @@ -12,16 +12,7 @@ import SearchPeerMembers import DeviceLocationManager import TelegramNotices import ChatPresentationInterfaceState - -enum ChatContextQueryError { - case generic - case inlineBotLocationRequest(PeerId) -} - -enum ChatContextQueryUpdate { - case remove - case update(ChatPresentationInputQuery, Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError>) -} +import ChatContextQuery func contextQueryResultStateForChatInterfacePresentationState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, currentQueryStates: inout [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)], requestBotLocationStatus: @escaping (PeerId) -> Void) -> [ChatPresentationInputQueryKind: ChatContextQueryUpdate] { guard let peer = chatPresentationInterfaceState.renderedPeer?.peer else { @@ -331,16 +322,16 @@ private func updatedContextQueryResultStateForQuery(context: AccountContext, pee return signal |> then(contextBot) case let .emojiSearch(query, languageCode, range): - if query.isSingleEmoji { - 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 + 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 } - |> distinctUntilChanged - + return user.isPremium + } + |> distinctUntilChanged + + if query.isSingleEmoji { return combineLatest( context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000), hasPremium @@ -385,15 +376,6 @@ 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 |> castError(ChatContextQueryError.self) diff --git a/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift index ddfa0fa079..97b7d61f3c 100644 --- a/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift @@ -1091,7 +1091,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { text = strongSelf.presentationData.strings.Conversation_AutoremoveOff } if let text = text { - strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text), elevatedLayout: false, action: { _ in return false }), in: .current) + strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .current) } }) }) diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index 70d9ca48ba..a4578a6bb6 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -37,6 +37,7 @@ import LottieComponent import SolidRoundedButtonNode import TooltipUI import ChatTextInputMediaRecordingButton +import ChatContextQuery private let accessoryButtonFont = Font.medium(14.0) private let counterFont = Font.with(size: 14.0, design: .regular, traits: [.monospacedNumbers]) diff --git a/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift index 09970ca2c7..adfad2e4b5 100644 --- a/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/CommandChatInputContextPanelNode.swift @@ -11,6 +11,7 @@ import AccountContext import ChatPresentationInterfaceState import ChatControllerInteraction import ItemListUI +import ChatContextQuery private struct CommandChatInputContextPanelEntryStableId: Hashable { let command: PeerCommand diff --git a/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift index 293bc1aa91..bb631876d6 100644 --- a/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/CommandMenuChatInputContextPanelNode.swift @@ -11,6 +11,7 @@ import MergeLists import AccountContext import ChatPresentationInterfaceState import ChatControllerInteraction +import ChatContextQuery private struct CommandMenuChatInputContextPanelEntryStableId: Hashable { let command: PeerCommand diff --git a/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift index 1544943493..47120d618b 100644 --- a/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/EmojisChatInputContextPanelNode.swift @@ -20,6 +20,7 @@ import PremiumUI import StickerPeekUI import UndoUI import Pasteboard +import ChatContextQuery private enum EmojisChatInputContextPanelEntryStableId: Hashable, Equatable { case symbol(String) diff --git a/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift index 77912f98fe..3fa1600733 100644 --- a/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HashtagChatInputContextPanelNode.swift @@ -12,6 +12,7 @@ import AccountContext import ItemListUI import ChatPresentationInterfaceState import ChatControllerInteraction +import ChatContextQuery private struct HashtagChatInputContextPanelEntryStableId: Hashable { let text: String diff --git a/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift index ed144a72d0..972f9b7710 100644 --- a/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/MentionChatInputContextPanelNode.swift @@ -12,6 +12,7 @@ import LocalizedPeerData import ItemListUI import ChatPresentationInterfaceState import ChatControllerInteraction +import ChatContextQuery private struct MentionChatInputContextPanelEntry: Comparable, Identifiable { let index: Int diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index 23d887e9e5..376307e551 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -5662,7 +5662,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro text = strongSelf.presentationData.strings.Conversation_AutoremoveOff } if let text = text { - strongSelf.controller?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text), elevatedLayout: false, action: { _ in return false }), in: .current) + strongSelf.controller?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .current) } }) }) @@ -5704,7 +5704,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro text = strongSelf.presentationData.strings.Conversation_AutoremoveOff } if let text = text { - strongSelf.controller?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text), elevatedLayout: false, action: { _ in return false }), in: .current) + strongSelf.controller?.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .autoDelete(isOn: isOn, title: nil, text: text, customUndoText: nil), elevatedLayout: false, action: { _ in return false }), in: .current) } }) } diff --git a/submodules/TelegramUI/Sources/TelegramRootController.swift b/submodules/TelegramUI/Sources/TelegramRootController.swift index c2d7015314..fc4c58120b 100644 --- a/submodules/TelegramUI/Sources/TelegramRootController.swift +++ b/submodules/TelegramUI/Sources/TelegramRootController.swift @@ -361,7 +361,9 @@ public final class TelegramRootController: NavigationController, TelegramRootCon if let imageData = compressImageToJPEG(image, quality: 0.6) { switch privacy { case let .story(storyPrivacy, period, pin): - self.context.engine.messages.uploadStory(media: .image(dimensions: dimensions, data: imageData), text: caption?.string ?? "", entities: [], pin: pin, privacy: storyPrivacy, period: period, randomId: randomId) + let text = caption ?? NSAttributedString() + let entities = generateChatInputTextEntities(text) + self.context.engine.messages.uploadStory(media: .image(dimensions: dimensions, data: imageData), text: text.string, entities: entities, pin: pin, privacy: storyPrivacy, period: period, randomId: randomId) /*let _ = (self.context.engine.messages.uploadStory(media: .image(dimensions: dimensions, data: imageData), text: caption?.string ?? "", entities: [], pin: pin, privacy: storyPrivacy, period: period, randomId: randomId) |> deliverOnMainQueue).start(next: { [weak chatListController] result in @@ -465,7 +467,9 @@ public final class TelegramRootController: NavigationController, TelegramRootCon let imageData = firstFrameImage.flatMap { compressImageToJPEG($0, quality: 0.6) } if case let .story(storyPrivacy, period, pin) = privacy { - self.context.engine.messages.uploadStory(media: .video(dimensions: dimensions, duration: duration, resource: resource, firstFrameImageData: imageData), text: caption?.string ?? "", entities: [], pin: pin, privacy: storyPrivacy, period: period, randomId: randomId) + let text = caption ?? NSAttributedString() + let entities = generateChatInputTextEntities(text) + self.context.engine.messages.uploadStory(media: .video(dimensions: dimensions, duration: duration, resource: resource, firstFrameImageData: imageData), text: text.string, entities: entities, pin: pin, privacy: storyPrivacy, period: period, randomId: randomId) /*let _ = (self.context.engine.messages.uploadStory(media: .video(dimensions: dimensions, duration: duration, resource: resource), text: caption?.string ?? "", entities: [], pin: pin, privacy: storyPrivacy, period: period, randomId: randomId) |> deliverOnMainQueue).start(next: { [weak chatListController] result in if let chatListController { diff --git a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift index b66567bc3d..38d60a723d 100644 --- a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift +++ b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift @@ -114,6 +114,7 @@ public func textAttributedStringForStateText(_ stateText: NSAttributedString, fo } } else if key == ChatTextInputAttributes.customEmoji { result.addAttribute(key, value: value, range: range) + result.addAttribute(NSAttributedString.Key.foregroundColor, value: UIColor.clear, range: range) } } @@ -715,6 +716,31 @@ public func refreshGenericTextInputAttributes(_ textNode: ASEditableTextNode, th } } +public func refreshChatTextInputTypingAttributes(_ textView: UITextView, textColor: UIColor, baseFontSize: CGFloat) { + var filteredAttributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key.font: Font.regular(baseFontSize), + NSAttributedString.Key.foregroundColor: textColor + ] + let style = NSMutableParagraphStyle() + style.baseWritingDirection = .natural + filteredAttributes[NSAttributedString.Key.paragraphStyle] = style + if let attributedText = textView.attributedText, attributedText.length != 0 { + let attributes = attributedText.attributes(at: max(0, min(textView.selectedRange.location - 1, attributedText.length - 1)), effectiveRange: nil) + for (key, value) in attributes { + if key == ChatTextInputAttributes.bold { + filteredAttributes[key] = value + } else if key == ChatTextInputAttributes.italic { + filteredAttributes[key] = value + } else if key == ChatTextInputAttributes.monospace { + filteredAttributes[key] = value + } else if key == NSAttributedString.Key.font { + filteredAttributes[key] = value + } + } + } + textView.typingAttributes = filteredAttributes +} + public func refreshChatTextInputTypingAttributes(_ textNode: ASEditableTextNode, theme: PresentationTheme, baseFontSize: CGFloat) { var filteredAttributes: [NSAttributedString.Key: Any] = [ NSAttributedString.Key.font: Font.regular(baseFontSize), diff --git a/submodules/UndoUI/Sources/UndoOverlayController.swift b/submodules/UndoUI/Sources/UndoOverlayController.swift index 077ba2e7fa..982b12556f 100644 --- a/submodules/UndoUI/Sources/UndoOverlayController.swift +++ b/submodules/UndoUI/Sources/UndoOverlayController.swift @@ -28,7 +28,7 @@ public enum UndoOverlayContent { case importedMessage(text: String) case audioRate(rate: CGFloat, text: String) case forward(savedMessages: Bool, text: String) - case autoDelete(isOn: Bool, title: String?, text: String) + case autoDelete(isOn: Bool, title: String?, text: String, customUndoText: String?) case gigagroupConversion(text: String) case linkRevoked(text: String) case voiceChatRecording(text: String) diff --git a/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift b/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift index 7295bce55a..f13da73112 100644 --- a/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift +++ b/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift @@ -154,7 +154,7 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { self.textNode.attributedText = NSAttributedString(string: text, font: Font.regular(14.0), textColor: .white) displayUndo = undo self.originalRemainingSeconds = 3 - case let .autoDelete(isOn, title, text): + case let .autoDelete(isOn, title, text, customUndoText): self.avatarNode = nil self.iconNode = nil self.iconCheckNode = nil @@ -164,8 +164,18 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { if let title = title { self.titleNode.attributedText = NSAttributedString(string: title, font: Font.semibold(14.0), textColor: .white) } - self.textNode.attributedText = NSAttributedString(string: text, font: Font.regular(14.0), textColor: .white) - displayUndo = false + + let body = MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white) + let bold = MarkdownAttributeSet(font: Font.semibold(14.0), textColor: .white) + let attributedText = parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: body, bold: bold, link: body, linkAttribute: { _ in return nil }), textAlignment: .natural) + self.textNode.attributedText = attributedText + if let customUndoText { + + undoText = customUndoText + displayUndo = true + } else { + displayUndo = false + } self.originalRemainingSeconds = 4.5 case let .succeed(text): self.avatarNode = nil