From b6e9138ea7cff7bf489e476767fd7706a4aa6329 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 30 Jun 2023 03:28:34 +0200 Subject: [PATCH] Various improvements --- .../CameraScreen/Sources/CameraScreen.swift | 8 +- .../Sources/CaptureControlsComponent.swift | 3 + .../Sources/EmojiSuggestionsComponent.swift | 40 ++- .../Sources/MediaEditorScreen.swift | 6 +- .../MessageInputPanelComponent/BUILD | 2 + .../Sources/InputContextQueries.swift | 105 ++++++++ .../Sources/MessageInputPanelComponent.swift | 238 +++++++++++++++++- .../Sources/CategoryListItemComponent.swift | 2 +- .../Sources/ShareWithPeersScreen.swift | 57 ++++- .../Sources/TextFieldComponent.swift | 88 ++++++- .../Sources/ChatTextInputPanelNode.swift | 2 +- 11 files changed, 515 insertions(+), 36 deletions(-) diff --git a/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift b/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift index 0c93ca3d22..14fa0a0a75 100644 --- a/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift +++ b/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift @@ -371,6 +371,8 @@ private final class CameraScreenComponent: CombinedComponent { self.camera.setDualCamEnabled(isEnabled) self.cameraState = self.cameraState.updatedIsDualCamEnabled(isEnabled) self.updated(transition: .easeInOut(duration: 0.1)) + + self.hapticFeedback.impact(.light) } func updateSwipeHint(_ hint: CaptureControlsComponent.SwipeHint) { @@ -1607,13 +1609,11 @@ public class CameraScreen: ViewController { } func presentDraftTooltip() { - guard let sourceView = self.componentHost.findTaggedView(tag: galleryButtonTag) else { + guard let sourceView = self.componentHost.findTaggedView(tag: galleryButtonTag), let absoluteLocation = sourceView.superview?.convert(sourceView.center, to: self.view) else { return } - let parentFrame = self.view.convert(self.bounds, to: nil) - let absoluteFrame = sourceView.convert(sourceView.bounds, to: nil).offsetBy(dx: -parentFrame.minX, dy: 0.0) - let location = CGRect(origin: CGPoint(x: absoluteFrame.midX, y: absoluteFrame.minY - 4.0), size: CGSize()) + let location = CGRect(origin: CGPoint(x: absoluteLocation.x, y: absoluteLocation.y - 29.0), size: CGSize()) let controller = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: "Draft Saved"), location: .point(location, .bottom), displayDuration: .default, inset: 16.0, shouldDismissOnTouch: { _ in return .ignore diff --git a/submodules/TelegramUI/Components/CameraScreen/Sources/CaptureControlsComponent.swift b/submodules/TelegramUI/Components/CameraScreen/Sources/CaptureControlsComponent.swift index 92be146087..df6e5a93e7 100644 --- a/submodules/TelegramUI/Components/CameraScreen/Sources/CaptureControlsComponent.swift +++ b/submodules/TelegramUI/Components/CameraScreen/Sources/CaptureControlsComponent.swift @@ -772,6 +772,9 @@ final class CaptureControlsComponent: Component { self.component?.swipeHintUpdated(.flip) if location.x > self.frame.width / 2.0 + 60.0 { self.panBlobState = .transientToFlip + if self.didFlip && location.x < self.frame.width - 100.0 { + self.didFlip = false + } if !self.didFlip && location.x > self.frame.width - 70.0 { self.didFlip = true self.hapticFeedback.impact(.light) diff --git a/submodules/TelegramUI/Components/EmojiSuggestionsComponent/Sources/EmojiSuggestionsComponent.swift b/submodules/TelegramUI/Components/EmojiSuggestionsComponent/Sources/EmojiSuggestionsComponent.swift index 535b79b95d..143d96eff1 100644 --- a/submodules/TelegramUI/Components/EmojiSuggestionsComponent/Sources/EmojiSuggestionsComponent.swift +++ b/submodules/TelegramUI/Components/EmojiSuggestionsComponent/Sources/EmojiSuggestionsComponent.swift @@ -15,6 +15,22 @@ import TelegramUIPreferences public final class EmojiSuggestionsComponent: Component { public typealias EnvironmentType = Empty + public struct Theme: Equatable { + let backgroundColor: UIColor + let textColor: UIColor + let placeholderColor: UIColor + + public init( + backgroundColor: UIColor, + textColor: UIColor, + placeholderColor: UIColor + ) { + self.backgroundColor = backgroundColor + self.textColor = textColor + self.placeholderColor = placeholderColor + } + } + public static func suggestionData(context: AccountContext, isSavedMessages: Bool, query: String) -> Signal<[TelegramMediaFile], NoError> { let hasPremium: Signal if isSavedMessages { @@ -98,7 +114,7 @@ public final class EmojiSuggestionsComponent: Component { } public let context: AccountContext - public let theme: PresentationTheme + public let theme: Theme public let animationCache: AnimationCache public let animationRenderer: MultiAnimationRenderer public let files: [TelegramMediaFile] @@ -107,7 +123,7 @@ public final class EmojiSuggestionsComponent: Component { public init( context: AccountContext, userLocation: MediaResourceUserLocation, - theme: PresentationTheme, + theme: Theme, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, files: [TelegramMediaFile], @@ -125,7 +141,7 @@ public final class EmojiSuggestionsComponent: Component { if lhs.context !== rhs.context { return false } - if lhs.theme !== rhs.theme { + if lhs.theme != rhs.theme { return false } if lhs.animationCache !== rhs.animationCache { @@ -305,7 +321,7 @@ public final class EmojiSuggestionsComponent: Component { let itemLayer: InlineStickerItemLayer if let current = self.visibleLayers[item.fileId] { itemLayer = current - itemLayer.dynamicColor = component.theme.list.itemPrimaryTextColor + itemLayer.dynamicColor = component.theme.textColor } else { itemLayer = InlineStickerItemLayer( context: component.context, @@ -315,9 +331,9 @@ public final class EmojiSuggestionsComponent: Component { file: item, cache: component.animationCache, renderer: component.animationRenderer, - placeholderColor: component.theme.list.mediaPlaceholderColor, + placeholderColor: component.theme.placeholderColor, pointSize: itemFrame.size, - dynamicColor: component.theme.list.itemPrimaryTextColor + dynamicColor: component.theme.textColor ) self.visibleLayers[item.fileId] = itemLayer self.scrollView.layer.addSublayer(itemLayer) @@ -382,10 +398,10 @@ public final class EmojiSuggestionsComponent: Component { func update(component: EmojiSuggestionsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let height: CGFloat = 54.0 - if self.component?.theme !== component.theme { + if self.component?.theme.backgroundColor != component.theme.backgroundColor { //self.backgroundLayer.fillColor = component.theme.list.plainBackgroundColor.cgColor self.backgroundLayer.fillColor = UIColor.black.cgColor - self.blurView.updateColor(color: component.theme.list.plainBackgroundColor.withMultipliedAlpha(0.88), transition: .immediate) + self.blurView.updateColor(color: component.theme.backgroundColor, transition: .immediate) } var resetScrollingPosition = false if self.component?.files != component.files { @@ -427,3 +443,11 @@ public final class EmojiSuggestionsComponent: Component { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } + +public extension EmojiSuggestionsComponent.Theme { + init(theme: PresentationTheme) { + self.backgroundColor = theme.list.plainBackgroundColor.withMultipliedAlpha(0.88) + self.textColor = theme.list.itemPrimaryTextColor + self.placeholderColor = theme.list.mediaPlaceholderColor + } +} diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index 210e265bd8..923a4da0b6 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -3338,6 +3338,10 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate func requestDismiss(saveDraft: Bool, animated: Bool) { self.dismissAllTooltips() + var showDraftTooltip = saveDraft + if let subject = self.node.subject, case .draft = subject { + showDraftTooltip = false + } if saveDraft { self.saveDraft(id: nil) } else { @@ -3351,7 +3355,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate } self.node.entitiesView.invalidate() - self.cancelled(saveDraft) + self.cancelled(showDraftTooltip) self.willDismiss() diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD b/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD index f48ad6e74c..354df30334 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD @@ -13,6 +13,7 @@ swift_library( "//submodules/Display", "//submodules/ComponentFlow", "//submodules/AppBundle", + "//submodules/TelegramCore", "//submodules/TelegramUI/Components/TextFieldComponent", "//submodules/Components/BundleIconComponent", "//submodules/TelegramUI/Components/ChatTextInputMediaRecordingButton", @@ -28,6 +29,7 @@ swift_library( "//submodules/TextFormat", "//submodules/TelegramUI/Components/Stories/PeerListItemComponent", "//submodules/TelegramUI/Components/MoreHeaderButton", + "//submodules/TelegramUI/Components/EmojiSuggestionsComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/InputContextQueries.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/InputContextQueries.swift index 4a36e6044c..584bd0d883 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/InputContextQueries.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/InputContextQueries.swift @@ -1,5 +1,6 @@ import Foundation import SwiftSignalKit +import TelegramCore import TextFieldComponent import ChatContextQuery import AccountContext @@ -129,6 +130,110 @@ private func updatedContextQueryResultStateForQuery(context: AccountContext, inp |> castError(ChatContextQueryError.self) return signal |> then(peers) + case let .emojiSearch(query, languageCode, range): + 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 + + if query.isSingleEmoji { + return combineLatest( + context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000), + hasPremium + ) + |> map { view, hasPremium -> [(String, TelegramMediaFile?, String)] in + var result: [(String, TelegramMediaFile?, String)] = [] + + for entry in view.entries { + guard let item = entry.item as? StickerPackItem else { + continue + } + for attribute in item.file.attributes { + switch attribute { + case let .CustomEmoji(_, _, alt, _): + if alt == query { + if !item.file.isPremiumEmoji || hasPremium { + result.append((alt, item.file, alt)) + } + } + default: + break + } + } + } + return result + } + |> map { result -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in + return { _ in return .emojis(result, range) } + } + |> castError(ChatContextQueryError.self) + } else { + var signal = context.engine.stickers.searchEmojiKeywords(inputLanguageCode: languageCode, query: query, completeMatch: query.count < 2) + if !languageCode.lowercased().hasPrefix("en") { + signal = signal + |> mapToSignal { keywords in + return .single(keywords) + |> then( + context.engine.stickers.searchEmojiKeywords(inputLanguageCode: "en-US", query: query, completeMatch: query.count < 3) + |> map { englishKeywords in + return keywords + englishKeywords + } + ) + } + } + + return signal + |> castError(ChatContextQueryError.self) + |> mapToSignal { keywords -> Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, ChatContextQueryError> in + return combineLatest( + context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000), + hasPremium + ) + |> map { view, hasPremium -> [(String, TelegramMediaFile?, String)] in + var result: [(String, TelegramMediaFile?, String)] = [] + + var allEmoticons: [String: String] = [:] + for keyword in keywords { + for emoticon in keyword.emoticons { + allEmoticons[emoticon] = keyword.keyword + } + } + + for entry in view.entries { + guard let item = entry.item as? StickerPackItem else { + continue + } + for attribute in item.file.attributes { + switch attribute { + case let .CustomEmoji(_, _, alt, _): + if !alt.isEmpty, let keyword = allEmoticons[alt] { + if !item.file.isPremiumEmoji || hasPremium { + result.append((alt, item.file, keyword)) + } + } + default: + break + } + } + } + + for keyword in keywords { + for emoticon in keyword.emoticons { + result.append((emoticon, nil, keyword.keyword)) + } + } + return result + } + |> map { result -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in + return { _ in return .emojis(result, range) } + } + |> castError(ChatContextQueryError.self) + } + } default: return .complete() } diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift index f2c7d2dc88..06d13fe462 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -3,6 +3,7 @@ import UIKit import Display import ComponentFlow import SwiftSignalKit +import TelegramCore import AppBundle import TextFieldComponent import BundleIconComponent @@ -12,6 +13,8 @@ import ChatPresentationInterfaceState import LottieComponent import ChatContextQuery import TextFormat +import EmojiSuggestionsComponent +import AudioToolbox public final class MessageInputPanelComponent: Component { public enum Style { @@ -210,7 +213,7 @@ public final class MessageInputPanelComponent: Component { public enum SendMessageInput { case text(NSAttributedString) } - + public final class View: UIView { private let fieldBackgroundView: BlurredBackgroundView private let vibrancyEffectView: UIVisualEffectView @@ -240,13 +243,15 @@ public final class MessageInputPanelComponent: Component { private var currentMediaInputIsVoice: Bool = true private var mediaCancelFraction: CGFloat = 0.0 + private var currentInputMode: InputMode? + private var contextQueryStates: [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)] = [:] private var contextQueryResults: [ChatPresentationInputQueryKind: ChatPresentationInputQueryResult] = [:] - private var contextQueryResultPanel: ComponentView? private var contextQueryResultPanelExternalState: ContextResultPanelComponent.ExternalState? - private var currentInputMode: InputMode? + private var viewForOverlayContent: ViewForOverlayContent? + private var currentEmojiSuggestionView: ComponentHostView? private var component: MessageInputPanelComponent? private weak var state: EmptyComponentState? @@ -272,6 +277,28 @@ public final class MessageInputPanelComponent: Component { self.addSubview(self.gradientView) self.fieldBackgroundView.addSubview(self.vibrancyEffectView) self.addSubview(self.fieldBackgroundView) + + self.viewForOverlayContent = ViewForOverlayContent( + ignoreHit: { [weak self] view, point in + guard let self else { + return false + } + if self.hitTest(view.convert(point, to: self), with: nil) != nil { + return true + } + if view.convert(point, to: self).y > self.bounds.maxY { + return true + } + return false + }, + dismissSuggestions: { [weak self] in + guard let self else { + return + } + self.textFieldExternalState.dismissedEmojiSuggestionPosition = self.textFieldExternalState.currentEmojiSuggestion?.position + self.state?.updated() + } + ) } required init?(coder: NSCoder) { @@ -351,6 +378,17 @@ public final class MessageInputPanelComponent: Component { override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let result = super.hitTest(point, with: event) + if let _ = self.textField.view, let currentEmojiSuggestion = self.textFieldExternalState.currentEmojiSuggestion, let currentEmojiSuggestionView = self.currentEmojiSuggestionView { + if let result = currentEmojiSuggestionView.hitTest(self.convert(point, to: currentEmojiSuggestionView), with: event) { + return result + } + self.textFieldExternalState.dismissedEmojiSuggestionPosition = currentEmojiSuggestion.position + if let textFieldView = self.textField.view as? TextFieldComponent.View { + textFieldView.updateEmojiSuggestion(transition: .immediate) + } + self.state?.updated() + } + if result == nil, let contextQueryResultPanel = self.contextQueryResultPanel?.view, let panelResult = contextQueryResultPanel.hitTest(self.convert(point, to: contextQueryResultPanel), with: event), panelResult !== contextQueryResultPanel { return panelResult } @@ -513,9 +551,18 @@ public final class MessageInputPanelComponent: Component { if let textFieldView = self.textField.view { if textFieldView.superview == nil { self.addSubview(textFieldView) + + if let viewForOverlayContent = self.viewForOverlayContent { + self.addSubview(viewForOverlayContent) + } } - transition.setFrame(view: textFieldView, frame: CGRect(origin: CGPoint(x: fieldBackgroundFrame.minX, y: fieldBackgroundFrame.maxY - textFieldSize.height), size: textFieldSize)) + let textFieldFrame = CGRect(origin: CGPoint(x: fieldBackgroundFrame.minX, y: fieldBackgroundFrame.maxY - textFieldSize.height), size: textFieldSize) + transition.setFrame(view: textFieldView, frame: textFieldFrame) transition.setAlpha(view: textFieldView, alpha: (hasMediaRecording || hasMediaEditing || component.disabledPlaceholder != nil) ? 0.0 : 1.0) + + if let viewForOverlayContent = self.viewForOverlayContent { + transition.setFrame(view: viewForOverlayContent, frame: textFieldFrame) + } } if let disabledPlaceholderText = component.disabledPlaceholder { @@ -1123,6 +1170,9 @@ public final class MessageInputPanelComponent: Component { } self.updateContextQueries() + + let panelLeftInset: CGFloat = max(insets.left, 7.0) + let panelRightInset: CGFloat = max(insets.right, 41.0) if let result = self.contextQueryResults[.mention], result.count > 0 && self.textFieldExternalState.isEditing { let availablePanelHeight: CGFloat = 413.0 @@ -1142,8 +1192,6 @@ public final class MessageInputPanelComponent: Component { 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( @@ -1209,6 +1257,143 @@ public final class MessageInputPanelComponent: Component { }) } + if let emojiSuggestion = self.textFieldExternalState.currentEmojiSuggestion, emojiSuggestion.disposable == nil { + emojiSuggestion.disposable = (EmojiSuggestionsComponent.suggestionData(context: component.context, isSavedMessages: false, query: emojiSuggestion.position.value) + |> deliverOnMainQueue).start(next: { [weak self, weak emojiSuggestion] result in + guard let self, let emojiSuggestion, self.textFieldExternalState.currentEmojiSuggestion === emojiSuggestion else { + return + } + + emojiSuggestion.value = result + self.state?.updated() + }) + } + + var hasTrackingView = self.textFieldExternalState.hasTrackingView + if let currentEmojiSuggestion = self.textFieldExternalState.currentEmojiSuggestion, let value = currentEmojiSuggestion.value as? [TelegramMediaFile], value.isEmpty { + hasTrackingView = false + } + if !self.textFieldExternalState.isEditing { + hasTrackingView = false + } + + if !hasTrackingView { + if let currentEmojiSuggestion = self.textFieldExternalState.currentEmojiSuggestion { + self.textFieldExternalState.currentEmojiSuggestion = nil + currentEmojiSuggestion.disposable?.dispose() + } + + if let currentEmojiSuggestionView = self.currentEmojiSuggestionView { + self.currentEmojiSuggestionView = nil + + currentEmojiSuggestionView.alpha = 0.0 + currentEmojiSuggestionView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak currentEmojiSuggestionView] _ in + currentEmojiSuggestionView?.removeFromSuperview() + }) + } + } + + if let currentEmojiSuggestion = self.textFieldExternalState.currentEmojiSuggestion, let value = currentEmojiSuggestion.value as? [TelegramMediaFile] { + let currentEmojiSuggestionView: ComponentHostView + if let current = self.currentEmojiSuggestionView { + currentEmojiSuggestionView = current + } else { + currentEmojiSuggestionView = ComponentHostView() + self.currentEmojiSuggestionView = currentEmojiSuggestionView + self.addSubview(currentEmojiSuggestionView) + + currentEmojiSuggestionView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + + //self.installEmojiSuggestionPreviewGesture(hostView: currentEmojiSuggestionView) + } + + + let globalPosition: CGPoint + if let textView = self.textField.view { + globalPosition = textView.convert(currentEmojiSuggestion.localPosition, to: self) + } else { + globalPosition = .zero + } + + let sideInset: CGFloat = 7.0 + + let viewSize = currentEmojiSuggestionView.update( + transition: .immediate, + component: AnyComponent(EmojiSuggestionsComponent( + context: component.context, + userLocation: .other, + theme: EmojiSuggestionsComponent.Theme( + backgroundColor: UIColor(white: 0.0, alpha: 0.5), + textColor: .white, + placeholderColor: UIColor(rgb: 0xffffff).mixedWith(UIColor(rgb: 0x1c1c1d), alpha: 0.9) + ), + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + files: value, + action: { [weak self] file in + guard let self, let textView = self.textField.view as? TextFieldComponent.View, let currentEmojiSuggestion = self.textFieldExternalState.currentEmojiSuggestion else { + return + } + + AudioServicesPlaySystemSound(0x450) + + let inputState = textView.getInputState() + let inputText = NSMutableAttributedString(attributedString: inputState.inputText) + + var text: String? + var emojiAttribute: ChatTextInputTextCustomEmojiAttribute? + loop: for attribute in file.attributes { + switch attribute { + case let .CustomEmoji(_, _, displayText, _): + text = displayText + emojiAttribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file) + break loop + default: + break + } + } + + if let emojiAttribute = emojiAttribute, let text = text { + let replacementText = NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: emojiAttribute]) + + let range = currentEmojiSuggestion.position.range + let previousText = inputText.attributedSubstring(from: range) + inputText.replaceCharacters(in: range, with: replacementText) + + var replacedUpperBound = range.lowerBound + while true { + if inputText.attributedSubstring(from: NSRange(location: 0, length: replacedUpperBound)).string.hasSuffix(previousText.string) { + let replaceRange = NSRange(location: replacedUpperBound - previousText.length, length: previousText.length) + if replaceRange.location < 0 { + break + } + let adjacentString = inputText.attributedSubstring(from: replaceRange) + if adjacentString.string != previousText.string || adjacentString.attribute(ChatTextInputAttributes.customEmoji, at: 0, effectiveRange: nil) != nil { + break + } + inputText.replaceCharacters(in: replaceRange, with: NSAttributedString(string: text, attributes: [ChatTextInputAttributes.customEmoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: emojiAttribute.interactivelySelectedFromPackId, fileId: emojiAttribute.fileId, file: emojiAttribute.file)])) + replacedUpperBound = replaceRange.lowerBound + } else { + break + } + } + + let selectionPosition = range.lowerBound + (replacementText.string as NSString).length + textView.updateText(inputText, selectionRange: selectionPosition ..< selectionPosition) + } + } + )), + environment: {}, + containerSize: CGSize(width: self.bounds.width - panelLeftInset - panelRightInset, height: 100.0) + ) + + let viewFrame = CGRect(origin: CGPoint(x: min(self.bounds.width - sideInset - viewSize.width, max(panelLeftInset, floor(globalPosition.x - viewSize.width / 2.0))), y: globalPosition.y - 4.0 - viewSize.height), size: viewSize) + currentEmojiSuggestionView.frame = viewFrame + if let componentView = currentEmojiSuggestionView.componentView as? EmojiSuggestionsComponent.View { + componentView.adjustBackground(relativePositionX: floor(globalPosition.x - viewFrame.minX)) + } + } + return size } } @@ -1221,3 +1406,44 @@ public final class MessageInputPanelComponent: Component { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } + +final class ViewForOverlayContent: UIView { + let ignoreHit: (UIView, CGPoint) -> Bool + let dismissSuggestions: () -> Void + + init(ignoreHit: @escaping (UIView, CGPoint) -> Bool, dismissSuggestions: @escaping () -> Void) { + self.ignoreHit = ignoreHit + self.dismissSuggestions = dismissSuggestions + + super.init(frame: CGRect()) + } + + required init(coder: NSCoder) { + preconditionFailure() + } + + func maybeDismissContent(point: CGPoint) { + for subview in self.subviews.reversed() { + if let _ = subview.hitTest(self.convert(point, to: subview), with: nil) { + return + } + } + + self.dismissSuggestions() + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + for subview in self.subviews.reversed() { + if let result = subview.hitTest(self.convert(point, to: subview), with: event) { + return result + } + } + + if event == nil || self.ignoreHit(self, point) { + return nil + } + + self.dismissSuggestions() + return nil + } +} diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CategoryListItemComponent.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CategoryListItemComponent.swift index a1f23de673..48b059b18f 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CategoryListItemComponent.swift +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CategoryListItemComponent.swift @@ -239,7 +239,7 @@ final class CategoryListItemComponent: Component { text: .plain(NSAttributedString(string: labelData.0, font: Font.regular(15.0), textColor: labelData.1 ? component.theme.list.itemAccentColor : component.theme.list.itemSecondaryTextColor)) )), environment: {}, - containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0) + containerSize: CGSize(width: availableSize.width - leftInset - rightInset - 14.0, height: 100.0) ) let labelArrowSize = self.labelArrow.update( diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift index 9a64aa4119..33ee596e91 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift @@ -1665,11 +1665,27 @@ public class ShareWithPeersScreen: ViewControllerComponentContainer { switch subject { case .stories: - let state = State(peers: [], presences: [:]) - self.stateValue = state - self.stateSubject.set(.single(state)) - self.readySubject.set(true) - self.initialPeerIds = initialPeerIds + var signals: [Signal] = [] + if initialPeerIds.count < 3 { + for peerId in initialPeerIds { + signals.append(context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))) + } + } + self.stateDisposable = (combineLatest(signals) + |> deliverOnMainQueue).start(next: { [weak self] peers in + guard let self else { + return + } + + let state = State( + peers: peers.compactMap { $0 }, + presences: [:] + ) + self.stateValue = state + self.stateSubject.set(.single(state)) + + self.readySubject.set(true) + }) case .chats: self.stateDisposable = (context.engine.messages.chatList(group: .root, count: 200) |> deliverOnMainQueue).start(next: { [weak self] chatList in @@ -1811,6 +1827,8 @@ public class ShareWithPeersScreen: ViewControllerComponentContainer { ) { self.context = context + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + var categoryItems: [ShareWithPeersScreenComponent.CategoryItem] = [] var optionItems: [ShareWithPeersScreenComponent.OptionItem] = [] if case let .stories(editing) = stateContext.subject { @@ -1822,12 +1840,25 @@ public class ShareWithPeersScreen: ViewControllerComponentContainer { actionTitle: nil )) + var peerNames = "" + if let peers = stateContext.stateValue?.peers, !peers.isEmpty { + peerNames = String(peers.map { $0.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) }.joined(separator: ", ")) + } + var contactsSubtitle = "exclude people" if initialPrivacy.base == .contacts, initialPrivacy.additionallyIncludePeers.count > 0 { if initialPrivacy.additionallyIncludePeers.count == 1 { - contactsSubtitle = "except 1 person" + if !peerNames.isEmpty { + contactsSubtitle = "except \(peerNames)" + } else { + contactsSubtitle = "except 1 person" + } } else { - contactsSubtitle = "except \(initialPrivacy.additionallyIncludePeers.count) people" + if !peerNames.isEmpty { + contactsSubtitle = "except \(peerNames)" + } else { + contactsSubtitle = "except \(initialPrivacy.additionallyIncludePeers.count) people" + } } } categoryItems.append(ShareWithPeersScreenComponent.CategoryItem( @@ -1849,9 +1880,17 @@ public class ShareWithPeersScreen: ViewControllerComponentContainer { var selectedContactsSubtitle = "choose" if initialPrivacy.base == .nobody, initialPrivacy.additionallyIncludePeers.count > 0 { if initialPrivacy.additionallyIncludePeers.count == 1 { - selectedContactsSubtitle = "1 person" + if !peerNames.isEmpty { + selectedContactsSubtitle = peerNames + } else { + selectedContactsSubtitle = "1 person" + } } else { - selectedContactsSubtitle = "\(initialPrivacy.additionallyIncludePeers.count) people" + if !peerNames.isEmpty { + selectedContactsSubtitle = peerNames + } else { + selectedContactsSubtitle = "\(initialPrivacy.additionallyIncludePeers.count) people" + } } } categoryItems.append(ShareWithPeersScreenComponent.CategoryItem( diff --git a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift index 6cdce3f439..96a9a25363 100644 --- a/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift +++ b/submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift @@ -23,10 +23,34 @@ public final class TextFieldComponent: Component { public fileprivate(set) var hasText: Bool = false public var initialText: NSAttributedString? + public var hasTrackingView = false + + public var currentEmojiSuggestion: EmojiSuggestion? + public var dismissedEmojiSuggestionPosition: EmojiSuggestion.Position? + public init() { } } + public final class EmojiSuggestion { + public struct Position: Equatable { + public var range: NSRange + public var value: String + } + + public var localPosition: CGPoint + public var position: Position + public var disposable: Disposable? + public var value: Any? + + init(localPosition: CGPoint, position: Position) { + self.localPosition = localPosition + self.position = position + self.disposable = nil + self.value = nil + } + } + public final class AnimationHint { public enum Kind { case textChanged @@ -116,7 +140,7 @@ public final class TextFieldComponent: Component { private var spoilerView: InvisibleInkDustView? private var customEmojiContainerView: CustomEmojiContainerView? private var emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)? - + 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) @@ -223,6 +247,7 @@ public final class TextFieldComponent: Component { } self.updateSpoilersRevealed() + self.updateEmojiSuggestion(transition: .immediate) } public func textViewDidBeginEditing(_ textView: UITextView) { @@ -335,11 +360,6 @@ public final class TextFieldComponent: Component { } self.textView.becomeFirstResponder() } -// 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) @@ -547,6 +567,60 @@ public final class TextFieldComponent: Component { } } + public func updateEmojiSuggestion(transition: Transition) { + guard let component = self.component else { + return + } + + var hasTracking = false + var hasTrackingView = false + if self.textView.selectedRange.length == 0 && self.textView.selectedRange.location > 0 { + let selectedSubstring = self.textView.attributedText.attributedSubstring(from: NSRange(location: 0, length: self.textView.selectedRange.location)) + if let lastCharacter = selectedSubstring.string.last, String(lastCharacter).isSingleEmoji { + let queryLength = (String(lastCharacter) as NSString).length + if selectedSubstring.attribute(ChatTextInputAttributes.customEmoji, at: selectedSubstring.length - queryLength, effectiveRange: nil) == nil { + let beginning = self.textView.beginningOfDocument + + let characterRange = NSRange(location: selectedSubstring.length - queryLength, length: queryLength) + + let start = self.textView.position(from: beginning, offset: selectedSubstring.length - queryLength) + let end = self.textView.position(from: beginning, offset: selectedSubstring.length) + + if let start = start, let end = end, let textRange = self.textView.textRange(from: start, to: end) { + let selectionRects = self.textView.selectionRects(for: textRange) + let emojiSuggestionPosition = EmojiSuggestion.Position(range: characterRange, value: String(lastCharacter)) + + hasTracking = true + + if let trackingRect = selectionRects.first?.rect { + let trackingPosition = CGPoint(x: trackingRect.midX, y: trackingRect.minY) + if component.externalState.dismissedEmojiSuggestionPosition == emojiSuggestionPosition { + } else { + hasTrackingView = true + + let emojiSuggestion: EmojiSuggestion + if let current = component.externalState.currentEmojiSuggestion, current.position.value == emojiSuggestionPosition.value { + emojiSuggestion = current + } else { + + emojiSuggestion = EmojiSuggestion(localPosition: trackingPosition, position: emojiSuggestionPosition) + component.externalState.currentEmojiSuggestion = emojiSuggestion + } + emojiSuggestion.localPosition = trackingPosition + emojiSuggestion.position = emojiSuggestionPosition + component.externalState.dismissedEmojiSuggestionPosition = nil + } + } + } + } + } + } + if !hasTracking { + component.externalState.dismissedEmojiSuggestionPosition = nil + } + component.externalState.hasTrackingView = hasTrackingView + } + func update(component: TextFieldComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state @@ -584,6 +658,8 @@ public final class TextFieldComponent: Component { self.textView.frame = CGRect(origin: CGPoint(), size: size) self.textView.panGestureRecognizer.isEnabled = isEditing + self.updateEmojiSuggestion(transition: .immediate) + if refreshScrolling { if isEditing { if wasEditing { diff --git a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift index 2781cd9d00..e98c6094ac 100644 --- a/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTextInputPanelNode.swift @@ -2859,7 +2859,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { component: AnyComponent(EmojiSuggestionsComponent( context: context, userLocation: .other, - theme: theme, + theme: EmojiSuggestionsComponent.Theme(theme: theme), animationCache: presentationContext.animationCache, animationRenderer: presentationContext.animationRenderer, files: value,