2025-02-14 19:11:58 +04:00

444 lines
20 KiB
Swift

import Foundation
import UIKit
import SwiftSignalKit
import Display
import AnimationCache
import MultiAnimationRenderer
import ComponentFlow
import AccountContext
import TelegramCore
import TelegramPresentationData
import EmojiTextAttachmentView
import TextFormat
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<Bool, NoError>
if isSavedMessages {
hasPremium = .single(true)
} else {
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 combineLatest(
context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000),
context.account.viewTracker.featuredEmojiPacks(),
hasPremium,
context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.stickerSettings])
)
|> take(1)
|> map { view, featuredEmojiPacks, hasPremium, sharedData -> [TelegramMediaFile] in
var stickerSettings = StickerSettings.defaultSettings
if let value = sharedData.entries[ApplicationSpecificSharedDataKeys.stickerSettings]?.get(StickerSettings.self) {
stickerSettings = value
}
if !stickerSettings.suggestAnimatedEmoji {
return []
}
var result: [TelegramMediaFile] = []
let normalizedQuery = query.basicEmoji.0
var existingIds = Set<EngineMedia.Id>()
for entry in view.entries {
guard let item = entry.item as? StickerPackItem, !item.file.isPremiumEmoji || hasPremium else {
continue
}
let stringRepresentations = item.getStringRepresentationsOfIndexKeys()
for stringRepresentation in stringRepresentations {
if stringRepresentation == query || (!normalizedQuery.isEmpty && stringRepresentation == normalizedQuery) {
if !existingIds.contains(item.file.fileId) {
existingIds.insert(item.file.fileId)
result.append(item.file._parse())
}
break
}
}
}
for featuredPack in featuredEmojiPacks {
for item in featuredPack.topItems {
if let alt = item.file.customEmojiAlt {
if alt == query || (!normalizedQuery.isEmpty && alt == normalizedQuery) {
if !item.file.isPremiumEmoji || hasPremium {
if !existingIds.contains(item.file.fileId) {
existingIds.insert(item.file.fileId)
result.append(item.file._parse())
}
}
}
}
}
}
return result
}
}
public let context: AccountContext
public let theme: Theme
public let animationCache: AnimationCache
public let animationRenderer: MultiAnimationRenderer
public let files: [TelegramMediaFile]
public let action: (TelegramMediaFile) -> Void
public init(
context: AccountContext,
userLocation: MediaResourceUserLocation,
theme: Theme,
animationCache: AnimationCache,
animationRenderer: MultiAnimationRenderer,
files: [TelegramMediaFile],
action: @escaping (TelegramMediaFile) -> Void
) {
self.context = context
self.theme = theme
self.animationCache = animationCache
self.animationRenderer = animationRenderer
self.files = files
self.action = action
}
public static func ==(lhs: EmojiSuggestionsComponent, rhs: EmojiSuggestionsComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.theme != rhs.theme {
return false
}
if lhs.animationCache !== rhs.animationCache {
return false
}
if lhs.animationRenderer !== rhs.animationRenderer {
return false
}
if lhs.files != rhs.files {
return false
}
return true
}
public final class View: UIView, UIScrollViewDelegate {
private struct ItemLayout: Equatable {
let spacing: CGFloat
let itemSize: CGFloat
let verticalInset: CGFloat
let itemCount: Int
let contentSize: CGSize
let sideInset: CGFloat
init(itemCount: Int) {
#if DEBUG
//var itemCount = itemCount
//itemCount = 100
#endif
self.spacing = 9.0
self.itemSize = 38.0
self.verticalInset = 5.0
self.sideInset = 5.0
self.itemCount = itemCount
self.contentSize = CGSize(width: self.sideInset * 2.0 + CGFloat(self.itemCount - 1) * self.spacing + CGFloat(self.itemCount) * self.itemSize, height: self.itemSize + self.verticalInset * 2.0)
}
func frame(at index: Int) -> CGRect {
return CGRect(origin: CGPoint(x: self.sideInset + CGFloat(index) * (self.spacing + self.itemSize), y: self.verticalInset), size: CGSize(width: self.itemSize, height: self.itemSize))
}
}
private let blurView: BlurredBackgroundView
private let backgroundLayer: SimpleShapeLayer
private let shadowLayer: SimpleLayer
private let scrollView: UIScrollView
private var component: EmojiSuggestionsComponent?
private var itemLayout: ItemLayout?
private var ignoreScrolling: Bool = false
private var visibleLayers: [EngineMedia.Id: InlineStickerItemLayer] = [:]
override init(frame: CGRect) {
self.blurView = BlurredBackgroundView(color: .clear, enableBlur: true)
/*self.blurView.layer.shadowColor = UIColor(white: 0.0, alpha: 1.0).cgColor
self.blurView.layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
self.blurView.layer.shadowRadius = 15.0
self.blurView.layer.shadowOpacity = 0.15*/
self.shadowLayer = SimpleLayer()
self.shadowLayer.shadowColor = UIColor(white: 0.0, alpha: 1.0).cgColor
self.shadowLayer.shadowOffset = CGSize(width: 0.0, height: 2.0)
self.shadowLayer.shadowRadius = 15.0
self.shadowLayer.shadowOpacity = 0.15
self.backgroundLayer = SimpleShapeLayer()
self.blurView.layer.mask = self.backgroundLayer
self.scrollView = UIScrollView()
super.init(frame: frame)
self.disablesInteractiveTransitionGestureRecognizer = true
self.disablesInteractiveKeyboardGestureRecognizer = true
self.scrollView.layer.anchorPoint = CGPoint()
self.scrollView.delaysContentTouches = false
self.scrollView.clipsToBounds = false
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.scrollView.contentInsetAdjustmentBehavior = .never
}
if #available(iOS 13.0, *) {
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
}
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.scrollsToTop = false
self.scrollView.delegate = self
self.scrollView.clipsToBounds = true
self.layer.addSublayer(self.shadowLayer)
self.addSubview(self.blurView)
//self.layer.addSublayer(self.backgroundLayer)
self.addSubview(self.scrollView)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func item(at point: CGPoint) -> (CALayer, TelegramMediaFile)? {
let location = self.convert(point, to: self.scrollView)
if self.scrollView.bounds.contains(location) {
var closestFile: (file: TelegramMediaFile, layer: CALayer, distance: CGFloat)?
for (_, itemLayer) in self.visibleLayers {
guard let file = itemLayer.file else {
continue
}
let distance = abs(location.x - itemLayer.position.x)
if let (_, _, currentDistance) = closestFile {
if distance < currentDistance {
closestFile = (file, itemLayer, distance)
}
} else {
closestFile = (file, itemLayer, distance)
}
}
if let (file, itemLayer, _) = closestFile {
return (itemLayer, file)
}
}
return nil
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
let location = recognizer.location(in: self.scrollView)
if self.scrollView.bounds.contains(location) {
var closestFile: (file: TelegramMediaFile, distance: CGFloat)?
for (_, itemLayer) in self.visibleLayers {
guard let file = itemLayer.file else {
continue
}
let distance = abs(location.x - itemLayer.position.x)
if let (_, currentDistance) = closestFile {
if distance < currentDistance {
closestFile = (file, distance)
}
} else {
closestFile = (file, distance)
}
}
if let (file, _) = closestFile {
self.component?.action(file)
}
}
}
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateVisibleItems(synchronousLoad: false)
}
}
private func updateVisibleItems(synchronousLoad: Bool) {
guard let component = self.component, let itemLayout = self.itemLayout else {
return
}
let visibleBounds = self.scrollView.bounds
var visibleIds = Set<EngineMedia.Id>()
for i in 0 ..< component.files.count {
let itemFrame = itemLayout.frame(at: i)
if visibleBounds.intersects(itemFrame) {
let item = component.files[i]
visibleIds.insert(item.fileId)
let itemLayer: InlineStickerItemLayer
if let current = self.visibleLayers[item.fileId] {
itemLayer = current
itemLayer.dynamicColor = component.theme.textColor
} else {
itemLayer = InlineStickerItemLayer(
context: component.context,
userLocation: .other,
attemptSynchronousLoad: synchronousLoad,
emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: item.fileId.id, file: item),
file: item,
cache: component.animationCache,
renderer: component.animationRenderer,
placeholderColor: component.theme.placeholderColor,
pointSize: itemFrame.size,
dynamicColor: component.theme.textColor
)
self.visibleLayers[item.fileId] = itemLayer
self.scrollView.layer.addSublayer(itemLayer)
}
itemLayer.frame = itemFrame
itemLayer.isVisibleForAnimations = true
}
}
var removedIds: [EngineMedia.Id] = []
for (id, itemLayer) in self.visibleLayers {
if !visibleIds.contains(id) {
itemLayer.removeFromSuperlayer()
removedIds.append(id)
}
}
for id in removedIds {
self.visibleLayers.removeValue(forKey: id)
}
}
public func adjustBackground(relativePositionX: CGFloat) {
let size = self.bounds.size
if size.width.isZero {
return
}
let radius: CGFloat = 10.0
let notchSize = CGSize(width: 19.0, height: 7.5)
let path = CGMutablePath()
path.move(to: CGPoint(x: radius, y: 0.0))
path.addArc(tangent1End: CGPoint(x: 0.0, y: 0.0), tangent2End: CGPoint(x: 0.0, y: radius), radius: radius)
path.addLine(to: CGPoint(x: 0.0, y: size.height - notchSize.height - radius))
path.addArc(tangent1End: CGPoint(x: 0.0, y: size.height - notchSize.height), tangent2End: CGPoint(x: radius, y: size.height - notchSize.height), radius: radius)
let notchBase = CGPoint(x: min(size.width - radius - notchSize.width, max(radius, floor(relativePositionX - notchSize.width / 2.0))), y: size.height - notchSize.height)
path.addLine(to: notchBase)
path.addCurve(to: CGPoint(x: notchBase.x + 7.49968, y: notchBase.y + 5.32576), control1: CGPoint(x: notchBase.x + 2.10085, y: notchBase.y + 0.0), control2: CGPoint(x: notchBase.x + 5.41005, y: notchBase.y + 3.11103))
path.addCurve(to: CGPoint(x: notchBase.x + 8.95665, y: notchBase.y + 6.61485), control1: CGPoint(x: notchBase.x + 8.2352, y: notchBase.y + 6.10531), control2: CGPoint(x: notchBase.x + 8.60297, y: notchBase.y + 6.49509))
path.addCurve(to: CGPoint(x: notchBase.x + 9.91544, y: notchBase.y + 6.61599), control1: CGPoint(x: notchBase.x + 9.29432, y: notchBase.y + 6.72919), control2: CGPoint(x: notchBase.x + 9.5775, y: notchBase.y + 6.72953))
path.addCurve(to: CGPoint(x: notchBase.x + 11.3772, y: notchBase.y + 5.32853), control1: CGPoint(x: notchBase.x + 10.2694, y: notchBase.y + 6.49707), control2: CGPoint(x: notchBase.x + 10.6387, y: notchBase.y + 6.10756))
path.addCurve(to: CGPoint(x: notchBase.x + 19.0, y: notchBase.y + 0.0), control1: CGPoint(x: notchBase.x + 13.477, y: notchBase.y + 3.11363), control2: CGPoint(x: notchBase.x + 16.817, y: notchBase.y + 0.0))
path.addLine(to: CGPoint(x: size.width - radius, y: size.height - notchSize.height))
path.addArc(tangent1End: CGPoint(x: size.width, y: size.height - notchSize.height), tangent2End: CGPoint(x: size.width, y: size.height - notchSize.height - radius), radius: radius)
path.addLine(to: CGPoint(x: size.width, y: radius))
path.addArc(tangent1End: CGPoint(x: size.width, y: 0.0), tangent2End: CGPoint(x: size.width - radius, y: 0.0), radius: radius)
path.addLine(to: CGPoint(x: radius, y: 0.0))
self.shadowLayer.shadowPath = path
self.shadowLayer.frame = CGRect(origin: CGPoint(), size: size)
self.blurView.frame = CGRect(origin: CGPoint(), size: size)
self.blurView.update(size: size, transition: .immediate)
self.backgroundLayer.frame = CGRect(origin: CGPoint(), size: size)
self.backgroundLayer.path = path
//self.blurView.shadowPath = path
}
func update(component: EmojiSuggestionsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
let height: CGFloat = 54.0
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.backgroundColor, transition: .immediate)
}
var resetScrollingPosition = false
if self.component?.files != component.files {
resetScrollingPosition = true
}
self.component = component
let itemLayout = ItemLayout(itemCount: component.files.count)
self.itemLayout = itemLayout
let size = CGSize(width: min(availableSize.width, itemLayout.contentSize.width), height: height)
let scrollFrame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: itemLayout.contentSize.height))
self.ignoreScrolling = true
if self.scrollView.frame != scrollFrame {
self.scrollView.frame = scrollFrame
}
if self.scrollView.contentSize != itemLayout.contentSize {
self.scrollView.contentSize = itemLayout.contentSize
}
if resetScrollingPosition {
self.scrollView.contentOffset = CGPoint()
}
self.ignoreScrolling = false
self.updateVisibleItems(synchronousLoad: resetScrollingPosition)
return size
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
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
}
}