mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
GIFs in media editor
Image search in media editor
This commit is contained in:
parent
9ddb8fa2d6
commit
b71304a3d2
@ -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
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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)?
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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",
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
],
|
||||||
|
)
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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?
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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?
|
||||||
|
Loading…
x
Reference in New Issue
Block a user