GIFs in media editor

Image search in media editor
This commit is contained in:
Ilya Laktyushin 2023-08-07 08:38:02 +02:00
parent 9ddb8fa2d6
commit b71304a3d2
24 changed files with 1231 additions and 686 deletions

View File

@ -900,7 +900,7 @@ public protocol SharedAccountContext: AnyObject {
func makeStickerPackScreen(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?, mainStickerPack: StickerPackReference, stickerPacks: [StickerPackReference], loadedStickerPacks: [LoadedStickerPack], parentNavigationController: NavigationController?, sendSticker: ((FileMediaReference, UIView, CGRect) -> Bool)?) -> ViewController func makeStickerPackScreen(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?, mainStickerPack: StickerPackReference, stickerPacks: [StickerPackReference], loadedStickerPacks: [LoadedStickerPack], parentNavigationController: NavigationController?, sendSticker: ((FileMediaReference, UIView, CGRect) -> Bool)?) -> ViewController
func makeMediaPickerScreen(context: AccountContext, completion: @escaping (Any) -> Void) -> ViewController func makeMediaPickerScreen(context: AccountContext, hasSearch: Bool, completion: @escaping (Any) -> Void) -> ViewController
func makeStoryMediaPickerScreen(context: AccountContext, getSourceRect: @escaping () -> CGRect, completion: @escaping (Any, UIView, CGRect, UIImage?, @escaping (Bool?) -> (UIView, CGRect)?, @escaping () -> Void) -> Void, dismissed: @escaping () -> Void) -> ViewController func makeStoryMediaPickerScreen(context: AccountContext, getSourceRect: @escaping () -> CGRect, completion: @escaping (Any, UIView, CGRect, UIImage?, @escaping (Bool?) -> (UIView, CGRect)?, @escaping () -> Void) -> Void, dismissed: @escaping () -> Void) -> ViewController

View File

@ -98,6 +98,9 @@ swift_library(
"//submodules/StickerPackPreviewUI:StickerPackPreviewUI", "//submodules/StickerPackPreviewUI:StickerPackPreviewUI",
"//submodules/TelegramUI/Components/LottieComponent", "//submodules/TelegramUI/Components/LottieComponent",
"//submodules/ImageTransparency", "//submodules/ImageTransparency",
"//submodules/GalleryUI",
"//submodules/MediaPlayer:UniversalMediaPlayer",
"//submodules/TelegramUniversalVideoContent",
], ],
visibility = [ visibility = [
"//visibility:public", "//visibility:public",

View File

@ -21,30 +21,13 @@ public final class DrawingMediaEntityView: DrawingEntityView, DrawingEntityMedia
if let previewView = self.previewView { if let previewView = self.previewView {
previewView.isUserInteractionEnabled = false previewView.isUserInteractionEnabled = false
previewView.layer.allowsEdgeAntialiasing = true previewView.layer.allowsEdgeAntialiasing = true
if self.additionalView == nil {
self.addSubview(previewView) self.addSubview(previewView)
}
} else { } else {
oldValue?.removeFromSuperview() oldValue?.removeFromSuperview()
} }
} }
} }
public var additionalView: DrawingStickerEntityView.VideoView? {
didSet {
if let additionalView = self.additionalView {
self.addSubview(additionalView)
} else {
if let previous = oldValue, previous.superview === self {
previous.removeFromSuperview()
}
if let previewView = self.previewView {
self.addSubview(previewView)
}
}
}
}
private let snapTool = DrawingEntitySnapTool() private let snapTool = DrawingEntitySnapTool()
init(context: AccountContext, entity: DrawingMediaEntity) { init(context: AccountContext, entity: DrawingMediaEntity) {
@ -113,14 +96,8 @@ public final class DrawingMediaEntityView: DrawingEntityView, DrawingEntityMedia
if self.previewView?.superview === self { if self.previewView?.superview === self {
self.previewView?.frame = CGRect(origin: .zero, size: size) self.previewView?.frame = CGRect(origin: .zero, size: size)
} }
if let additionalView = self.additionalView, additionalView.superview === self {
additionalView.frame = CGRect(origin: .zero, size: size)
}
self.update(animated: false) self.update(animated: false)
} }
if let additionalView = self.additionalView, additionalView.superview === self {
self.additionalView?.frame = self.bounds
}
} }
public var updated: (() -> Void)? public var updated: (() -> Void)?

View File

@ -759,7 +759,7 @@ private final class DrawingScreenComponent: CombinedComponent {
emojiItems, emojiItems,
stickerItems stickerItems
) |> map { emoji, stickers -> StickerPickerInputData in ) |> map { emoji, stickers -> StickerPickerInputData in
return StickerPickerInputData(emoji: emoji, stickers: stickers, masks: nil) return StickerPickerInputData(emoji: emoji, stickers: stickers, gifs: nil)
} }
stickerPickerInputData.set(signal) stickerPickerInputData.set(signal)
@ -3057,9 +3057,7 @@ public final class DrawingToolsInteraction {
var isVideo = false var isVideo = false
if let entity = entityView.entity as? DrawingStickerEntity { if let entity = entityView.entity as? DrawingStickerEntity {
if case .video = entity.content { if case .dualVideoReference = entity.content {
isVideo = true
} else if case .dualVideoReference = entity.content {
isVideo = true isVideo = true
} }
} }

View File

@ -1,5 +1,6 @@
import Foundation import Foundation
import UIKit import UIKit
import AsyncDisplayKit
import AVFoundation import AVFoundation
import Display import Display
import SwiftSignalKit import SwiftSignalKit
@ -9,31 +10,10 @@ import TelegramAnimatedStickerNode
import StickerResources import StickerResources
import AccountContext import AccountContext
import MediaEditor import MediaEditor
import UniversalMediaPlayer
import TelegramUniversalVideoContent
public final class DrawingStickerEntityView: DrawingEntityView { public final class DrawingStickerEntityView: DrawingEntityView {
public class VideoView: UIView {
init(player: AVPlayer) {
super.init(frame: .zero)
self.videoLayer.player = player
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var videoLayer: AVPlayerLayer {
guard let layer = self.layer as? AVPlayerLayer else {
fatalError()
}
return layer
}
public override class var layerClass: AnyClass {
return AVPlayerLayer.self
}
}
private var stickerEntity: DrawingStickerEntity { private var stickerEntity: DrawingStickerEntity {
return self.entity as! DrawingStickerEntity return self.entity as! DrawingStickerEntity
} }
@ -46,26 +26,7 @@ public final class DrawingStickerEntityView: DrawingEntityView {
private let imageNode: TransformImageNode private let imageNode: TransformImageNode
private var animationNode: AnimatedStickerNode? private var animationNode: AnimatedStickerNode?
private var videoNode: UniversalVideoNode?
private var videoContainerView: UIView?
private var videoPlayer: AVPlayer?
public var videoView: VideoView?
private var videoImageView: UIImageView?
public var mainView: MediaEditorPreviewView? {
didSet {
if let mainView = self.mainView {
self.videoContainerView?.addSubview(mainView)
} else {
if let previous = oldValue, previous.superview === self {
previous.removeFromSuperview()
}
if let videoView = self.videoView {
self.videoContainerView?.addSubview(videoView)
}
}
}
}
private var didSetUpAnimationNode = false private var didSetUpAnimationNode = false
private let stickerFetchedDisposable = MetaDisposable() private let stickerFetchedDisposable = MetaDisposable()
@ -109,9 +70,9 @@ public final class DrawingStickerEntityView: DrawingEntityView {
} }
} }
private var video: String? { private var video: TelegramMediaFile? {
if case let .video(path, _, _) = self.stickerEntity.content { if case let .video(file) = self.stickerEntity.content {
return path return file
} else { } else {
return nil return nil
} }
@ -123,13 +84,8 @@ public final class DrawingStickerEntityView: DrawingEntityView {
return file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0) return file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0)
case let .image(image, _): case let .image(image, _):
return image.size return image.size
case let .video(_, image, _): case let .video(file):
if let image { return file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0)
let minSide = min(image.size.width, image.size.height)
return CGSize(width: minSide, height: minSide)
} else {
return CGSize(width: 512.0, height: 512.0)
}
case .dualVideoReference: case .dualVideoReference:
return CGSize(width: 512.0, height: 512.0) return CGSize(width: 512.0, height: 512.0)
} }
@ -220,30 +176,46 @@ public final class DrawingStickerEntityView: DrawingEntityView {
return context return context
}), attemptSynchronously: synchronous) }), attemptSynchronously: synchronous)
self.setNeedsLayout() self.setNeedsLayout()
} else if case let .video(videoPath, image, _) = self.stickerEntity.content { } else if case let .video(file) = self.stickerEntity.content {
let url = URL(fileURLWithPath: videoPath) let videoNode = UniversalVideoNode(
let asset = AVURLAsset(url: url) postbox: self.context.account.postbox,
let playerItem = AVPlayerItem(asset: asset) audioSession: self.context.sharedContext.mediaManager.audioSession,
let player = AVPlayer(playerItem: playerItem) manager: self.context.sharedContext.mediaManager.universalVideoManager,
player.automaticallyWaitsToMinimizeStalling = false decoration: StickerVideoDecoration(),
content: NativeVideoContent(
let videoContainerView = UIView() id: .contextResult(0, "\(UInt64.random(in: 0 ... UInt64.max))"),
videoContainerView.clipsToBounds = true userLocation: .other,
fileReference: .standalone(media: file),
let videoView = VideoView(player: player) imageReference: nil,
videoContainerView.addSubview(videoView) streamVideo: .story,
loopVideo: true,
self.addSubview(videoContainerView) enableSound: false,
soundMuted: true,
self.videoPlayer = player beginWithAmbientSound: false,
self.videoContainerView = videoContainerView mixWithOthers: true,
self.videoView = videoView useLargeThumbnail: false,
autoFetchFullSizeThumbnail: false,
let imageView = UIImageView(image: image) tempFilePath: nil,
imageView.clipsToBounds = true captureProtected: false,
imageView.contentMode = .scaleAspectFill hintDimensions: file.dimensions?.cgSize,
videoContainerView.addSubview(imageView) storeAfterDownload: nil,
self.videoImageView = imageView displayImage: false,
hasSentFramesToDisplay: { [weak self] in
guard let self else {
return
}
self.videoNode?.isHidden = false
}
),
priority: .gallery
)
videoNode.canAttachContent = true
videoNode.isUserInteractionEnabled = false
videoNode.cornerRadius = floor(CGFloat(file.dimensions?.width ?? 512) * 0.03)
videoNode.clipsToBounds = true
self.addSubnode(videoNode)
self.videoNode = videoNode
self.setNeedsLayout()
} }
} }
@ -251,27 +223,14 @@ public final class DrawingStickerEntityView: DrawingEntityView {
self.isVisible = true self.isVisible = true
self.applyVisibility() self.applyVisibility()
if let player = self.videoPlayer { self.videoNode?.play()
player.play()
if let videoImageView = self.videoImageView {
self.videoImageView = nil
Queue.mainQueue().after(0.1) {
videoImageView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak videoImageView] _ in
videoImageView?.removeFromSuperview()
})
}
}
}
} }
public override func pause() { public override func pause() {
self.isVisible = false self.isVisible = false
self.applyVisibility() self.applyVisibility()
if let player = self.videoPlayer { self.videoNode?.pause()
player.pause()
}
} }
public override func seek(to timestamp: Double) { public override func seek(to timestamp: Double) {
@ -279,9 +238,7 @@ public final class DrawingStickerEntityView: DrawingEntityView {
self.isPlaying = false self.isPlaying = false
self.animationNode?.seekTo(.timestamp(timestamp)) self.animationNode?.seekTo(.timestamp(timestamp))
if let player = self.videoPlayer { self.videoNode?.seek(timestamp)
player.seek(to: CMTime(seconds: timestamp, preferredTimescale: CMTimeScale(60.0)), toleranceBefore: .zero, toleranceAfter: .zero, completionHandler: { _ in })
}
} }
override func resetToStart() { override func resetToStart() {
@ -343,16 +300,10 @@ public final class DrawingStickerEntityView: DrawingEntityView {
} }
} }
if let videoView = self.videoView { if let videoNode = self.videoNode {
let videoSize = CGSize(width: imageFrame.width, height: imageFrame.width / 9.0 * 16.0) let videoSize = self.dimensions.aspectFitted(boundingSize)
videoView.frame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((imageFrame.height - videoSize.height) / 2.0)), size: videoSize) videoNode.frame = CGRect(origin: CGPoint(x: floor((size.width - videoSize.width) * 0.5), y: floor((size.height - videoSize.height) * 0.5)), size: videoSize)
} videoNode.updateLayout(size: videoSize, transition: .immediate)
if let videoContainerView = self.videoContainerView {
videoContainerView.layer.cornerRadius = imageFrame.width / 2.0
videoContainerView.frame = imageFrame
}
if let videoImageView = self.videoImageView {
videoImageView.frame = CGRect(origin: .zero, size: imageFrame.size)
} }
self.update(animated: false) self.update(animated: false)
@ -386,18 +337,18 @@ public final class DrawingStickerEntityView: DrawingEntityView {
UIView.animate(withDuration: 0.25, animations: { UIView.animate(withDuration: 0.25, animations: {
self.imageNode.transform = animationTargetTransform self.imageNode.transform = animationTargetTransform
self.animationNode?.transform = animationTargetTransform self.animationNode?.transform = animationTargetTransform
self.videoContainerView?.layer.transform = animationTargetTransform self.videoNode?.transform = animationTargetTransform
}, completion: { finished in }, completion: { finished in
self.imageNode.transform = staticTransform self.imageNode.transform = staticTransform
self.animationNode?.transform = staticTransform self.animationNode?.transform = staticTransform
self.videoContainerView?.layer.transform = staticTransform self.videoNode?.transform = staticTransform
}) })
} else { } else {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
self.imageNode.transform = staticTransform self.imageNode.transform = staticTransform
self.animationNode?.transform = staticTransform self.animationNode?.transform = staticTransform
self.videoContainerView?.layer.transform = staticTransform self.videoNode?.transform = staticTransform
CATransaction.commit() CATransaction.commit()
} }
@ -713,3 +664,117 @@ final class DrawingStickerEntititySelectionView: DrawingEntitySelectionView {
self.rightHandle.position = CGPoint(x: self.bounds.maxX - actualInset, y: self.bounds.midY) self.rightHandle.position = CGPoint(x: self.bounds.maxX - actualInset, y: self.bounds.midY)
} }
} }
private final class StickerVideoDecoration: UniversalVideoDecoration {
public let backgroundNode: ASDisplayNode? = nil
public let contentContainerNode: ASDisplayNode
public let foregroundNode: ASDisplayNode? = nil
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
private var validLayoutSize: CGSize?
public init() {
self.contentContainerNode = ASDisplayNode()
}
public func updateContentNode(_ contentNode: (UniversalVideoContentNode & ASDisplayNode)?) {
if self.contentNode !== contentNode {
let previous = self.contentNode
self.contentNode = contentNode
if let previous = previous {
if previous.supernode === self.contentContainerNode {
previous.removeFromSupernode()
}
}
if let contentNode = contentNode {
if contentNode.supernode !== self.contentContainerNode {
self.contentContainerNode.addSubnode(contentNode)
if let validLayoutSize = self.validLayoutSize {
contentNode.frame = CGRect(origin: CGPoint(), size: validLayoutSize)
contentNode.updateLayout(size: validLayoutSize, transition: .immediate)
}
}
}
}
}
public func updateCorners(_ corners: ImageCorners) {
self.contentContainerNode.clipsToBounds = true
if isRoundEqualCorners(corners) {
self.contentContainerNode.cornerRadius = corners.topLeft.radius
} else {
let boundingSize: CGSize = CGSize(width: max(corners.topLeft.radius, corners.bottomLeft.radius) + max(corners.topRight.radius, corners.bottomRight.radius), height: max(corners.topLeft.radius, corners.topRight.radius) + max(corners.bottomLeft.radius, corners.bottomRight.radius))
let size: CGSize = CGSize(width: boundingSize.width + corners.extendedEdges.left + corners.extendedEdges.right, height: boundingSize.height + corners.extendedEdges.top + corners.extendedEdges.bottom)
let arguments = TransformImageArguments(corners: corners, imageSize: size, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets())
guard let context = DrawingContext(size: size, clear: true) else {
return
}
context.withContext { ctx in
ctx.setFillColor(UIColor.black.cgColor)
ctx.fill(arguments.drawingRect)
}
addCorners(context, arguments: arguments)
if let maskImage = context.generateImage() {
let mask = CALayer()
mask.contents = maskImage.cgImage
mask.contentsScale = maskImage.scale
mask.contentsCenter = CGRect(x: max(corners.topLeft.radius, corners.bottomLeft.radius) / maskImage.size.width, y: max(corners.topLeft.radius, corners.topRight.radius) / maskImage.size.height, width: (maskImage.size.width - max(corners.topLeft.radius, corners.bottomLeft.radius) - max(corners.topRight.radius, corners.bottomRight.radius)) / maskImage.size.width, height: (maskImage.size.height - max(corners.topLeft.radius, corners.topRight.radius) - max(corners.bottomLeft.radius, corners.bottomRight.radius)) / maskImage.size.height)
self.contentContainerNode.layer.mask = mask
self.contentContainerNode.layer.mask?.frame = self.contentContainerNode.bounds
}
}
}
public func updateClippingFrame(_ frame: CGRect, completion: (() -> Void)?) {
self.contentContainerNode.layer.animate(from: NSValue(cgRect: self.contentContainerNode.bounds), to: NSValue(cgRect: frame), keyPath: "bounds", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
})
if let maskLayer = self.contentContainerNode.layer.mask {
maskLayer.animate(from: NSValue(cgRect: self.contentContainerNode.bounds), to: NSValue(cgRect: frame), keyPath: "bounds", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
})
maskLayer.animate(from: NSValue(cgPoint: maskLayer.position), to: NSValue(cgPoint: frame.center), keyPath: "position", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
})
}
if let contentNode = self.contentNode {
contentNode.layer.animate(from: NSValue(cgPoint: contentNode.layer.position), to: NSValue(cgPoint: frame.center), keyPath: "position", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
completion?()
})
}
}
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
}
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
self.validLayoutSize = size
let bounds = CGRect(origin: CGPoint(), size: size)
if let backgroundNode = self.backgroundNode {
transition.updateFrame(node: backgroundNode, frame: bounds)
}
if let foregroundNode = self.foregroundNode {
transition.updateFrame(node: foregroundNode, frame: bounds)
}
transition.updateFrame(node: self.contentContainerNode, frame: bounds)
if let maskLayer = self.contentContainerNode.layer.mask {
transition.updateFrame(layer: maskLayer, frame: bounds)
}
if let contentNode = self.contentNode {
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
contentNode.updateLayout(size: size, transition: transition)
}
}
public func setStatus(_ status: Signal<MediaPlayerStatus?, NoError>) {
}
public func tap() {
}
}

View File

@ -18,20 +18,23 @@ import ContextUI
import ChatPresentationInterfaceState import ChatPresentationInterfaceState
import MediaEditor import MediaEditor
import StickerPackPreviewUI import StickerPackPreviewUI
import EntityKeyboardGifContent
import GalleryUI
import UndoUI
public struct StickerPickerInputData: Equatable { public struct StickerPickerInputData: Equatable {
var emoji: EmojiPagerContentComponent var emoji: EmojiPagerContentComponent
var stickers: EmojiPagerContentComponent? var stickers: EmojiPagerContentComponent?
var masks: EmojiPagerContentComponent? var gifs: GifPagerContentComponent?
public init( public init(
emoji: EmojiPagerContentComponent, emoji: EmojiPagerContentComponent,
stickers: EmojiPagerContentComponent?, stickers: EmojiPagerContentComponent?,
masks: EmojiPagerContentComponent? gifs: GifPagerContentComponent?
) { ) {
self.emoji = emoji self.emoji = emoji
self.stickers = stickers self.stickers = stickers
self.masks = masks self.gifs = gifs
} }
} }
@ -95,9 +98,18 @@ private final class StickerSelectionComponent: Component {
return true return true
} }
final class KeyboardClippingView: UIView {
var hitEdgeInsets: UIEdgeInsets = .zero
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let bounds = self.bounds.inset(by: self.hitEdgeInsets)
return bounds.contains(point)
}
}
public final class View: UIView { public final class View: UIView {
fileprivate let keyboardView: ComponentView<Empty> fileprivate let keyboardView: ComponentView<Empty>
private let keyboardClippingView: UIView private let keyboardClippingView: KeyboardClippingView
private let panelHostView: PagerExternalTopPanelContainer private let panelHostView: PagerExternalTopPanelContainer
private let panelBackgroundView: BlurredBackgroundView private let panelBackgroundView: BlurredBackgroundView
private let panelSeparatorView: UIView private let panelSeparatorView: UIView
@ -107,18 +119,20 @@ private final class StickerSelectionComponent: Component {
private var interaction: ChatEntityKeyboardInputNode.Interaction? private var interaction: ChatEntityKeyboardInputNode.Interaction?
private var inputNodeInteraction: ChatMediaInputNodeInteraction? private var inputNodeInteraction: ChatMediaInputNodeInteraction?
private let trendingGifsPromise = Promise<ChatMediaInputGifPaneTrendingState?>(nil)
private var searchVisible = false private var searchVisible = false
private var forceUpdate = false private var forceUpdate = false
private var ignoreNextZeroScrollingOffset = false
private var topPanelScrollingOffset: CGFloat = 0.0 private var topPanelScrollingOffset: CGFloat = 0.0
private var keyboardContentId: AnyHashable?
override init(frame: CGRect) { override init(frame: CGRect) {
self.keyboardView = ComponentView<Empty>() self.keyboardView = ComponentView<Empty>()
self.keyboardClippingView = UIView() self.keyboardClippingView = KeyboardClippingView()
self.panelHostView = PagerExternalTopPanelContainer() self.panelHostView = PagerExternalTopPanelContainer()
self.panelBackgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) self.panelBackgroundView = BlurredBackgroundView(color: .clear, enableBlur: true)
self.panelBackgroundView.isUserInteractionEnabled = false
self.panelSeparatorView = UIView() self.panelSeparatorView = UIView()
super.init(frame: frame) super.init(frame: frame)
@ -210,6 +224,12 @@ private final class StickerSelectionComponent: Component {
deinit { deinit {
} }
func scrolledToItemGroup() {
self.topPanelScrollingOffset = 30.0
self.ignoreNextZeroScrollingOffset = true
self.state?.updated(transition: .easeInOut(duration: 0.2))
}
func update(component: StickerSelectionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: Transition) -> CGSize { func update(component: StickerSelectionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: Transition) -> CGSize {
self.backgroundColor = component.backgroundColor self.backgroundColor = component.backgroundColor
let panelBackgroundColor = component.backgroundColor.withMultipliedAlpha(0.85) let panelBackgroundColor = component.backgroundColor.withMultipliedAlpha(0.85)
@ -236,7 +256,6 @@ private final class StickerSelectionComponent: Component {
} }
) )
let trendingGifsPromise = self.trendingGifsPromise
let keyboardSize = self.keyboardView.update( let keyboardSize = self.keyboardView.update(
transition: transition.withUserData(EmojiPagerContentComponent.SynchronousLoadBehavior(isDisabled: true)), transition: transition.withUserData(EmojiPagerContentComponent.SynchronousLoadBehavior(isDisabled: true)),
component: AnyComponent(EntityKeyboardComponent( component: AnyComponent(EntityKeyboardComponent(
@ -247,9 +266,9 @@ private final class StickerSelectionComponent: Component {
topPanelInsets: UIEdgeInsets(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0), topPanelInsets: UIEdgeInsets(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0),
emojiContent: component.content.emoji, emojiContent: component.content.emoji,
stickerContent: component.content.stickers, stickerContent: component.content.stickers,
maskContent: component.content.masks, maskContent: nil,
gifContent: nil, gifContent: component.content.gifs,
hasRecentGifs: false, hasRecentGifs: true,
availableGifSearchEmojies: [], availableGifSearchEmojies: [],
defaultToEmojiTab: defaultToEmoji, defaultToEmojiTab: defaultToEmoji,
externalTopPanelContainer: self.panelHostView, externalTopPanelContainer: self.panelHostView,
@ -259,8 +278,12 @@ private final class StickerSelectionComponent: Component {
}, },
topPanelScrollingOffset: { [weak self] offset, transition in topPanelScrollingOffset: { [weak self] offset, transition in
if let self { if let self {
if self.ignoreNextZeroScrollingOffset && offset == 0.0 {
self.ignoreNextZeroScrollingOffset = false
} else {
self.topPanelScrollingOffset = offset self.topPanelScrollingOffset = offset
} }
}
}, },
hideInputUpdated: { [weak self] _, searchVisible, transition in hideInputUpdated: { [weak self] _, searchVisible, transition in
guard let self else { guard let self else {
@ -297,14 +320,19 @@ private final class StickerSelectionComponent: Component {
inputNodeInteraction: inputNodeInteraction, inputNodeInteraction: inputNodeInteraction,
mode: mappedMode, mode: mappedMode,
stickerActionTitle: presentationData.strings.StickerPack_AddSticker, stickerActionTitle: presentationData.strings.StickerPack_AddSticker,
trendingGifsPromise: trendingGifsPromise, trendingGifsPromise: Promise(nil),
cancel: { cancel: {
}, },
peekBehavior: stickerPeekBehavior peekBehavior: stickerPeekBehavior
) )
return searchContainerNode return searchContainerNode
}, },
contentIdUpdated: { _ in }, contentIdUpdated: { [weak self] id in
guard let self else {
return
}
self.keyboardContentId = id
},
deviceMetrics: component.deviceMetrics, deviceMetrics: component.deviceMetrics,
hiddenInputHeight: 0.0, hiddenInputHeight: 0.0,
inputHeight: 0.0, inputHeight: 0.0,
@ -330,6 +358,7 @@ private final class StickerSelectionComponent: Component {
} }
transition.setFrame(view: self.keyboardClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight), size: CGSize(width: availableSize.width, height: availableSize.height - topPanelHeight))) transition.setFrame(view: self.keyboardClippingView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight), size: CGSize(width: availableSize.width, height: availableSize.height - topPanelHeight)))
self.keyboardClippingView.hitEdgeInsets = UIEdgeInsets(top: -topPanelHeight, left: 0.0, bottom: 0.0, right: 0.0)
transition.setFrame(view: keyboardComponentView, frame: CGRect(origin: CGPoint(x: 0.0, y: -topPanelHeight), size: keyboardSize)) transition.setFrame(view: keyboardComponentView, frame: CGRect(origin: CGPoint(x: 0.0, y: -topPanelHeight), size: keyboardSize))
transition.setFrame(view: self.panelHostView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight - 34.0), size: CGSize(width: keyboardSize.width, height: 0.0))) transition.setFrame(view: self.panelHostView, frame: CGRect(origin: CGPoint(x: 0.0, y: topPanelHeight - 34.0), size: CGSize(width: keyboardSize.width, height: 0.0)))
@ -338,7 +367,7 @@ private final class StickerSelectionComponent: Component {
self.panelBackgroundView.update(size: self.panelBackgroundView.bounds.size, transition: transition.containedViewLayoutTransition) self.panelBackgroundView.update(size: self.panelBackgroundView.bounds.size, transition: transition.containedViewLayoutTransition)
let topPanelAlpha: CGFloat let topPanelAlpha: CGFloat
if self.searchVisible { if self.searchVisible || self.keyboardContentId == AnyHashable("gifs") {
topPanelAlpha = 0.0 topPanelAlpha = 0.0
} else { } else {
topPanelAlpha = max(0.0, min(1.0, (self.topPanelScrollingOffset / 20.0))) topPanelAlpha = max(0.0, min(1.0, (self.topPanelScrollingOffset / 20.0)))
@ -386,6 +415,8 @@ public class StickerPickerScreen: ViewController {
private var content: StickerPickerInputData? private var content: StickerPickerInputData?
private let contentDisposable = MetaDisposable() private let contentDisposable = MetaDisposable()
private var hasRecentGifsDisposable: Disposable?
private let trendingGifsPromise = Promise<ChatMediaInputGifPaneTrendingState?>(nil)
private var scheduledEmojiContentAnimationHint: EmojiPagerContentComponent.ContentAnimation? private var scheduledEmojiContentAnimationHint: EmojiPagerContentComponent.ContentAnimation?
private(set) var isExpanded = false private(set) var isExpanded = false
@ -397,6 +428,24 @@ public class StickerPickerScreen: ViewController {
fileprivate var temporaryDismiss = false fileprivate var temporaryDismiss = false
private var gifMode: GifPagerContentComponent.Subject? {
didSet {
if let gifMode = self.gifMode, gifMode != oldValue {
self.reloadGifContext()
}
}
}
private var gifContext: GifContext? {
didSet {
if let gifContext = self.gifContext {
self.gifComponent.set(gifContext.component)
}
}
}
private let gifComponent = Promise<EntityKeyboardGifContent>()
private var gifInputInteraction: GifPagerContentComponent.InputInteraction?
private struct EmojiSearchResult { private struct EmojiSearchResult {
var groups: [EmojiPagerContentComponent.ItemGroup] var groups: [EmojiPagerContentComponent.ItemGroup]
var id: AnyHashable var id: AnyHashable
@ -464,15 +513,18 @@ public class StickerPickerScreen: ViewController {
let data = combineLatest( let data = combineLatest(
queue: Queue.mainQueue(), queue: Queue.mainQueue(),
controller.inputData, controller.inputData,
.single(nil) |> then(self.gifComponent.get() |> map(Optional.init)),
self.stickerSearchState.get(), self.stickerSearchState.get(),
self.emojiSearchState.get() self.emojiSearchState.get()
) )
self.contentDisposable.set(data.start(next: { [weak self] inputData, stickerSearchState, emojiSearchState in self.contentDisposable.set(data.start(next: { [weak self] inputData, gifData, stickerSearchState, emojiSearchState in
if let strongSelf = self { if let strongSelf = self {
let presentationData = strongSelf.presentationData let presentationData = strongSelf.presentationData
var inputData = inputData var inputData = inputData
inputData.gifs = gifData?.component
let emoji = inputData.emoji let emoji = inputData.emoji
if let emojiSearchResult = emojiSearchState.result { if let emojiSearchResult = emojiSearchState.result {
var emptySearchResults: EmojiPagerContentComponent.EmptySearchResults? var emptySearchResults: EmojiPagerContentComponent.EmptySearchResults?
@ -509,12 +561,183 @@ public class StickerPickerScreen: ViewController {
strongSelf.updateContent(inputData) strongSelf.updateContent(inputData)
} }
})) }))
if controller.hasGifs {
let hasRecentGifs = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs))
|> map { savedGifs -> Bool in
return !savedGifs.isEmpty
}
self.hasRecentGifsDisposable = (hasRecentGifs
|> deliverOnMainQueue).start(next: { [weak self] hasRecentGifs in
guard let strongSelf = self else {
return
}
if let gifMode = strongSelf.gifMode {
if !hasRecentGifs, case .recent = gifMode {
strongSelf.gifMode = .trending
}
} else {
strongSelf.gifMode = hasRecentGifs ? .recent : .trending
}
})
}
self.gifInputInteraction = GifPagerContentComponent.InputInteraction(
performItemAction: { [weak self] item, view, rect in
guard let self else {
return
}
self.controller?.completion(.video(item.file.media))
self.controller?.dismiss(animated: true)
},
openGifContextMenu: { [weak self] item, sourceView, sourceRect, gesture, isSaved in
guard let self else {
return
}
self.openGifContextMenu(file: item.file, contextResult: item.contextResult, sourceView: sourceView, sourceRect: sourceRect, gesture: gesture, isSaved: isSaved)
},
loadMore: { [weak self] token in
guard let strongSelf = self, let gifContext = strongSelf.gifContext else {
return
}
gifContext.loadMore(token: token)
},
openSearch: { [weak self] in
if let self, let componentView = self.hostView.componentView as? StickerSelectionComponent.View {
if let pagerView = componentView.keyboardView.view as? EntityKeyboardComponent.View {
pagerView.openSearch()
}
self.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring))
}
},
updateSearchQuery: { [weak self] query in
guard let self else {
return
}
if let query {
self.gifMode = .emojiSearch(query)
} else {
self.gifMode = .recent
}
},
hideBackground: true,
hasSearch: true
)
} }
deinit { deinit {
self.contentDisposable.dispose() self.contentDisposable.dispose()
self.emojiSearchDisposable.dispose() self.emojiSearchDisposable.dispose()
self.stickerSearchDisposable.dispose() self.stickerSearchDisposable.dispose()
self.hasRecentGifsDisposable?.dispose()
}
private func reloadGifContext() {
if let gifInputInteraction = self.gifInputInteraction, let gifMode = self.gifMode, let context = self.controller?.context {
self.gifContext = GifContext(context: context, subject: gifMode, gifInputInteraction: gifInputInteraction, trendingGifs: self.trendingGifsPromise.get())
}
}
private func openGifContextMenu(file: FileMediaReference, contextResult: (ChatContextResultCollection, ChatContextResult)?, sourceView: UIView, sourceRect: CGRect, gesture: ContextGesture, isSaved: Bool) {
guard let controller = self.controller else {
return
}
let context = controller.context
let canSaveGif: Bool
if file.media.fileId.namespace == Namespaces.Media.CloudFile {
canSaveGif = true
} else {
canSaveGif = false
}
let _ = (context.engine.stickers.isGifSaved(id: file.media.fileId)
|> deliverOnMainQueue).start(next: { [weak self] isGifSaved in
var isGifSaved = isGifSaved
if !canSaveGif {
isGifSaved = false
}
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let message = Message(stableId: 0, stableVersion: 0, id: MessageId(peerId: PeerId(0), namespace: Namespaces.Message.Local, id: 0), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [], tags: [], globalTags: [], localTags: [], forwardInfo: nil, author: nil, text: "", attributes: [], media: [file.media], peers: SimpleDictionary(), associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:])
let gallery = GalleryController(context: context, source: .standaloneMessage(message), streamSingleVideo: true, replaceRootController: { _, _ in
}, baseNavigationController: nil)
gallery.setHintWillBePresentedInPreviewingContext(true)
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: { [weak self] _, f in
f(.default)
if let self {
if isSaved {
self.controller?.completion(.video(file.media))
self.controller?.dismiss(animated: true)
} else {
}
// 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)
// }
}
})))
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
f(.dismissWithoutContent)
let _ = removeSavedGif(postbox: 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: { [weak self] _, f in
f(.dismissWithoutContent)
guard let self else {
return
}
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let _ = (toggleGifSaved(account: context.account, fileReference: file, saved: true)
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let controller = self?.controller else {
return
}
switch result {
case .generic:
controller.present(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 }), in: .window(.root))
case let .limitExceeded(limit, premiumLimit):
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
let text: String
if limit == premiumLimit || premiumConfiguration.isPremiumDisabled {
text = presentationData.strings.Premium_MaxSavedGifsFinalText
} else {
text = presentationData.strings.Premium_MaxSavedGifsText("\(premiumLimit)").string
}
controller.present(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: { [weak controller] action in
if case .info = action {
let premiumController = context.sharedContext.makePremiumIntroController(context: context, source: .savedGifs, forceDark: true)
controller?.push(premiumController)
return true
}
return false
}), in: .window(.root))
}
})
})))
}
let contextController = ContextController(account: context.account, presentationData: presentationData, source: .controller(ContextControllerContentSourceImpl(controller: gallery, sourceView: sourceView, sourceRect: sourceRect)), items: .single(ContextController.Items(content: .list(items))), gesture: gesture)
controller.presentInGlobalOverlay(contextController)
})
} }
func updateContent(_ content: StickerPickerInputData) { func updateContent(_ content: StickerPickerInputData) {
@ -922,6 +1145,9 @@ public class StickerPickerScreen: ViewController {
} }
}, },
updateScrollingToItemGroup: { [weak self] in updateScrollingToItemGroup: { [weak self] in
if let self, let componentView = self.hostView.componentView as? StickerSelectionComponent.View {
componentView.scrolledToItemGroup()
}
self?.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring)) self?.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring))
}, },
onScroll: {}, onScroll: {},
@ -1187,6 +1413,9 @@ public class StickerPickerScreen: ViewController {
} }
}, },
updateScrollingToItemGroup: { [weak self] in updateScrollingToItemGroup: { [weak self] in
if let self, let componentView = self.hostView.componentView as? StickerSelectionComponent.View {
componentView.scrolledToItemGroup()
}
self?.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring)) self?.update(isExpanded: true, transition: .animated(duration: 0.4, curve: .spring))
}, },
onScroll: {}, onScroll: {},
@ -1653,6 +1882,7 @@ public class StickerPickerScreen: ViewController {
private let theme: PresentationTheme private let theme: PresentationTheme
private let inputData: Signal<StickerPickerInputData, NoError> private let inputData: Signal<StickerPickerInputData, NoError>
fileprivate let defaultToEmoji: Bool fileprivate let defaultToEmoji: Bool
let hasGifs: Bool
private var currentLayout: ContainerViewLayout? private var currentLayout: ContainerViewLayout?
@ -1664,11 +1894,12 @@ public class StickerPickerScreen: ViewController {
public var presentGallery: () -> Void = { } public var presentGallery: () -> Void = { }
public var presentLocationPicker: () -> Void = { } public var presentLocationPicker: () -> Void = { }
public init(context: AccountContext, inputData: Signal<StickerPickerInputData, NoError>, defaultToEmoji: Bool = false) { public init(context: AccountContext, inputData: Signal<StickerPickerInputData, NoError>, defaultToEmoji: Bool = false, hasGifs: Bool = false) {
self.context = context self.context = context
self.theme = defaultDarkColorPresentationTheme self.theme = defaultDarkColorPresentationTheme
self.inputData = inputData self.inputData = inputData
self.defaultToEmoji = defaultToEmoji self.defaultToEmoji = defaultToEmoji
self.hasGifs = hasGifs
super.init(navigationBarPresentationData: nil) super.init(navigationBarPresentationData: nil)
@ -1809,3 +2040,37 @@ final class StoryStickersContentView: UIView, EmojiCustomContentView {
return size return size
} }
} }
private final class ContextControllerContentSourceImpl: ContextControllerContentSource {
let controller: ViewController
weak var sourceView: UIView?
let sourceRect: CGRect
let navigationController: NavigationController? = nil
let passthroughTouches: Bool = false
init(controller: ViewController, sourceView: UIView?, sourceRect: CGRect) {
self.controller = controller
self.sourceView = sourceView
self.sourceRect = sourceRect
}
func transitionInfo() -> ContextControllerTakeControllerInfo? {
let sourceView = self.sourceView
let sourceRect = self.sourceRect
return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceView] in
if let sourceView = sourceView {
return (sourceView, sourceRect)
} else {
return nil
}
})
}
func animatedIn() {
if let controller = self.controller as? GalleryController {
controller.viewDidAppear(false)
}
}
}

View File

@ -1534,7 +1534,7 @@ public final class MediaPickerScreen: ViewController, AttachmentContainable {
if collection == nil { if collection == nil {
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed)) self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed))
if mode == .story { if mode == .story || mode == .addImage {
self.navigationItem.rightBarButtonItem = UIBarButtonItem(customDisplayNode: self.moreButtonNode) self.navigationItem.rightBarButtonItem = UIBarButtonItem(customDisplayNode: self.moreButtonNode)
self.navigationItem.rightBarButtonItem?.action = #selector(self.rightButtonPressed) self.navigationItem.rightBarButtonItem?.action = #selector(self.rightButtonPressed)
self.navigationItem.rightBarButtonItem?.target = self self.navigationItem.rightBarButtonItem?.target = self
@ -2289,6 +2289,7 @@ public func wallpaperMediaPickerController(
public func mediaPickerController( public func mediaPickerController(
context: AccountContext, context: AccountContext,
hasSearch: Bool,
completion: @escaping (Any) -> Void completion: @escaping (Any) -> Void
) -> ViewController { ) -> ViewController {
let presentationData = context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: defaultDarkColorPresentationTheme) let presentationData = context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: defaultDarkColorPresentationTheme)
@ -2302,6 +2303,26 @@ public func mediaPickerController(
completion(result) completion(result)
controller.dismiss(animated: true) controller.dismiss(animated: true)
} }
if hasSearch {
mediaPickerController.presentWebSearch = { [weak mediaPickerController] groups, activateOnDisplay in
let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.SearchBots())
|> deliverOnMainQueue).start(next: { configuration in
let webSearchController = WebSearchController(
context: context,
updatedPresentationData: updatedPresentationData,
peer: nil,
chatLocation: nil,
configuration: configuration,
mode: .editor(completion: { [weak mediaPickerController] image in
completion(image)
mediaPickerController?.dismiss(animated: true)
}),
activateOnDisplay: activateOnDisplay
)
mediaPickerController?.present(webSearchController, in: .current)
})
}
}
present(mediaPickerController, mediaPickerController.mediaPickerContext) present(mediaPickerController, mediaPickerController.mediaPickerContext)
} }
controller.navigationPresentation = .flatModal controller.navigationPresentation = .flatModal

View File

@ -39,6 +39,7 @@ swift_library(
"//submodules/TelegramUI/Components/ChatControllerInteraction:ChatControllerInteraction", "//submodules/TelegramUI/Components/ChatControllerInteraction:ChatControllerInteraction",
"//submodules/FeaturedStickersScreen:FeaturedStickersScreen", "//submodules/FeaturedStickersScreen:FeaturedStickersScreen",
"//submodules/StickerPackPreviewUI", "//submodules/StickerPackPreviewUI",
"//submodules/TelegramUI/Components/EntityKeyboardGifContent:EntityKeyboardGifContent",
], ],
visibility = [ visibility = [
"//visibility:public", "//visibility:public",

View File

@ -31,6 +31,7 @@ import ChatControllerInteraction
import FeaturedStickersScreen import FeaturedStickersScreen
import Pasteboard import Pasteboard
import StickerPackPreviewUI import StickerPackPreviewUI
import EntityKeyboardGifContent
public final class EmptyInputView: UIView, UIInputViewAudioFeedback { public final class EmptyInputView: UIView, UIInputViewAudioFeedback {
public var enableInputClicksWhenVisible: Bool { public var enableInputClicksWhenVisible: Bool {
@ -43,36 +44,6 @@ public struct ChatMediaInputPaneScrollState {
let relativeChange: CGFloat let relativeChange: CGFloat
} }
public final class ChatMediaInputGifPaneTrendingState {
public let files: [MultiplexedVideoNodeFile]
public let nextOffset: String?
public init(files: [MultiplexedVideoNodeFile], nextOffset: String?) {
self.files = files
self.nextOffset = nextOffset
}
}
public final class EntityKeyboardGifContent: Equatable {
public let hasRecentGifs: Bool
public let component: GifPagerContentComponent
public init(hasRecentGifs: Bool, component: GifPagerContentComponent) {
self.hasRecentGifs = hasRecentGifs
self.component = component
}
public static func ==(lhs: EntityKeyboardGifContent, rhs: EntityKeyboardGifContent) -> Bool {
if lhs.hasRecentGifs != rhs.hasRecentGifs {
return false
}
if lhs.component != rhs.component {
return false
}
return true
}
}
public final class ChatEntityKeyboardInputNode: ChatInputNode { public final class ChatEntityKeyboardInputNode: ChatInputNode {
public final class Interaction { public final class Interaction {
let sendSticker: (FileMediaReference, Bool, Bool, String?, Bool, UIView, CGRect, CALayer?, [ItemCollectionId]) -> Bool let sendSticker: (FileMediaReference, Bool, Bool, String?, Bool, UIView, CGRect, CALayer?, [ItemCollectionId]) -> Bool
@ -408,243 +379,6 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode {
public var useExternalSearchContainer: Bool = false public var useExternalSearchContainer: Bool = false
private final class GifContext {
private var componentValue: EntityKeyboardGifContent? {
didSet {
if let componentValue = self.componentValue {
self.componentResult.set(.single(componentValue))
}
}
}
private let componentPromise = Promise<EntityKeyboardGifContent>()
private let componentResult = Promise<EntityKeyboardGifContent>()
var component: Signal<EntityKeyboardGifContent, NoError> {
return self.componentResult.get()
}
private var componentDisposable: Disposable?
private let context: AccountContext
private let subject: GifPagerContentComponent.Subject
private let gifInputInteraction: GifPagerContentComponent.InputInteraction
private var loadingMoreToken: String?
init(context: AccountContext, subject: GifPagerContentComponent.Subject, gifInputInteraction: GifPagerContentComponent.InputInteraction, trendingGifs: Signal<ChatMediaInputGifPaneTrendingState?, NoError>) {
self.context = context
self.subject = subject
self.gifInputInteraction = gifInputInteraction
let hideBackground = gifInputInteraction.hideBackground
let hasRecentGifs = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs))
|> map { savedGifs -> Bool in
return !savedGifs.isEmpty
}
let searchCategories: Signal<EmojiSearchCategories?, NoError> = context.engine.stickers.emojiSearchCategories(kind: .emoji)
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let gifItems: Signal<EntityKeyboardGifContent, NoError>
switch subject {
case .recent:
gifItems = combineLatest(
context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs)),
searchCategories
)
|> map { savedGifs, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
for gifItem in savedGifs {
items.append(GifPagerContentComponent.Item(
file: .savedGif(media: gifItem.contents.get(RecentMediaItem.self)!.media),
contextResult: nil
))
}
return EntityKeyboardGifContent(
hasRecentGifs: true,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: false,
loadMoreToken: nil,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .empty(hasResults: false),
hideBackground: hideBackground
)
)
}
case .trending:
gifItems = combineLatest(hasRecentGifs, trendingGifs, searchCategories)
|> map { hasRecentGifs, trendingGifs, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
var isLoading = false
if let trendingGifs = trendingGifs {
for file in trendingGifs.files {
items.append(GifPagerContentComponent.Item(
file: file.file,
contextResult: file.contextResult
))
}
} else {
isLoading = true
}
return EntityKeyboardGifContent(
hasRecentGifs: hasRecentGifs,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: isLoading,
loadMoreToken: nil,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .empty(hasResults: false),
hideBackground: hideBackground
)
)
}
case let .emojiSearch(query):
gifItems = combineLatest(
hasRecentGifs,
paneGifSearchForQuery(context: context, query: query.joined(separator: ""), offset: nil, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil),
searchCategories
)
|> map { hasRecentGifs, result, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
var loadMoreToken: String?
var isLoading = false
if let result = result {
for file in result.files {
items.append(GifPagerContentComponent.Item(
file: file.file,
contextResult: file.contextResult
))
}
loadMoreToken = result.nextOffset
} else {
isLoading = true
}
return EntityKeyboardGifContent(
hasRecentGifs: hasRecentGifs,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: isLoading,
loadMoreToken: loadMoreToken,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .active,
hideBackground: gifInputInteraction.hideBackground
)
)
}
}
self.componentPromise.set(gifItems)
self.componentDisposable = (self.componentPromise.get()
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let strongSelf = self else {
return
}
strongSelf.componentValue = result
})
}
deinit {
self.componentDisposable?.dispose()
}
func loadMore(token: String) {
if self.loadingMoreToken == token {
return
}
self.loadingMoreToken = token
guard let componentValue = self.componentValue else {
return
}
let context = self.context
let subject = self.subject
let gifInputInteraction = self.gifInputInteraction
switch self.subject {
case let .emojiSearch(query):
let hasRecentGifs = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs))
|> map { savedGifs -> Bool in
return !savedGifs.isEmpty
}
let searchCategories: Signal<EmojiSearchCategories?, NoError> = context.engine.stickers.emojiSearchCategories(kind: .emoji)
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let gifItems: Signal<EntityKeyboardGifContent, NoError>
gifItems = combineLatest(hasRecentGifs, paneGifSearchForQuery(context: context, query: query.joined(separator: ""), offset: token, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil), searchCategories)
|> map { hasRecentGifs, result, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
var existingIds = Set<MediaId>()
for item in componentValue.component.items {
items.append(item)
existingIds.insert(item.file.media.fileId)
}
var loadMoreToken: String?
var isLoading = false
if let result = result {
for file in result.files {
if existingIds.contains(file.file.media.fileId) {
continue
}
existingIds.insert(file.file.media.fileId)
items.append(GifPagerContentComponent.Item(
file: file.file,
contextResult: file.contextResult
))
}
if !result.isComplete {
loadMoreToken = result.nextOffset
}
} else {
isLoading = true
}
return EntityKeyboardGifContent(
hasRecentGifs: hasRecentGifs,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: isLoading,
loadMoreToken: loadMoreToken,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .active,
hideBackground: gifInputInteraction.hideBackground
)
)
}
self.componentPromise.set(gifItems)
default:
break
}
}
}
private var gifContext: GifContext? { private var gifContext: GifContext? {
didSet { didSet {
if let gifContext = self.gifContext { if let gifContext = self.gifContext {
@ -2975,112 +2709,3 @@ public final class EmojiContentPeekBehaviorImpl: EmojiContentPeekBehavior {
} }
} }
} }
public class PaneGifSearchForQueryResult {
public let files: [MultiplexedVideoNodeFile]
public let nextOffset: String?
public let isComplete: Bool
public let isStale: Bool
public init(files: [MultiplexedVideoNodeFile], nextOffset: String?, isComplete: Bool, isStale: Bool) {
self.files = files
self.nextOffset = nextOffset
self.isComplete = isComplete
self.isStale = isStale
}
}
public func paneGifSearchForQuery(context: AccountContext, query: String, offset: String?, incompleteResults: Bool = false, staleCachedResults: Bool = false, delayRequest: Bool = true, updateActivity: ((Bool) -> Void)?) -> Signal<PaneGifSearchForQueryResult?, NoError> {
let contextBot = context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.SearchBots())
|> mapToSignal { searchBots -> Signal<EnginePeer?, NoError> in
let botName = searchBots.gifBotUsername ?? "gif"
return context.engine.peers.resolvePeerByName(name: botName)
}
|> mapToSignal { peer -> Signal<(ChatPresentationInputQueryResult?, Bool, Bool), NoError> in
if case let .user(user) = peer, let botInfo = user.botInfo, let _ = botInfo.inlinePlaceholder {
let results = requestContextResults(engine: context.engine, botId: user.id, query: query, peerId: context.account.peerId, offset: offset ?? "", incompleteResults: incompleteResults, staleCachedResults: staleCachedResults, limit: 1)
|> map { results -> (ChatPresentationInputQueryResult?, Bool, Bool) in
return (.contextRequestResult(.user(user), results?.results), results != nil, results?.isStale ?? false)
}
let maybeDelayedContextResults: Signal<(ChatPresentationInputQueryResult?, Bool, Bool), NoError>
if delayRequest {
maybeDelayedContextResults = results |> delay(0.4, queue: Queue.concurrentDefaultQueue())
} else {
maybeDelayedContextResults = results
}
return maybeDelayedContextResults
} else {
return .single((nil, true, false))
}
}
return contextBot
|> mapToSignal { result -> Signal<PaneGifSearchForQueryResult?, NoError> in
if let r = result.0, case let .contextRequestResult(_, maybeCollection) = r, let collection = maybeCollection {
let results = collection.results
var references: [MultiplexedVideoNodeFile] = []
for result in results {
switch result {
case let .externalReference(externalReference):
var imageResource: TelegramMediaResource?
var thumbnailResource: TelegramMediaResource?
var thumbnailIsVideo: Bool = false
var uniqueId: Int64?
if let content = externalReference.content {
imageResource = content.resource
if let resource = content.resource as? WebFileReferenceMediaResource {
uniqueId = Int64(HashFunctions.murMurHash32(resource.url))
}
}
if let thumbnail = externalReference.thumbnail {
thumbnailResource = thumbnail.resource
if thumbnail.mimeType.hasPrefix("video/") {
thumbnailIsVideo = true
}
}
if externalReference.type == "gif", let resource = imageResource, let content = externalReference.content, let dimensions = content.dimensions {
var previews: [TelegramMediaImageRepresentation] = []
var videoThumbnails: [TelegramMediaFile.VideoThumbnail] = []
if let thumbnailResource = thumbnailResource {
if thumbnailIsVideo {
videoThumbnails.append(TelegramMediaFile.VideoThumbnail(
dimensions: dimensions,
resource: thumbnailResource
))
} else {
previews.append(TelegramMediaImageRepresentation(
dimensions: dimensions,
resource: thumbnailResource,
progressiveSizes: [],
immediateThumbnailData: nil,
hasVideo: false,
isPersonal: false
))
}
}
let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: uniqueId ?? 0), partialReference: nil, resource: resource, previewRepresentations: previews, videoThumbnails: videoThumbnails, immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: dimensions, flags: [], preloadSize: nil)])
references.append(MultiplexedVideoNodeFile(file: FileMediaReference.standalone(media: file), contextResult: (collection, result)))
}
case let .internalReference(internalReference):
if let file = internalReference.file {
references.append(MultiplexedVideoNodeFile(file: FileMediaReference.standalone(media: file), contextResult: (collection, result)))
}
}
}
return .single(PaneGifSearchForQueryResult(files: references, nextOffset: collection.nextOffset, isComplete: result.1, isStale: result.2))
} else if incompleteResults {
return .single(nil)
} else {
return .complete()
}
}
|> deliverOnMainQueue
|> beforeStarted {
updateActivity?(true)
}
|> afterCompleted {
updateActivity?(false)
}
}

View File

@ -11,6 +11,7 @@ import AppBundle
import ChatControllerInteraction import ChatControllerInteraction
import MultiplexedVideoNode import MultiplexedVideoNode
import ChatPresentationInterfaceState import ChatPresentationInterfaceState
import EntityKeyboardGifContent
final class GifPaneSearchContentNode: ASDisplayNode & PaneSearchContentNode { final class GifPaneSearchContentNode: ASDisplayNode & PaneSearchContentNode {
private let context: AccountContext private let context: AccountContext

View File

@ -13,6 +13,7 @@ import ChatControllerInteraction
import MultiplexedVideoNode import MultiplexedVideoNode
import FeaturedStickersScreen import FeaturedStickersScreen
import StickerPeekUI import StickerPeekUI
import EntityKeyboardGifContent
private let searchBarHeight: CGFloat = 52.0 private let searchBarHeight: CGFloat = 52.0

View File

@ -18,7 +18,6 @@ import PagerComponent
import SoftwareVideo import SoftwareVideo
import AVFoundation import AVFoundation
import PhotoResources import PhotoResources
//import ContextUI
import ShimmerEffect import ShimmerEffect
private class GifVideoLayer: AVSampleBufferDisplayLayer { private class GifVideoLayer: AVSampleBufferDisplayLayer {
@ -269,6 +268,7 @@ public final class GifPagerContentComponent: Component {
return true return true
} }
public final class View: ContextControllerSourceView, PagerContentViewWithBackground, UIScrollViewDelegate { public final class View: ContextControllerSourceView, PagerContentViewWithBackground, UIScrollViewDelegate {
private struct ItemGroupDescription: Equatable { private struct ItemGroupDescription: Equatable {
let hasTitle: Bool let hasTitle: Bool
@ -994,7 +994,13 @@ public final class GifPagerContentComponent: Component {
vibrancyEffectView.contentView.addSubview(self.mirrorSearchHeaderContainer) vibrancyEffectView.contentView.addSubview(self.mirrorSearchHeaderContainer)
} }
} }
self.backgroundView.updateColor(color: theme.chat.inputMediaPanel.backgroundColor, enableBlur: true, forceKeepBlur: false, transition: transition.containedViewLayoutTransition)
let hideBackground = self.component?.hideBackground ?? false
var backgroundColor = theme.chat.inputMediaPanel.backgroundColor
if hideBackground {
backgroundColor = backgroundColor.withAlphaComponent(0.01)
}
self.backgroundView.updateColor(color: backgroundColor, enableBlur: true, forceKeepBlur: false, transition: transition.containedViewLayoutTransition)
transition.setFrame(view: self.backgroundView, frame: backgroundFrame) transition.setFrame(view: self.backgroundView, frame: backgroundFrame)
self.backgroundView.update(size: backgroundFrame.size, transition: transition.containedViewLayoutTransition) self.backgroundView.update(size: backgroundFrame.size, transition: transition.containedViewLayoutTransition)
@ -1115,8 +1121,6 @@ public final class GifPagerContentComponent: Component {
transition.setPosition(view: self.scrollClippingView, position: clippingFrame.center) transition.setPosition(view: self.scrollClippingView, position: clippingFrame.center)
transition.setBounds(view: self.scrollClippingView, bounds: clippingFrame) transition.setBounds(view: self.scrollClippingView, bounds: clippingFrame)
self.backgroundView.isHidden = component.hideBackground
return availableSize return availableSize
} }
} }

View File

@ -0,0 +1,43 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "EntityKeyboardGifContent",
module_name = "EntityKeyboardGifContent",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/Display:Display",
"//submodules/ComponentFlow:ComponentFlow",
"//submodules/Components/PagerComponent:PagerComponent",
"//submodules/Components/BlurredBackgroundComponent:BlurredBackgroundComponent",
"//submodules/Components/ComponentDisplayAdapters:ComponentDisplayAdapters",
"//submodules/Components/BundleIconComponent:BundleIconComponent",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramCore:TelegramCore",
"//submodules/Postbox:Postbox",
"//submodules/AnimatedStickerNode:AnimatedStickerNode",
"//submodules/TelegramAnimatedStickerNode:TelegramAnimatedStickerNode",
"//submodules/YuvConversion:YuvConversion",
"//submodules/AccountContext:AccountContext",
"//submodules/SoftwareVideo:SoftwareVideo",
"//submodules/ShimmerEffect:ShimmerEffect",
"//submodules/PhotoResources:PhotoResources",
"//submodules/StickerResources:StickerResources",
"//submodules/AppBundle:AppBundle",
"//submodules/Components/MultilineTextComponent:MultilineTextComponent",
"//submodules/Components/SolidRoundedButtonComponent:SolidRoundedButtonComponent",
"//submodules/Components/LottieAnimationComponent:LottieAnimationComponent",
"//submodules/LocalizedPeerData:LocalizedPeerData",
"//submodules/TelegramNotices:TelegramNotices",
"//submodules/TelegramUI/Components/MultiplexedVideoNode:MultiplexedVideoNode",
"//submodules/TelegramUI/Components/EntityKeyboard:EntityKeyboard",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,385 @@
import Foundation
import SwiftSignalKit
import Postbox
import TelegramCore
import AccountContext
import MultiplexedVideoNode
import EntityKeyboard
public final class ChatMediaInputGifPaneTrendingState {
public let files: [MultiplexedVideoNodeFile]
public let nextOffset: String?
public init(files: [MultiplexedVideoNodeFile], nextOffset: String?) {
self.files = files
self.nextOffset = nextOffset
}
}
public final class EntityKeyboardGifContent: Equatable {
public let hasRecentGifs: Bool
public let component: GifPagerContentComponent
public init(hasRecentGifs: Bool, component: GifPagerContentComponent) {
self.hasRecentGifs = hasRecentGifs
self.component = component
}
public static func ==(lhs: EntityKeyboardGifContent, rhs: EntityKeyboardGifContent) -> Bool {
if lhs.hasRecentGifs != rhs.hasRecentGifs {
return false
}
if lhs.component != rhs.component {
return false
}
return true
}
}
public class PaneGifSearchForQueryResult {
public let files: [MultiplexedVideoNodeFile]
public let nextOffset: String?
public let isComplete: Bool
public let isStale: Bool
public init(files: [MultiplexedVideoNodeFile], nextOffset: String?, isComplete: Bool, isStale: Bool) {
self.files = files
self.nextOffset = nextOffset
self.isComplete = isComplete
self.isStale = isStale
}
}
public func paneGifSearchForQuery(context: AccountContext, query: String, offset: String?, incompleteResults: Bool = false, staleCachedResults: Bool = false, delayRequest: Bool = true, updateActivity: ((Bool) -> Void)?) -> Signal<PaneGifSearchForQueryResult?, NoError> {
let contextBot = context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.SearchBots())
|> mapToSignal { searchBots -> Signal<EnginePeer?, NoError> in
let botName = searchBots.gifBotUsername ?? "gif"
return context.engine.peers.resolvePeerByName(name: botName)
}
|> mapToSignal { peer -> Signal<(ChatPresentationInputQueryResult?, Bool, Bool), NoError> in
if case let .user(user) = peer, let botInfo = user.botInfo, let _ = botInfo.inlinePlaceholder {
let results = requestContextResults(engine: context.engine, botId: user.id, query: query, peerId: context.account.peerId, offset: offset ?? "", incompleteResults: incompleteResults, staleCachedResults: staleCachedResults, limit: 1)
|> map { results -> (ChatPresentationInputQueryResult?, Bool, Bool) in
return (.contextRequestResult(.user(user), results?.results), results != nil, results?.isStale ?? false)
}
let maybeDelayedContextResults: Signal<(ChatPresentationInputQueryResult?, Bool, Bool), NoError>
if delayRequest {
maybeDelayedContextResults = results |> delay(0.4, queue: Queue.concurrentDefaultQueue())
} else {
maybeDelayedContextResults = results
}
return maybeDelayedContextResults
} else {
return .single((nil, true, false))
}
}
return contextBot
|> mapToSignal { result -> Signal<PaneGifSearchForQueryResult?, NoError> in
if let r = result.0, case let .contextRequestResult(_, maybeCollection) = r, let collection = maybeCollection {
let results = collection.results
var references: [MultiplexedVideoNodeFile] = []
for result in results {
switch result {
case let .externalReference(externalReference):
var imageResource: TelegramMediaResource?
var thumbnailResource: TelegramMediaResource?
var thumbnailIsVideo: Bool = false
var uniqueId: Int64?
if let content = externalReference.content {
imageResource = content.resource
if let resource = content.resource as? WebFileReferenceMediaResource {
uniqueId = Int64(HashFunctions.murMurHash32(resource.url))
}
}
if let thumbnail = externalReference.thumbnail {
thumbnailResource = thumbnail.resource
if thumbnail.mimeType.hasPrefix("video/") {
thumbnailIsVideo = true
}
}
if externalReference.type == "gif", let resource = imageResource, let content = externalReference.content, let dimensions = content.dimensions {
var previews: [TelegramMediaImageRepresentation] = []
var videoThumbnails: [TelegramMediaFile.VideoThumbnail] = []
if let thumbnailResource = thumbnailResource {
if thumbnailIsVideo {
videoThumbnails.append(TelegramMediaFile.VideoThumbnail(
dimensions: dimensions,
resource: thumbnailResource
))
} else {
previews.append(TelegramMediaImageRepresentation(
dimensions: dimensions,
resource: thumbnailResource,
progressiveSizes: [],
immediateThumbnailData: nil,
hasVideo: false,
isPersonal: false
))
}
}
let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: uniqueId ?? 0), partialReference: nil, resource: resource, previewRepresentations: previews, videoThumbnails: videoThumbnails, immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: dimensions, flags: [], preloadSize: nil)])
references.append(MultiplexedVideoNodeFile(file: FileMediaReference.standalone(media: file), contextResult: (collection, result)))
}
case let .internalReference(internalReference):
if let file = internalReference.file {
references.append(MultiplexedVideoNodeFile(file: FileMediaReference.standalone(media: file), contextResult: (collection, result)))
}
}
}
return .single(PaneGifSearchForQueryResult(files: references, nextOffset: collection.nextOffset, isComplete: result.1, isStale: result.2))
} else if incompleteResults {
return .single(nil)
} else {
return .complete()
}
}
|> deliverOnMainQueue
|> beforeStarted {
updateActivity?(true)
}
|> afterCompleted {
updateActivity?(false)
}
}
public final class GifContext {
private var componentValue: EntityKeyboardGifContent? {
didSet {
if let componentValue = self.componentValue {
self.componentResult.set(.single(componentValue))
}
}
}
private let componentPromise = Promise<EntityKeyboardGifContent>()
private let componentResult = Promise<EntityKeyboardGifContent>()
public var component: Signal<EntityKeyboardGifContent, NoError> {
return self.componentResult.get()
}
private var componentDisposable: Disposable?
private let context: AccountContext
private let subject: GifPagerContentComponent.Subject
private let gifInputInteraction: GifPagerContentComponent.InputInteraction
private var loadingMoreToken: String?
public init(context: AccountContext, subject: GifPagerContentComponent.Subject, gifInputInteraction: GifPagerContentComponent.InputInteraction, trendingGifs: Signal<ChatMediaInputGifPaneTrendingState?, NoError>) {
self.context = context
self.subject = subject
self.gifInputInteraction = gifInputInteraction
let hideBackground = gifInputInteraction.hideBackground
let hasRecentGifs = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs))
|> map { savedGifs -> Bool in
return !savedGifs.isEmpty
}
let searchCategories: Signal<EmojiSearchCategories?, NoError> = context.engine.stickers.emojiSearchCategories(kind: .emoji)
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let gifItems: Signal<EntityKeyboardGifContent, NoError>
switch subject {
case .recent:
gifItems = combineLatest(
context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs)),
searchCategories
)
|> map { savedGifs, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
for gifItem in savedGifs {
items.append(GifPagerContentComponent.Item(
file: .savedGif(media: gifItem.contents.get(RecentMediaItem.self)!.media),
contextResult: nil
))
}
return EntityKeyboardGifContent(
hasRecentGifs: true,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: false,
loadMoreToken: nil,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .empty(hasResults: false),
hideBackground: hideBackground
)
)
}
case .trending:
gifItems = combineLatest(hasRecentGifs, trendingGifs, searchCategories)
|> map { hasRecentGifs, trendingGifs, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
var isLoading = false
if let trendingGifs = trendingGifs {
for file in trendingGifs.files {
items.append(GifPagerContentComponent.Item(
file: file.file,
contextResult: file.contextResult
))
}
} else {
isLoading = true
}
return EntityKeyboardGifContent(
hasRecentGifs: hasRecentGifs,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: isLoading,
loadMoreToken: nil,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .empty(hasResults: false),
hideBackground: hideBackground
)
)
}
case let .emojiSearch(query):
gifItems = combineLatest(
hasRecentGifs,
paneGifSearchForQuery(context: context, query: query.joined(separator: ""), offset: nil, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil),
searchCategories
)
|> map { hasRecentGifs, result, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
var loadMoreToken: String?
var isLoading = false
if let result = result {
for file in result.files {
items.append(GifPagerContentComponent.Item(
file: file.file,
contextResult: file.contextResult
))
}
loadMoreToken = result.nextOffset
} else {
isLoading = true
}
return EntityKeyboardGifContent(
hasRecentGifs: hasRecentGifs,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: isLoading,
loadMoreToken: loadMoreToken,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .active,
hideBackground: gifInputInteraction.hideBackground
)
)
}
}
self.componentPromise.set(gifItems)
self.componentDisposable = (self.componentPromise.get()
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let strongSelf = self else {
return
}
strongSelf.componentValue = result
})
}
deinit {
self.componentDisposable?.dispose()
}
public func loadMore(token: String) {
if self.loadingMoreToken == token {
return
}
self.loadingMoreToken = token
guard let componentValue = self.componentValue else {
return
}
let context = self.context
let subject = self.subject
let gifInputInteraction = self.gifInputInteraction
switch self.subject {
case let .emojiSearch(query):
let hasRecentGifs = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs))
|> map { savedGifs -> Bool in
return !savedGifs.isEmpty
}
let searchCategories: Signal<EmojiSearchCategories?, NoError> = context.engine.stickers.emojiSearchCategories(kind: .emoji)
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let gifItems: Signal<EntityKeyboardGifContent, NoError>
gifItems = combineLatest(hasRecentGifs, paneGifSearchForQuery(context: context, query: query.joined(separator: ""), offset: token, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil), searchCategories)
|> map { hasRecentGifs, result, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
var existingIds = Set<MediaId>()
for item in componentValue.component.items {
items.append(item)
existingIds.insert(item.file.media.fileId)
}
var loadMoreToken: String?
var isLoading = false
if let result = result {
for file in result.files {
if existingIds.contains(file.file.media.fileId) {
continue
}
existingIds.insert(file.file.media.fileId)
items.append(GifPagerContentComponent.Item(
file: file.file,
contextResult: file.contextResult
))
}
if !result.isComplete {
loadMoreToken = result.nextOffset
}
} else {
isLoading = true
}
return EntityKeyboardGifContent(
hasRecentGifs: hasRecentGifs,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: isLoading,
loadMoreToken: loadMoreToken,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .active,
hideBackground: gifInputInteraction.hideBackground
)
)
}
self.componentPromise.set(gifItems)
default:
break
}
}
}

View File

@ -21,7 +21,7 @@ public final class DrawingStickerEntity: DrawingEntity, Codable {
} }
case file(TelegramMediaFile) case file(TelegramMediaFile)
case image(UIImage, ImageType) case image(UIImage, ImageType)
case video(String, UIImage?, Bool) case video(TelegramMediaFile)
case dualVideoReference case dualVideoReference
public static func == (lhs: Content, rhs: Content) -> Bool { public static func == (lhs: Content, rhs: Content) -> Bool {
@ -38,9 +38,9 @@ public final class DrawingStickerEntity: DrawingEntity, Codable {
} else { } else {
return false return false
} }
case let .video(lhsPath, _, lhsInternalMirrored): case let .video(lhsFile):
if case let .video(rhsPath, _, rhsInternalMirrored) = rhs { if case let .video(rhsFile) = rhs {
return lhsPath == rhsPath && lhsInternalMirrored == rhsInternalMirrored return lhsFile.fileId == rhsFile.fileId
} else { } else {
return false return false
} }
@ -57,9 +57,7 @@ public final class DrawingStickerEntity: DrawingEntity, Codable {
case uuid case uuid
case file case file
case imagePath case imagePath
case videoPath case videoFile
case videoImagePath
case videoMirrored
case isRectangle case isRectangle
case isDualPhoto case isDualPhoto
case dualVideo case dualVideo
@ -98,8 +96,8 @@ public final class DrawingStickerEntity: DrawingEntity, Codable {
dimensions = image.size dimensions = image.size
case let .file(file): case let .file(file):
dimensions = file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0) dimensions = file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0)
case let .video(_, image, _): case let .video(file):
dimensions = image?.size ?? CGSize(width: 512.0, height: 512.0) dimensions = file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0)
case .dualVideoReference: case .dualVideoReference:
dimensions = CGSize(width: 512.0, height: 512.0) dimensions = CGSize(width: 512.0, height: 512.0)
} }
@ -129,6 +127,8 @@ public final class DrawingStickerEntity: DrawingEntity, Codable {
switch self.content { switch self.content {
case let .image(_, imageType): case let .image(_, imageType):
return imageType == .rectangle return imageType == .rectangle
case .video:
return true
default: default:
return false return false
} }
@ -173,13 +173,8 @@ public final class DrawingStickerEntity: DrawingEntity, Codable {
imageType = .sticker imageType = .sticker
} }
self.content = .image(image, imageType) self.content = .image(image, imageType)
} else if let videoPath = try container.decodeIfPresent(String.self, forKey: .videoPath) { } else if let file = try container.decodeIfPresent(TelegramMediaFile.self, forKey: .videoFile) {
var imageValue: UIImage? self.content = .video(file)
if let imagePath = try container.decodeIfPresent(String.self, forKey: .videoImagePath), let image = UIImage(contentsOfFile: fullEntityMediaPath(imagePath)) {
imageValue = image
}
let videoMirrored = try container.decodeIfPresent(Bool.self, forKey: .videoMirrored) ?? false
self.content = .video(videoPath, imageValue, videoMirrored)
} else { } else {
fatalError() fatalError()
} }
@ -213,16 +208,8 @@ public final class DrawingStickerEntity: DrawingEntity, Codable {
default: default:
break break
} }
case let .video(path, image, videoMirrored): case let .video(file):
try container.encode(path, forKey: .videoPath) try container.encode(file, forKey: .videoFile)
let imagePath = "\(self.uuid).jpg"
let fullImagePath = fullEntityMediaPath(imagePath)
if let imageData = image?.jpegData(compressionQuality: 0.87) {
try? FileManager.default.createDirectory(atPath: entitiesPath(), withIntermediateDirectories: true)
try? imageData.write(to: URL(fileURLWithPath: fullImagePath))
try container.encodeIfPresent(imagePath, forKey: .videoImagePath)
}
try container.encode(videoMirrored, forKey: .videoMirrored)
case .dualVideoReference: case .dualVideoReference:
try container.encode(true, forKey: .dualVideo) try container.encode(true, forKey: .dualVideo)
} }

View File

@ -68,8 +68,8 @@ func composerEntitiesForDrawingEntity(account: Account, textScale: CGFloat, enti
content = .file(file) content = .file(file)
case let .image(image, _): case let .image(image, _):
content = .image(image) content = .image(image)
case let .video(path, _, _): case let .video(file):
content = .video(path) content = .video(file)
case .dualVideoReference: case .dualVideoReference:
return [] return []
} }
@ -135,8 +135,8 @@ private class MediaEditorComposerStaticEntity: MediaEditorComposerEntity {
private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
public enum Content { public enum Content {
case file(TelegramMediaFile) case file(TelegramMediaFile)
case video(TelegramMediaFile)
case image(UIImage) case image(UIImage)
case video(String)
var file: TelegramMediaFile? { var file: TelegramMediaFile? {
if case let .file(file) = self { if case let .file(file) = self {
@ -146,6 +146,7 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
} }
} }
let account: Account
let content: Content let content: Content
let position: CGPoint let position: CGPoint
let scale: CGFloat let scale: CGFloat
@ -181,6 +182,7 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
let imagePromise = Promise<UIImage>() let imagePromise = Promise<UIImage>()
init(account: Account, content: Content, position: CGPoint, scale: CGFloat, rotation: CGFloat, baseSize: CGSize, mirrored: Bool, colorSpace: CGColorSpace, tintColor: UIColor?, isStatic: Bool) { init(account: Account, content: Content, position: CGPoint, scale: CGFloat, rotation: CGFloat, baseSize: CGSize, mirrored: Bool, colorSpace: CGColorSpace, tintColor: UIColor?, isStatic: Bool) {
self.account = account
self.content = content self.content = content
self.position = position self.position = position
self.scale = scale self.scale = scale
@ -263,13 +265,23 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
case let .image(image): case let .image(image):
self.isAnimated = false self.isAnimated = false
self.imagePromise.set(.single(image)) self.imagePromise.set(.single(image))
case let .video(videoPath): case .video:
self.isAnimated = true self.isAnimated = true
}
}
let url = URL(fileURLWithPath: videoPath) deinit {
self.disposables.dispose()
}
private func setupVideoOutput() {
if case let .video(file) = self.content {
if let path = self.account.postbox.mediaBox.completedResourcePath(file.resource, pathExtension: "mp4") {
let url = URL(fileURLWithPath: path)
let asset = AVURLAsset(url: url) let asset = AVURLAsset(url: url)
if let assetReader = try? AVAssetReader(asset: asset), let videoTrack = asset.tracks(withMediaType: .video).first { if let assetReader = try? AVAssetReader(asset: asset), let videoTrack = asset.tracks(withMediaType: .video).first {
self.videoFrameRate = videoTrack.nominalFrameRate
let outputSettings: [String: Any] = [ let outputSettings: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
kCVPixelBufferMetalCompatibilityKey as String: true kCVPixelBufferMetalCompatibilityKey as String: true
@ -286,36 +298,65 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
} }
} }
} }
deinit {
self.disposables.dispose()
} }
private var circleMaskFilter: CIFilter? private var videoFrameRate: Float?
private var maskFilter: CIFilter?
func image(for time: CMTime, frameRate: Float, context: CIContext, completion: @escaping (CIImage?) -> Void) { func image(for time: CMTime, frameRate: Float, context: CIContext, completion: @escaping (CIImage?) -> Void) {
let currentTime = CMTimeGetSeconds(time)
if case .video = self.content { if case .video = self.content {
if self.videoOutput == nil {
self.setupVideoOutput()
}
if let videoOutput = self.videoOutput { if let videoOutput = self.videoOutput {
if let sampleBuffer = videoOutput.copyNextSampleBuffer(), let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) { var frameAdvancement: Int = 0
if let frameRate = self.videoFrameRate, frameRate > 0 {
let frameTime = 1.0 / Double(frameRate)
let frameIndex = Int(floor(currentTime / frameTime))
let currentFrameIndex = self.currentFrameIndex
if currentFrameIndex != frameIndex {
let previousFrameIndex = currentFrameIndex
self.currentFrameIndex = frameIndex
var delta = 1
if let previousFrameIndex = previousFrameIndex {
delta = max(1, frameIndex - previousFrameIndex)
}
frameAdvancement = delta
}
}
if frameAdvancement == 0, let image = self.image {
completion(image)
return
} else {
var sampleBuffer = videoOutput.copyNextSampleBuffer()
if sampleBuffer == nil && self.assetReader?.status == .completed {
self.setupVideoOutput()
sampleBuffer = self.videoOutput?.copyNextSampleBuffer()
}
if let sampleBuffer, let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) {
var ciImage = CIImage(cvPixelBuffer: imageBuffer) var ciImage = CIImage(cvPixelBuffer: imageBuffer)
ciImage = ciImage.oriented(forExifOrientation: UIImage.Orientation.right.exifOrientation)
let minSide = min(ciImage.extent.size.width, ciImage.extent.size.height)
let cropRect = CGRect(origin: CGPoint(x: floor((ciImage.extent.size.width - minSide) / 2.0), y: floor((ciImage.extent.size.height - minSide) / 2.0)), size: CGSize(width: minSide, height: minSide))
ciImage = ciImage.cropped(to: cropRect).samplingLinear()
ciImage = ciImage.transformed(by: CGAffineTransform(translationX: 0.0, y: -420.0))
var circleMaskFilter: CIFilter? var circleMaskFilter: CIFilter?
if let current = self.circleMaskFilter { if let current = self.maskFilter {
circleMaskFilter = current circleMaskFilter = current
} else { } else {
let circleImage = generateImage(CGSize(width: minSide, height: minSide), scale: 1.0, rotatedContext: { size, context in let circleImage = generateImage(ciImage.extent.size, scale: 1.0, rotatedContext: { size, context in
context.clear(CGRect(origin: .zero, size: size)) context.clear(CGRect(origin: .zero, size: size))
context.setFillColor(UIColor.white.cgColor) context.setFillColor(UIColor.white.cgColor)
context.fillEllipse(in: CGRect(origin: .zero, size: size))
let path = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: floor(size.width * 0.03))
context.addPath(path.cgPath)
context.fillPath()
})! })!
let circleMask = CIImage(image: circleImage) let circleMask = CIImage(image: circleImage)
if let filter = CIFilter(name: "CIBlendWithAlphaMask") { if let filter = CIFilter(name: "CIBlendWithAlphaMask") {
filter.setValue(circleMask, forKey: kCIInputMaskImageKey) filter.setValue(circleMask, forKey: kCIInputMaskImageKey)
self.circleMaskFilter = filter self.maskFilter = filter
circleMaskFilter = filter circleMaskFilter = filter
} }
} }
@ -328,14 +369,14 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
} }
} }
self.image = ciImage
completion(ciImage) completion(ciImage)
} }
}
} else { } else {
completion(nil) completion(nil)
} }
} else if self.isAnimated { } else if self.isAnimated {
let currentTime = CMTimeGetSeconds(time)
var tintColor: UIColor? var tintColor: UIColor?
if let file = self.content.file, file.isCustomTemplateEmoji { if let file = self.content.file, file.isCustomTemplateEmoji {
tintColor = self.tintColor ?? UIColor(rgb: 0xffffff) tintColor = self.tintColor ?? UIColor(rgb: 0xffffff)
@ -389,8 +430,8 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity {
} }
} }
if frameAdvancement == 0 && strongSelf.image != nil { if frameAdvancement == 0, let image = strongSelf.image {
completion(strongSelf.image) completion(image)
} else { } else {
if let frame = takeFrame(max(1, frameAdvancement)) { if let frame = takeFrame(max(1, frameAdvancement)) {
var imagePixelBuffer: CVPixelBuffer? var imagePixelBuffer: CVPixelBuffer?

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import UIKit import UIKit
import SwiftSignalKit import SwiftSignalKit
import CoreLocation
import TelegramCore import TelegramCore
import TelegramUIPreferences import TelegramUIPreferences
import PersistentStringHash import PersistentStringHash
@ -67,6 +68,8 @@ public final class MediaEditorDraft: Codable, Equatable {
case caption case caption
case privacy case privacy
case timestamp case timestamp
case locationLatitude
case locationLongitude
} }
public let path: String public let path: String
@ -78,8 +81,9 @@ public final class MediaEditorDraft: Codable, Equatable {
public let caption: NSAttributedString public let caption: NSAttributedString
public let privacy: MediaEditorResultPrivacy? public let privacy: MediaEditorResultPrivacy?
public let timestamp: Int32 public let timestamp: Int32
public let location: CLLocationCoordinate2D?
public init(path: String, isVideo: Bool, thumbnail: UIImage, dimensions: PixelDimensions, duration: Double?, values: MediaEditorValues, caption: NSAttributedString, privacy: MediaEditorResultPrivacy?, timestamp: Int32) { public init(path: String, isVideo: Bool, thumbnail: UIImage, dimensions: PixelDimensions, duration: Double?, values: MediaEditorValues, caption: NSAttributedString, privacy: MediaEditorResultPrivacy?, timestamp: Int32, location: CLLocationCoordinate2D?) {
self.path = path self.path = path
self.isVideo = isVideo self.isVideo = isVideo
self.thumbnail = thumbnail self.thumbnail = thumbnail
@ -89,6 +93,7 @@ public final class MediaEditorDraft: Codable, Equatable {
self.caption = caption self.caption = caption
self.privacy = privacy self.privacy = privacy
self.timestamp = timestamp self.timestamp = timestamp
self.location = location
} }
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
@ -122,6 +127,12 @@ public final class MediaEditorDraft: Codable, Equatable {
} }
self.timestamp = try container.decodeIfPresent(Int32.self, forKey: .timestamp) ?? 1688909663 self.timestamp = try container.decodeIfPresent(Int32.self, forKey: .timestamp) ?? 1688909663
if let latitude = try container.decodeIfPresent(Double.self, forKey: .locationLatitude), let longitude = try container.decodeIfPresent(Double.self, forKey: .locationLongitude) {
self.location = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
} else {
self.location = nil
}
} }
public func encode(to encoder: Encoder) throws { public func encode(to encoder: Encoder) throws {
@ -141,7 +152,7 @@ public final class MediaEditorDraft: Codable, Equatable {
let chatInputText = ChatTextInputStateText(attributedText: self.caption) let chatInputText = ChatTextInputStateText(attributedText: self.caption)
try container.encode(chatInputText, forKey: .caption) try container.encode(chatInputText, forKey: .caption)
if let privacy = self .privacy { if let privacy = self.privacy {
if let data = try? JSONEncoder().encode(privacy) { if let data = try? JSONEncoder().encode(privacy) {
try container.encode(data, forKey: .privacy) try container.encode(data, forKey: .privacy)
} else { } else {
@ -151,6 +162,14 @@ public final class MediaEditorDraft: Codable, Equatable {
try container.encodeNil(forKey: .privacy) try container.encodeNil(forKey: .privacy)
} }
try container.encode(self.timestamp, forKey: .timestamp) try container.encode(self.timestamp, forKey: .timestamp)
if let location = self.location {
try container.encode(location.latitude, forKey: .locationLatitude)
try container.encode(location.longitude, forKey: .locationLongitude)
} else {
try container.encodeNil(forKey: .locationLatitude)
try container.encodeNil(forKey: .locationLongitude)
}
} }
} }

View File

@ -807,7 +807,7 @@ final class MediaEditorScreenComponent: Component {
containerSize: CGSize(width: 40.0, height: 40.0) containerSize: CGSize(width: 40.0, height: 40.0)
) )
let textButtonFrame = CGRect( let textButtonFrame = CGRect(
origin: CGPoint(x: buttonsLeftOffset + floorToScreenPixels(buttonsAvailableWidth / 5.0 * 2.0 - textButtonSize.width / 2.0), y: availableSize.height - environment.safeInsets.bottom + buttonBottomInset + controlsBottomInset + 2.0), origin: CGPoint(x: buttonsLeftOffset + floorToScreenPixels(buttonsAvailableWidth / 5.0 * 2.0 - textButtonSize.width / 2.0 - 1.0), y: availableSize.height - environment.safeInsets.bottom + buttonBottomInset + controlsBottomInset + 2.0),
size: textButtonSize size: textButtonSize
) )
if let textButtonView = self.textButton.view { if let textButtonView = self.textButton.view {
@ -836,7 +836,7 @@ final class MediaEditorScreenComponent: Component {
containerSize: CGSize(width: 40.0, height: 40.0) containerSize: CGSize(width: 40.0, height: 40.0)
) )
let stickerButtonFrame = CGRect( let stickerButtonFrame = CGRect(
origin: CGPoint(x: buttonsLeftOffset + floorToScreenPixels(buttonsAvailableWidth / 5.0 * 3.0 - stickerButtonSize.width / 2.0), y: availableSize.height - environment.safeInsets.bottom + buttonBottomInset + controlsBottomInset + 2.0), origin: CGPoint(x: buttonsLeftOffset + floorToScreenPixels(buttonsAvailableWidth / 5.0 * 3.0 - stickerButtonSize.width / 2.0 + 1.0), y: availableSize.height - environment.safeInsets.bottom + buttonBottomInset + controlsBottomInset + 2.0),
size: stickerButtonSize size: stickerButtonSize
) )
if let stickerButtonView = self.stickerButton.view { if let stickerButtonView = self.stickerButton.view {
@ -1746,7 +1746,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
emojiItems, emojiItems,
stickerItems stickerItems
) |> map { emoji, stickers -> StickerPickerInputData in ) |> map { emoji, stickers -> StickerPickerInputData in
return StickerPickerInputData(emoji: emoji, stickers: stickers, masks: nil) return StickerPickerInputData(emoji: emoji, stickers: stickers, gifs: nil)
} }
stickerPickerInputData.set(signal) stickerPickerInputData.set(signal)
@ -2655,7 +2655,9 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
let tooltipController = SaveProgressScreen(context: self.context, content: .progress(text, 0.0)) let tooltipController = SaveProgressScreen(context: self.context, content: .progress(text, 0.0))
tooltipController.cancelled = { [weak self] in tooltipController.cancelled = { [weak self] in
if let self, let controller = self.controller { if let self, let controller = self.controller {
controller.isSavingAvailable = true
controller.cancelVideoExport() controller.cancelVideoExport()
controller.requestLayout(transition: .animated(duration: 0.25, curve: .easeInOut))
} }
} }
controller.present(tooltipController, in: .current) controller.present(tooltipController, in: .current)
@ -2667,16 +2669,11 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
guard let controller = self.controller else { guard let controller = self.controller else {
return return
} }
let galleryController = self.context.sharedContext.makeMediaPickerScreen(context: self.context, completion: { [weak self] result in let galleryController = self.context.sharedContext.makeMediaPickerScreen(context: self.context, hasSearch: true, completion: { [weak self] result in
guard let self, let asset = result as? PHAsset else { guard let self else {
return return
} }
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
PHImageManager.default().requestImage(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .default, options: options) { [weak self] image, _ in
if let self, let image {
Queue.mainQueue().async {
func roundedImageWithTransparentCorners(image: UIImage, cornerRadius: CGFloat) -> UIImage? { func roundedImageWithTransparentCorners(image: UIImage, cornerRadius: CGFloat) -> UIImage? {
let rect = CGRect(origin: .zero, size: image.size) let rect = CGRect(origin: .zero, size: image.size)
UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale) UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale)
@ -2694,11 +2691,25 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
return newImage return newImage
} }
let completeWithImage: (UIImage) -> Void = { [weak self] image in
let updatedImage = roundedImageWithTransparentCorners(image: image, cornerRadius: floor(image.size.width * 0.03))! let updatedImage = roundedImageWithTransparentCorners(image: image, cornerRadius: floor(image.size.width * 0.03))!
self.interaction?.insertEntity(DrawingStickerEntity(content: .image(updatedImage, .rectangle)), scale: 2.5) self?.interaction?.insertEntity(DrawingStickerEntity(content: .image(updatedImage, .rectangle)), scale: 2.5)
}
if let asset = result as? PHAsset {
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
PHImageManager.default().requestImage(for: asset, targetSize: PHImageManagerMaximumSize, contentMode: .default, options: options) { image, _ in
if let image {
Queue.mainQueue().async {
completeWithImage(image)
} }
} }
} }
} else if let image = result as? UIImage {
completeWithImage(image)
}
}) })
galleryController.customModalStyleOverlayTransitionFactorUpdated = { [weak self, weak galleryController] transition in galleryController.customModalStyleOverlayTransitionFactorUpdated = { [weak self, weak galleryController] transition in
if let self, let galleryController { if let self, let galleryController {
@ -2945,7 +2956,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
switch mode { switch mode {
case .sticker: case .sticker:
self.mediaEditor?.stop() self.mediaEditor?.stop()
let controller = StickerPickerScreen(context: self.context, inputData: self.stickerPickerInputData.get(), defaultToEmoji: self.defaultToEmoji) let controller = StickerPickerScreen(context: self.context, inputData: self.stickerPickerInputData.get(), defaultToEmoji: self.defaultToEmoji, hasGifs: true)
controller.completion = { [weak self] content in controller.completion = { [weak self] content in
if let self { if let self {
if let content { if let content {
@ -3767,10 +3778,15 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
let duration = mediaEditor.duration ?? 0.0 let duration = mediaEditor.duration ?? 0.0
var timestamp: Int32 var timestamp: Int32
var location: CLLocationCoordinate2D?
if case let .draft(draft, _) = subject { if case let .draft(draft, _) = subject {
timestamp = draft.timestamp timestamp = draft.timestamp
location = draft.location
} else { } else {
timestamp = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) timestamp = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
if case let .asset(asset) = subject {
location = asset.location?.coordinate
}
} }
if let resultImage = mediaEditor.resultImage { if let resultImage = mediaEditor.resultImage {
@ -3786,7 +3802,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
if let thumbnailImage = generateScaledImage(image: resultImage, size: fittedSize) { if let thumbnailImage = generateScaledImage(image: resultImage, size: fittedSize) {
let path = "\(Int64.random(in: .min ... .max)).jpg" let path = "\(Int64.random(in: .min ... .max)).jpg"
if let data = image.jpegData(compressionQuality: 0.87) { if let data = image.jpegData(compressionQuality: 0.87) {
let draft = MediaEditorDraft(path: path, isVideo: false, thumbnail: thumbnailImage, dimensions: dimensions, duration: nil, values: values, caption: caption, privacy: privacy, timestamp: timestamp) let draft = MediaEditorDraft(path: path, isVideo: false, thumbnail: thumbnailImage, dimensions: dimensions, duration: nil, values: values, caption: caption, privacy: privacy, timestamp: timestamp, location: location)
try? data.write(to: URL(fileURLWithPath: draft.fullPath())) try? data.write(to: URL(fileURLWithPath: draft.fullPath()))
if let id { if let id {
saveStorySource(engine: context.engine, item: draft, peerId: context.account.peerId, id: id) saveStorySource(engine: context.engine, item: draft, peerId: context.account.peerId, id: id)
@ -3800,7 +3816,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate
let saveVideoDraft: (String, PixelDimensions, Double) -> Void = { videoPath, dimensions, duration in let saveVideoDraft: (String, PixelDimensions, Double) -> Void = { videoPath, dimensions, duration in
if let thumbnailImage = generateScaledImage(image: resultImage, size: fittedSize) { if let thumbnailImage = generateScaledImage(image: resultImage, size: fittedSize) {
let path = "\(Int64.random(in: .min ... .max)).mp4" let path = "\(Int64.random(in: .min ... .max)).mp4"
let draft = MediaEditorDraft(path: path, isVideo: true, thumbnail: thumbnailImage, dimensions: dimensions, duration: duration, values: values, caption: caption, privacy: privacy, timestamp: timestamp) let draft = MediaEditorDraft(path: path, isVideo: true, thumbnail: thumbnailImage, dimensions: dimensions, duration: duration, values: values, caption: caption, privacy: privacy, timestamp: timestamp, location: location)
try? FileManager.default.moveItem(atPath: videoPath, toPath: draft.fullPath()) try? FileManager.default.moveItem(atPath: videoPath, toPath: draft.fullPath())
if let id { if let id {
saveStorySource(engine: context.engine, item: draft, peerId: context.account.peerId, id: id) saveStorySource(engine: context.engine, item: draft, peerId: context.account.peerId, id: id)

View File

@ -300,6 +300,7 @@ final class ShareWithPeersScreenComponent: Component {
private let categoryTemplateItem = ComponentView<Empty>() private let categoryTemplateItem = ComponentView<Empty>()
private let peerTemplateItem = ComponentView<Empty>() private let peerTemplateItem = ComponentView<Empty>()
private let optionTemplateItem = ComponentView<Empty>() private let optionTemplateItem = ComponentView<Empty>()
private let footerTemplateItem = ComponentView<Empty>()
private let itemContainerView: UIView private let itemContainerView: UIView
private var visibleSectionHeaders: [Int: ComponentView<Empty>] = [:] private var visibleSectionHeaders: [Int: ComponentView<Empty>] = [:]
@ -1033,7 +1034,7 @@ final class ShareWithPeersScreenComponent: Component {
} }
)), )),
maximumNumberOfLines: 0, maximumNumberOfLines: 0,
lineSpacing: 0.2, lineSpacing: 0.1,
highlightColor: UIColor(rgb: 0x007aff, alpha: 0.2), highlightColor: UIColor(rgb: 0x007aff, alpha: 0.2),
highlightAction: { attributes in highlightAction: { attributes in
if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] { if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] {
@ -1166,7 +1167,7 @@ final class ShareWithPeersScreenComponent: Component {
itemTransition.setFrame(view: itemView, frame: itemFrame) itemTransition.setFrame(view: itemView, frame: itemFrame)
} }
} }
} else if section.id == 2 { } else if section.id == 2 && section.itemCount > 0 {
for i in 0 ..< component.optionItems.count { for i in 0 ..< component.optionItems.count {
let itemFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: sectionOffset + section.insets.top + CGFloat(i) * section.itemHeight), size: CGSize(width: itemLayout.containerSize.width, height: section.itemHeight)) let itemFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: sectionOffset + section.insets.top + CGFloat(i) * section.itemHeight), size: CGSize(width: itemLayout.containerSize.width, height: section.itemHeight))
if !visibleBounds.intersects(itemFrame) { if !visibleBounds.intersects(itemFrame) {
@ -1645,7 +1646,6 @@ final class ShareWithPeersScreenComponent: Component {
transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize)) transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize))
let categoryItemSize = self.categoryTemplateItem.update( let categoryItemSize = self.categoryTemplateItem.update(
transition: .immediate, transition: .immediate,
component: AnyComponent(CategoryListItemComponent( component: AnyComponent(CategoryListItemComponent(
@ -1697,6 +1697,76 @@ final class ShareWithPeersScreenComponent: Component {
containerSize: CGSize(width: itemsContainerWidth, height: 1000.0) containerSize: CGSize(width: itemsContainerWidth, height: 1000.0)
) )
var footersTotalHeight: CGFloat = 0.0
if case let .stories(editing) = component.stateContext.subject {
let body = MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor)
let bold = MarkdownAttributeSet(font: Font.semibold(13.0), textColor: environment.theme.list.freeTextColor)
let link = MarkdownAttributeSet(font: Font.regular(13.0), textColor: environment.theme.list.itemAccentColor)
let firstFooterText: String
if let grayListPeers = component.stateContext.stateValue?.grayListPeers, !grayListPeers.isEmpty {
let footerValue = environment.strings.Story_Privacy_GrayListPeople(Int32(grayListPeers.count))
firstFooterText = environment.strings.Story_Privacy_GrayListSelected(footerValue).string
} else {
firstFooterText = environment.strings.Story_Privacy_GrayListSelect
}
let footerInset: CGFloat = 7.0
let firstFooterSize = self.footerTemplateItem.update(
transition: transition,
component: AnyComponent(MultilineTextComponent(
text: .markdown(text: firstFooterText, attributes: MarkdownAttributes(
body: body,
bold: bold,
link: link,
linkAttribute: { url in
return ("URL", url)
}
)),
maximumNumberOfLines: 0,
lineSpacing: 0.1,
highlightColor: .clear,
highlightAction: { _ in
return nil
},
tapAction: { _, _ in
}
)),
environment: {},
containerSize: CGSize(width: itemsContainerWidth - 16.0 * 2.0, height: 1000.0)
)
footersTotalHeight += firstFooterSize.height + footerInset
if !editing {
let footerValue = environment.strings.Story_Privacy_KeepOnMyPageHours(Int32(component.timeout / 3600))
let secondFooterText = environment.strings.Story_Privacy_KeepOnMyPageInfo(footerValue).string
let secondFooterSize = self.footerTemplateItem.update(
transition: transition,
component: AnyComponent(MultilineTextComponent(
text: .markdown(text: secondFooterText, attributes: MarkdownAttributes(
body: body,
bold: bold,
link: link,
linkAttribute: { url in
return ("URL", url)
}
)),
maximumNumberOfLines: 0,
lineSpacing: 0.1,
highlightColor: .clear,
highlightAction: { _ in
return nil
},
tapAction: { _, _ in
}
)),
environment: {},
containerSize: CGSize(width: itemsContainerWidth - 16.0 * 2.0, height: 1000.0)
)
footersTotalHeight += secondFooterSize.height + footerInset
}
}
var sections: [ItemLayout.Section] = [] var sections: [ItemLayout.Section] = []
if let stateValue = self.effectiveStateValue { if let stateValue = self.effectiveStateValue {
if case .stories = component.stateContext.subject { if case .stories = component.stateContext.subject {
@ -1815,13 +1885,14 @@ final class ShareWithPeersScreenComponent: Component {
if environment.inputHeight != 0.0 || !self.navigationTextFieldState.text.isEmpty { if environment.inputHeight != 0.0 || !self.navigationTextFieldState.text.isEmpty {
topInset = 0.0 topInset = 0.0
} else { } else {
let inset: CGFloat var inset: CGFloat
if case let .stories(editing) = component.stateContext.subject { if case let .stories(editing) = component.stateContext.subject {
if editing { if editing {
inset = 478.0 inset = 351.0
} else { } else {
inset = 630.0 inset = 464.0
} }
inset += 10.0 + environment.safeInsets.bottom + 50.0 + footersTotalHeight
} else { } else {
inset = 600.0 inset = 600.0
} }
@ -2024,7 +2095,7 @@ final class ShareWithPeersScreenComponent: Component {
transition.setFrame(layer: self.bottomSeparatorLayer, frame: CGRect(origin: CGPoint(x: containerSideInset + sideInset, y: availableSize.height - bottomPanelHeight - 8.0 - UIScreenPixel), size: CGSize(width: containerWidth, height: UIScreenPixel))) transition.setFrame(layer: self.bottomSeparatorLayer, frame: CGRect(origin: CGPoint(x: containerSideInset + sideInset, y: availableSize.height - bottomPanelHeight - 8.0 - UIScreenPixel), size: CGSize(width: containerWidth, height: UIScreenPixel)))
let itemContainerSize = CGSize(width: itemsContainerWidth, height: availableSize.height) let itemContainerSize = CGSize(width: itemsContainerWidth, height: availableSize.height)
let itemLayout = ItemLayout(style: itemLayoutStyle, containerSize: itemContainerSize, containerInset: containerInset, bottomInset: bottomPanelHeight, topInset: topInset, sideInset: sideInset, navigationHeight: navigationHeight, sections: sections) let itemLayout = ItemLayout(style: itemLayoutStyle, containerSize: itemContainerSize, containerInset: containerInset, bottomInset: footersTotalHeight, topInset: topInset, sideInset: sideInset, navigationHeight: navigationHeight, sections: sections)
let previousItemLayout = self.itemLayout let previousItemLayout = self.itemLayout
self.itemLayout = itemLayout self.itemLayout = itemLayout

View File

@ -84,6 +84,8 @@ swift_library(
"//submodules/Speak", "//submodules/Speak",
"//submodules/TranslateUI", "//submodules/TranslateUI",
"//submodules/TelegramNotices", "//submodules/TelegramNotices",
"//submodules/MediaPlayer:UniversalMediaPlayer",
"//submodules/TelegramUniversalVideoContent",
], ],
visibility = [ visibility = [
"//visibility:public", "//visibility:public",

View File

@ -758,6 +758,9 @@ final class StoryItemContentComponent: Component {
if maskView.subviews.isEmpty { if maskView.subviews.isEmpty {
let referenceSize = availableSize let referenceSize = availableSize
for mediaArea in component.item.mediaAreas { for mediaArea in component.item.mediaAreas {
guard case .venue = mediaArea else {
continue
}
let size = CGSize(width: mediaArea.coordinates.width / 100.0 * referenceSize.width, height: mediaArea.coordinates.height / 100.0 * referenceSize.height) let size = CGSize(width: mediaArea.coordinates.width / 100.0 * referenceSize.width, height: mediaArea.coordinates.height / 100.0 * referenceSize.height)
let position = CGPoint(x: mediaArea.coordinates.x / 100.0 * referenceSize.width, y: mediaArea.coordinates.y / 100.0 * referenceSize.height) let position = CGPoint(x: mediaArea.coordinates.x / 100.0 * referenceSize.width, y: mediaArea.coordinates.y / 100.0 * referenceSize.height)

View File

@ -1847,8 +1847,8 @@ public final class SharedAccountContextImpl: SharedAccountContext {
return StickerPackScreen(context: context, updatedPresentationData: updatedPresentationData, mainStickerPack: mainStickerPack, stickerPacks: stickerPacks, loadedStickerPacks: loadedStickerPacks, parentNavigationController: parentNavigationController, sendSticker: sendSticker) return StickerPackScreen(context: context, updatedPresentationData: updatedPresentationData, mainStickerPack: mainStickerPack, stickerPacks: stickerPacks, loadedStickerPacks: loadedStickerPacks, parentNavigationController: parentNavigationController, sendSticker: sendSticker)
} }
public func makeMediaPickerScreen(context: AccountContext, completion: @escaping (Any) -> Void) -> ViewController { public func makeMediaPickerScreen(context: AccountContext, hasSearch: Bool, completion: @escaping (Any) -> Void) -> ViewController {
return mediaPickerController(context: context, completion: completion) return mediaPickerController(context: context, hasSearch: hasSearch, completion: completion)
} }
public func makeStoryMediaPickerScreen(context: AccountContext, getSourceRect: @escaping () -> CGRect, completion: @escaping (Any, UIView, CGRect, UIImage?, @escaping (Bool?) -> (UIView, CGRect)?, @escaping () -> Void) -> Void, dismissed: @escaping () -> Void) -> ViewController { public func makeStoryMediaPickerScreen(context: AccountContext, getSourceRect: @escaping () -> CGRect, completion: @escaping (Any, UIView, CGRect, UIImage?, @escaping (Bool?) -> (UIView, CGRect)?, @escaping () -> Void) -> Void, dismissed: @escaping () -> Void) -> ViewController {

View File

@ -17,11 +17,12 @@ public enum WebSearchMode {
public enum WebSearchControllerMode { public enum WebSearchControllerMode {
case media(attachment: Bool, completion: (ChatContextResultCollection, TGMediaSelectionContext, TGMediaEditingContext, Bool) -> Void) case media(attachment: Bool, completion: (ChatContextResultCollection, TGMediaSelectionContext, TGMediaEditingContext, Bool) -> Void)
case editor(completion: (UIImage) -> Void)
case avatar(initialQuery: String?, completion: (UIImage) -> Void) case avatar(initialQuery: String?, completion: (UIImage) -> Void)
var mode: WebSearchMode { var mode: WebSearchMode {
switch self { switch self {
case .media: case .media, .editor:
return .media return .media
case .avatar: case .avatar:
return .avatar return .avatar
@ -81,7 +82,7 @@ public final class WebSearchController: ViewController {
private var validLayout: ContainerViewLayout? private var validLayout: ContainerViewLayout?
private let context: AccountContext private let context: AccountContext
private let mode: WebSearchControllerMode let mode: WebSearchControllerMode
private let peer: EnginePeer? private let peer: EnginePeer?
private let chatLocation: ChatLocation? private let chatLocation: ChatLocation?
private let configuration: EngineConfiguration.SearchBots private let configuration: EngineConfiguration.SearchBots
@ -193,6 +194,8 @@ public final class WebSearchController: ViewController {
var attachment = false var attachment = false
if case let .media(attachmentValue, _) = mode { if case let .media(attachmentValue, _) = mode {
attachment = attachmentValue attachment = attachmentValue
} else if case .editor = mode {
attachment = true
} }
let navigationContentNode = WebSearchNavigationContentNode(theme: presentationData.theme, strings: presentationData.strings, attachment: attachment) let navigationContentNode = WebSearchNavigationContentNode(theme: presentationData.theme, strings: presentationData.strings, attachment: attachment)
self.navigationContentNode = navigationContentNode self.navigationContentNode = navigationContentNode
@ -218,6 +221,8 @@ public final class WebSearchController: ViewController {
selectionState = TGMediaSelectionContext() selectionState = TGMediaSelectionContext()
case .avatar: case .avatar:
selectionState = nil selectionState = nil
case .editor:
selectionState = nil
} }
let editingState = TGMediaEditingContext() let editingState = TGMediaEditingContext()
self.controllerInteraction = WebSearchControllerInteraction(openResult: { [weak self] result in self.controllerInteraction = WebSearchControllerInteraction(openResult: { [weak self] result in
@ -345,10 +350,13 @@ public final class WebSearchController: ViewController {
super.viewWillAppear(animated) super.viewWillAppear(animated)
var select = false var select = false
if case let .avatar(initialQuery, _) = mode, let _ = initialQuery { if case let .avatar(initialQuery, _) = self.mode, let _ = initialQuery {
select = true select = true
} }
if case let .media(attachment, _) = mode, attachment && !self.didPlayPresentationAnimation { if case let .media(attachment, _) = self.mode, attachment && !self.didPlayPresentationAnimation {
self.didPlayPresentationAnimation = true
self.controllerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
} else if case .editor = self.mode, !self.didPlayPresentationAnimation {
self.didPlayPresentationAnimation = true self.didPlayPresentationAnimation = true
self.controllerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.controllerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
} }
@ -414,7 +422,7 @@ public final class WebSearchController: ViewController {
return state.state?.scope return state.state?.scope
} }
|> distinctUntilChanged |> distinctUntilChanged
case .avatar: case .avatar, .editor:
scope = .single(.images) scope = .single(.images)
} }
@ -467,9 +475,7 @@ public final class WebSearchController: ViewController {
let delayRequest = true let delayRequest = true
let signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError> = .single({ _ in return .contextRequestResult(nil, nil) }) let signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError> = .single({ _ in return .contextRequestResult(nil, nil) })
guard let peerId = self.peer?.id else { let peerId = self.peer?.id ?? self.context.account.peerId
return .single({ _ in return .contextRequestResult(nil, nil) })
}
let botName: String? let botName: String?
switch scope { switch scope {

View File

@ -790,6 +790,16 @@ class WebSearchControllerNode: ASDisplayNode {
})) }))
} }
} }
} else {
if let mode = self.controller?.mode, case let .editor(completion) = mode {
if let item = legacyWebSearchItem(account: self.context.account, result: currentResult) {
let _ = (item.originalImage
|> deliverOnMainQueue).start(next: { image in
if !image.degraded() {
completion(image)
}
})
}
} else { } else {
presentLegacyWebSearchEditor(context: self.context, theme: self.theme, result: currentResult, initialLayout: self.containerLayout?.0, updateHiddenMedia: { [weak self] id in presentLegacyWebSearchEditor(context: self.context, theme: self.theme, result: currentResult, initialLayout: self.containerLayout?.0, updateHiddenMedia: { [weak self] id in
self?.hiddenMediaId.set(.single(id)) self?.hiddenMediaId.set(.single(id))
@ -805,6 +815,7 @@ class WebSearchControllerNode: ASDisplayNode {
}, present: present) }, present: present)
} }
} }
}
private func transitionNode(for result: ChatContextResult) -> WebSearchItemNode? { private func transitionNode(for result: ChatContextResult) -> WebSearchItemNode? {
var transitionNode: WebSearchItemNode? var transitionNode: WebSearchItemNode?