From b71304a3d2b092273ce5fa981b43ed51773df8b1 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Mon, 7 Aug 2023 08:38:02 +0200 Subject: [PATCH] GIFs in media editor Image search in media editor --- .../Sources/AccountContext.swift | 2 +- submodules/DrawingUI/BUILD | 3 + .../Sources/DrawingMediaEntity.swift | 27 +- .../DrawingUI/Sources/DrawingScreen.swift | 6 +- .../Sources/DrawingStickerEntity.swift | 285 ++++++++----- .../Sources/StickerPickerScreen.swift | 299 +++++++++++++- .../Sources/MediaPickerScreen.swift | 23 +- .../ChatEntityKeyboardInputNode/BUILD | 1 + .../Sources/ChatEntityKeyboardInputNode.swift | 377 +---------------- .../Sources/GifPaneSearchContentNode.swift | 1 + .../Sources/PaneSearchContainerNode.swift | 1 + .../Sources/GifPagerContentComponent.swift | 12 +- .../Components/EntityKeyboardGifContent/BUILD | 43 ++ .../Sources/GifContext.swift | 385 ++++++++++++++++++ .../Drawing/DrawingStickerEntity.swift | 39 +- .../Sources/MediaEditorComposerEntity.swift | 157 ++++--- .../Sources/MediaEditorDraft.swift | 23 +- .../Sources/MediaEditorScreen.swift | 78 ++-- .../Sources/ShareWithPeersScreen.swift | 85 +++- .../Stories/StoryContainerScreen/BUILD | 2 + .../Sources/StoryItemContentComponent.swift | 3 + .../Sources/SharedAccountContext.swift | 4 +- .../Sources/WebSearchController.swift | 28 +- .../Sources/WebSearchControllerNode.swift | 33 +- 24 files changed, 1231 insertions(+), 686 deletions(-) create mode 100644 submodules/TelegramUI/Components/EntityKeyboardGifContent/BUILD create mode 100644 submodules/TelegramUI/Components/EntityKeyboardGifContent/Sources/GifContext.swift diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 08ca9bed05..bdd233c464 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -900,7 +900,7 @@ public protocol SharedAccountContext: AnyObject { func makeStickerPackScreen(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)?, 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 diff --git a/submodules/DrawingUI/BUILD b/submodules/DrawingUI/BUILD index 74789fe4ec..1cc5b84c1f 100644 --- a/submodules/DrawingUI/BUILD +++ b/submodules/DrawingUI/BUILD @@ -98,6 +98,9 @@ swift_library( "//submodules/StickerPackPreviewUI:StickerPackPreviewUI", "//submodules/TelegramUI/Components/LottieComponent", "//submodules/ImageTransparency", + "//submodules/GalleryUI", + "//submodules/MediaPlayer:UniversalMediaPlayer", + "//submodules/TelegramUniversalVideoContent", ], visibility = [ "//visibility:public", diff --git a/submodules/DrawingUI/Sources/DrawingMediaEntity.swift b/submodules/DrawingUI/Sources/DrawingMediaEntity.swift index fdb4f60717..1fa43368d6 100644 --- a/submodules/DrawingUI/Sources/DrawingMediaEntity.swift +++ b/submodules/DrawingUI/Sources/DrawingMediaEntity.swift @@ -21,30 +21,13 @@ public final class DrawingMediaEntityView: DrawingEntityView, DrawingEntityMedia if let previewView = self.previewView { previewView.isUserInteractionEnabled = false previewView.layer.allowsEdgeAntialiasing = true - if self.additionalView == nil { - self.addSubview(previewView) - } + self.addSubview(previewView) } else { 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() init(context: AccountContext, entity: DrawingMediaEntity) { @@ -113,14 +96,8 @@ public final class DrawingMediaEntityView: DrawingEntityView, DrawingEntityMedia if self.previewView?.superview === self { 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) } - if let additionalView = self.additionalView, additionalView.superview === self { - self.additionalView?.frame = self.bounds - } } public var updated: (() -> Void)? diff --git a/submodules/DrawingUI/Sources/DrawingScreen.swift b/submodules/DrawingUI/Sources/DrawingScreen.swift index 48385363b9..e71f9c2ef9 100644 --- a/submodules/DrawingUI/Sources/DrawingScreen.swift +++ b/submodules/DrawingUI/Sources/DrawingScreen.swift @@ -759,7 +759,7 @@ private final class DrawingScreenComponent: CombinedComponent { emojiItems, stickerItems ) |> map { emoji, stickers -> StickerPickerInputData in - return StickerPickerInputData(emoji: emoji, stickers: stickers, masks: nil) + return StickerPickerInputData(emoji: emoji, stickers: stickers, gifs: nil) } stickerPickerInputData.set(signal) @@ -3057,9 +3057,7 @@ public final class DrawingToolsInteraction { var isVideo = false if let entity = entityView.entity as? DrawingStickerEntity { - if case .video = entity.content { - isVideo = true - } else if case .dualVideoReference = entity.content { + if case .dualVideoReference = entity.content { isVideo = true } } diff --git a/submodules/DrawingUI/Sources/DrawingStickerEntity.swift b/submodules/DrawingUI/Sources/DrawingStickerEntity.swift index 1d60e7a1ce..7b56d8ff40 100644 --- a/submodules/DrawingUI/Sources/DrawingStickerEntity.swift +++ b/submodules/DrawingUI/Sources/DrawingStickerEntity.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import AsyncDisplayKit import AVFoundation import Display import SwiftSignalKit @@ -9,31 +10,10 @@ import TelegramAnimatedStickerNode import StickerResources import AccountContext import MediaEditor +import UniversalMediaPlayer +import TelegramUniversalVideoContent -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 - } - } - +public final class DrawingStickerEntityView: DrawingEntityView { private var stickerEntity: DrawingStickerEntity { return self.entity as! DrawingStickerEntity } @@ -46,27 +26,8 @@ public final class DrawingStickerEntityView: DrawingEntityView { private let imageNode: TransformImageNode private var animationNode: AnimatedStickerNode? - - 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 videoNode: UniversalVideoNode? + private var didSetUpAnimationNode = false private let stickerFetchedDisposable = MetaDisposable() private let cachedDisposable = MetaDisposable() @@ -109,9 +70,9 @@ public final class DrawingStickerEntityView: DrawingEntityView { } } - private var video: String? { - if case let .video(path, _, _) = self.stickerEntity.content { - return path + private var video: TelegramMediaFile? { + if case let .video(file) = self.stickerEntity.content { + return file } else { return nil } @@ -123,13 +84,8 @@ public final class DrawingStickerEntityView: DrawingEntityView { return file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0) case let .image(image, _): return image.size - case let .video(_, image, _): - if let image { - 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 let .video(file): + return file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0) case .dualVideoReference: return CGSize(width: 512.0, height: 512.0) } @@ -220,30 +176,46 @@ public final class DrawingStickerEntityView: DrawingEntityView { return context }), attemptSynchronously: synchronous) self.setNeedsLayout() - } else if case let .video(videoPath, image, _) = self.stickerEntity.content { - let url = URL(fileURLWithPath: videoPath) - let asset = AVURLAsset(url: url) - let playerItem = AVPlayerItem(asset: asset) - let player = AVPlayer(playerItem: playerItem) - player.automaticallyWaitsToMinimizeStalling = false - - let videoContainerView = UIView() - videoContainerView.clipsToBounds = true - - let videoView = VideoView(player: player) - videoContainerView.addSubview(videoView) - - self.addSubview(videoContainerView) - - self.videoPlayer = player - self.videoContainerView = videoContainerView - self.videoView = videoView - - let imageView = UIImageView(image: image) - imageView.clipsToBounds = true - imageView.contentMode = .scaleAspectFill - videoContainerView.addSubview(imageView) - self.videoImageView = imageView + } else if case let .video(file) = self.stickerEntity.content { + let videoNode = UniversalVideoNode( + postbox: self.context.account.postbox, + audioSession: self.context.sharedContext.mediaManager.audioSession, + manager: self.context.sharedContext.mediaManager.universalVideoManager, + decoration: StickerVideoDecoration(), + content: NativeVideoContent( + id: .contextResult(0, "\(UInt64.random(in: 0 ... UInt64.max))"), + userLocation: .other, + fileReference: .standalone(media: file), + imageReference: nil, + streamVideo: .story, + loopVideo: true, + enableSound: false, + soundMuted: true, + beginWithAmbientSound: false, + mixWithOthers: true, + useLargeThumbnail: false, + autoFetchFullSizeThumbnail: false, + tempFilePath: nil, + captureProtected: false, + hintDimensions: file.dimensions?.cgSize, + storeAfterDownload: nil, + 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.applyVisibility() - if let player = self.videoPlayer { - 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() - }) - } - } - } + self.videoNode?.play() } public override func pause() { self.isVisible = false self.applyVisibility() - if let player = self.videoPlayer { - player.pause() - } + self.videoNode?.pause() } public override func seek(to timestamp: Double) { @@ -279,9 +238,7 @@ public final class DrawingStickerEntityView: DrawingEntityView { self.isPlaying = false self.animationNode?.seekTo(.timestamp(timestamp)) - if let player = self.videoPlayer { - player.seek(to: CMTime(seconds: timestamp, preferredTimescale: CMTimeScale(60.0)), toleranceBefore: .zero, toleranceAfter: .zero, completionHandler: { _ in }) - } + self.videoNode?.seek(timestamp) } override func resetToStart() { @@ -343,16 +300,10 @@ public final class DrawingStickerEntityView: DrawingEntityView { } } - if let videoView = self.videoView { - let videoSize = CGSize(width: imageFrame.width, height: imageFrame.width / 9.0 * 16.0) - videoView.frame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((imageFrame.height - videoSize.height) / 2.0)), size: videoSize) - } - 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) + if let videoNode = self.videoNode { + let videoSize = self.dimensions.aspectFitted(boundingSize) + 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) } self.update(animated: false) @@ -386,18 +337,18 @@ public final class DrawingStickerEntityView: DrawingEntityView { UIView.animate(withDuration: 0.25, animations: { self.imageNode.transform = animationTargetTransform self.animationNode?.transform = animationTargetTransform - self.videoContainerView?.layer.transform = animationTargetTransform + self.videoNode?.transform = animationTargetTransform }, completion: { finished in self.imageNode.transform = staticTransform self.animationNode?.transform = staticTransform - self.videoContainerView?.layer.transform = staticTransform + self.videoNode?.transform = staticTransform }) } else { CATransaction.begin() CATransaction.setDisableActions(true) self.imageNode.transform = staticTransform self.animationNode?.transform = staticTransform - self.videoContainerView?.layer.transform = staticTransform + self.videoNode?.transform = staticTransform CATransaction.commit() } @@ -713,3 +664,117 @@ final class DrawingStickerEntititySelectionView: DrawingEntitySelectionView { 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) { + } + + public func tap() { + } +} diff --git a/submodules/DrawingUI/Sources/StickerPickerScreen.swift b/submodules/DrawingUI/Sources/StickerPickerScreen.swift index cb9f051bcd..c86c9d53c1 100644 --- a/submodules/DrawingUI/Sources/StickerPickerScreen.swift +++ b/submodules/DrawingUI/Sources/StickerPickerScreen.swift @@ -18,20 +18,23 @@ import ContextUI import ChatPresentationInterfaceState import MediaEditor import StickerPackPreviewUI +import EntityKeyboardGifContent +import GalleryUI +import UndoUI public struct StickerPickerInputData: Equatable { var emoji: EmojiPagerContentComponent var stickers: EmojiPagerContentComponent? - var masks: EmojiPagerContentComponent? + var gifs: GifPagerContentComponent? public init( emoji: EmojiPagerContentComponent, stickers: EmojiPagerContentComponent?, - masks: EmojiPagerContentComponent? + gifs: GifPagerContentComponent? ) { self.emoji = emoji self.stickers = stickers - self.masks = masks + self.gifs = gifs } } @@ -95,9 +98,18 @@ private final class StickerSelectionComponent: Component { 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 { fileprivate let keyboardView: ComponentView - private let keyboardClippingView: UIView + private let keyboardClippingView: KeyboardClippingView private let panelHostView: PagerExternalTopPanelContainer private let panelBackgroundView: BlurredBackgroundView private let panelSeparatorView: UIView @@ -107,18 +119,20 @@ private final class StickerSelectionComponent: Component { private var interaction: ChatEntityKeyboardInputNode.Interaction? private var inputNodeInteraction: ChatMediaInputNodeInteraction? - private let trendingGifsPromise = Promise(nil) private var searchVisible = false private var forceUpdate = false + private var ignoreNextZeroScrollingOffset = false private var topPanelScrollingOffset: CGFloat = 0.0 + private var keyboardContentId: AnyHashable? override init(frame: CGRect) { self.keyboardView = ComponentView() - self.keyboardClippingView = UIView() + self.keyboardClippingView = KeyboardClippingView() self.panelHostView = PagerExternalTopPanelContainer() self.panelBackgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) + self.panelBackgroundView.isUserInteractionEnabled = false self.panelSeparatorView = UIView() super.init(frame: frame) @@ -210,6 +224,12 @@ private final class StickerSelectionComponent: Component { 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, transition: Transition) -> CGSize { self.backgroundColor = component.backgroundColor 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( transition: transition.withUserData(EmojiPagerContentComponent.SynchronousLoadBehavior(isDisabled: true)), 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), emojiContent: component.content.emoji, stickerContent: component.content.stickers, - maskContent: component.content.masks, - gifContent: nil, - hasRecentGifs: false, + maskContent: nil, + gifContent: component.content.gifs, + hasRecentGifs: true, availableGifSearchEmojies: [], defaultToEmojiTab: defaultToEmoji, externalTopPanelContainer: self.panelHostView, @@ -259,7 +278,11 @@ private final class StickerSelectionComponent: Component { }, topPanelScrollingOffset: { [weak self] offset, transition in if let self { - self.topPanelScrollingOffset = offset + if self.ignoreNextZeroScrollingOffset && offset == 0.0 { + self.ignoreNextZeroScrollingOffset = false + } else { + self.topPanelScrollingOffset = offset + } } }, hideInputUpdated: { [weak self] _, searchVisible, transition in @@ -297,14 +320,19 @@ private final class StickerSelectionComponent: Component { inputNodeInteraction: inputNodeInteraction, mode: mappedMode, stickerActionTitle: presentationData.strings.StickerPack_AddSticker, - trendingGifsPromise: trendingGifsPromise, + trendingGifsPromise: Promise(nil), cancel: { }, peekBehavior: stickerPeekBehavior ) return searchContainerNode }, - contentIdUpdated: { _ in }, + contentIdUpdated: { [weak self] id in + guard let self else { + return + } + self.keyboardContentId = id + }, deviceMetrics: component.deviceMetrics, hiddenInputHeight: 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))) + 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: 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) let topPanelAlpha: CGFloat - if self.searchVisible { + if self.searchVisible || self.keyboardContentId == AnyHashable("gifs") { topPanelAlpha = 0.0 } else { topPanelAlpha = max(0.0, min(1.0, (self.topPanelScrollingOffset / 20.0))) @@ -386,6 +415,8 @@ public class StickerPickerScreen: ViewController { private var content: StickerPickerInputData? private let contentDisposable = MetaDisposable() + private var hasRecentGifsDisposable: Disposable? + private let trendingGifsPromise = Promise(nil) private var scheduledEmojiContentAnimationHint: EmojiPagerContentComponent.ContentAnimation? private(set) var isExpanded = false @@ -397,6 +428,24 @@ public class StickerPickerScreen: ViewController { 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() + private var gifInputInteraction: GifPagerContentComponent.InputInteraction? + private struct EmojiSearchResult { var groups: [EmojiPagerContentComponent.ItemGroup] var id: AnyHashable @@ -431,7 +480,7 @@ public class StickerPickerScreen: ViewController { } private var storyStickersContentView: StoryStickersContentView? - + init(context: AccountContext, controller: StickerPickerScreen, theme: PresentationTheme) { self.presentationData = context.sharedContext.currentPresentationData.with { $0 } self.controller = controller @@ -464,15 +513,18 @@ public class StickerPickerScreen: ViewController { let data = combineLatest( queue: Queue.mainQueue(), controller.inputData, + .single(nil) |> then(self.gifComponent.get() |> map(Optional.init)), self.stickerSearchState.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 { let presentationData = strongSelf.presentationData var inputData = inputData + inputData.gifs = gifData?.component + let emoji = inputData.emoji if let emojiSearchResult = emojiSearchState.result { var emptySearchResults: EmojiPagerContentComponent.EmptySearchResults? @@ -509,12 +561,183 @@ public class StickerPickerScreen: ViewController { 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 { self.contentDisposable.dispose() self.emojiSearchDisposable.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) { @@ -922,6 +1145,9 @@ public class StickerPickerScreen: ViewController { } }, 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)) }, onScroll: {}, @@ -1187,6 +1413,9 @@ public class StickerPickerScreen: ViewController { } }, 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)) }, onScroll: {}, @@ -1653,6 +1882,7 @@ public class StickerPickerScreen: ViewController { private let theme: PresentationTheme private let inputData: Signal fileprivate let defaultToEmoji: Bool + let hasGifs: Bool private var currentLayout: ContainerViewLayout? @@ -1664,11 +1894,12 @@ public class StickerPickerScreen: ViewController { public var presentGallery: () -> Void = { } public var presentLocationPicker: () -> Void = { } - public init(context: AccountContext, inputData: Signal, defaultToEmoji: Bool = false) { + public init(context: AccountContext, inputData: Signal, defaultToEmoji: Bool = false, hasGifs: Bool = false) { self.context = context self.theme = defaultDarkColorPresentationTheme self.inputData = inputData self.defaultToEmoji = defaultToEmoji + self.hasGifs = hasGifs super.init(navigationBarPresentationData: nil) @@ -1809,3 +2040,37 @@ final class StoryStickersContentView: UIView, EmojiCustomContentView { 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) + } + } +} diff --git a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift index db290f0c67..173f34e1ea 100644 --- a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift +++ b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift @@ -1534,7 +1534,7 @@ public final class MediaPickerScreen: ViewController, AttachmentContainable { if collection == nil { 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?.action = #selector(self.rightButtonPressed) self.navigationItem.rightBarButtonItem?.target = self @@ -2289,6 +2289,7 @@ public func wallpaperMediaPickerController( public func mediaPickerController( context: AccountContext, + hasSearch: Bool, completion: @escaping (Any) -> Void ) -> ViewController { let presentationData = context.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: defaultDarkColorPresentationTheme) @@ -2302,6 +2303,26 @@ public func mediaPickerController( completion(result) 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) } controller.navigationPresentation = .flatModal diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD index 82231a6d07..24e6e395b8 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD @@ -39,6 +39,7 @@ swift_library( "//submodules/TelegramUI/Components/ChatControllerInteraction:ChatControllerInteraction", "//submodules/FeaturedStickersScreen:FeaturedStickersScreen", "//submodules/StickerPackPreviewUI", + "//submodules/TelegramUI/Components/EntityKeyboardGifContent:EntityKeyboardGifContent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift index 1e309d573c..6f36589a07 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift @@ -31,6 +31,7 @@ import ChatControllerInteraction import FeaturedStickersScreen import Pasteboard import StickerPackPreviewUI +import EntityKeyboardGifContent public final class EmptyInputView: UIView, UIInputViewAudioFeedback { public var enableInputClicksWhenVisible: Bool { @@ -43,36 +44,6 @@ public struct ChatMediaInputPaneScrollState { 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 Interaction { let sendSticker: (FileMediaReference, Bool, Bool, String?, Bool, UIView, CGRect, CALayer?, [ItemCollectionId]) -> Bool @@ -407,244 +378,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } 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() - private let componentResult = Promise() - var component: Signal { - 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) { - 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 = context.engine.stickers.emojiSearchCategories(kind: .emoji) - - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let gifItems: Signal - 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 = context.engine.stickers.emojiSearchCategories(kind: .emoji) - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - - let gifItems: Signal - 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() - 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? { didSet { 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 { - let contextBot = context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.SearchBots()) - |> mapToSignal { searchBots -> Signal 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 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) - } -} diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/GifPaneSearchContentNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/GifPaneSearchContentNode.swift index f509da0549..ae765add8f 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/GifPaneSearchContentNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/GifPaneSearchContentNode.swift @@ -11,6 +11,7 @@ import AppBundle import ChatControllerInteraction import MultiplexedVideoNode import ChatPresentationInterfaceState +import EntityKeyboardGifContent final class GifPaneSearchContentNode: ASDisplayNode & PaneSearchContentNode { private let context: AccountContext diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift index b2971c836e..be655b579d 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift @@ -13,6 +13,7 @@ import ChatControllerInteraction import MultiplexedVideoNode import FeaturedStickersScreen import StickerPeekUI +import EntityKeyboardGifContent private let searchBarHeight: CGFloat = 52.0 diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift index c28804a168..55d0729bbf 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/GifPagerContentComponent.swift @@ -18,7 +18,6 @@ import PagerComponent import SoftwareVideo import AVFoundation import PhotoResources -//import ContextUI import ShimmerEffect private class GifVideoLayer: AVSampleBufferDisplayLayer { @@ -269,6 +268,7 @@ public final class GifPagerContentComponent: Component { return true } + public final class View: ContextControllerSourceView, PagerContentViewWithBackground, UIScrollViewDelegate { private struct ItemGroupDescription: Equatable { let hasTitle: Bool @@ -994,7 +994,13 @@ public final class GifPagerContentComponent: Component { 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) 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.setBounds(view: self.scrollClippingView, bounds: clippingFrame) - self.backgroundView.isHidden = component.hideBackground - return availableSize } } diff --git a/submodules/TelegramUI/Components/EntityKeyboardGifContent/BUILD b/submodules/TelegramUI/Components/EntityKeyboardGifContent/BUILD new file mode 100644 index 0000000000..cc15d62131 --- /dev/null +++ b/submodules/TelegramUI/Components/EntityKeyboardGifContent/BUILD @@ -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", + ], +) diff --git a/submodules/TelegramUI/Components/EntityKeyboardGifContent/Sources/GifContext.swift b/submodules/TelegramUI/Components/EntityKeyboardGifContent/Sources/GifContext.swift new file mode 100644 index 0000000000..7085d8c9c0 --- /dev/null +++ b/submodules/TelegramUI/Components/EntityKeyboardGifContent/Sources/GifContext.swift @@ -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 { + let contextBot = context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.SearchBots()) + |> mapToSignal { searchBots -> Signal 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 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() + + private let componentResult = Promise() + public var component: Signal { + 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) { + 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 = context.engine.stickers.emojiSearchCategories(kind: .emoji) + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let gifItems: Signal + 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 = context.engine.stickers.emojiSearchCategories(kind: .emoji) + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + + let gifItems: Signal + 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() + 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 + } + } +} diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift index bd8832f41d..6a3b369225 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/Drawing/DrawingStickerEntity.swift @@ -21,7 +21,7 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { } case file(TelegramMediaFile) case image(UIImage, ImageType) - case video(String, UIImage?, Bool) + case video(TelegramMediaFile) case dualVideoReference public static func == (lhs: Content, rhs: Content) -> Bool { @@ -38,9 +38,9 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { } else { return false } - case let .video(lhsPath, _, lhsInternalMirrored): - if case let .video(rhsPath, _, rhsInternalMirrored) = rhs { - return lhsPath == rhsPath && lhsInternalMirrored == rhsInternalMirrored + case let .video(lhsFile): + if case let .video(rhsFile) = rhs { + return lhsFile.fileId == rhsFile.fileId } else { return false } @@ -57,9 +57,7 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { case uuid case file case imagePath - case videoPath - case videoImagePath - case videoMirrored + case videoFile case isRectangle case isDualPhoto case dualVideo @@ -98,8 +96,8 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { dimensions = image.size case let .file(file): dimensions = file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0) - case let .video(_, image, _): - dimensions = image?.size ?? CGSize(width: 512.0, height: 512.0) + case let .video(file): + dimensions = file.dimensions?.cgSize ?? CGSize(width: 512.0, height: 512.0) case .dualVideoReference: dimensions = CGSize(width: 512.0, height: 512.0) } @@ -129,6 +127,8 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { switch self.content { case let .image(_, imageType): return imageType == .rectangle + case .video: + return true default: return false } @@ -173,13 +173,8 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { imageType = .sticker } self.content = .image(image, imageType) - } else if let videoPath = try container.decodeIfPresent(String.self, forKey: .videoPath) { - var imageValue: UIImage? - 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 if let file = try container.decodeIfPresent(TelegramMediaFile.self, forKey: .videoFile) { + self.content = .video(file) } else { fatalError() } @@ -213,16 +208,8 @@ public final class DrawingStickerEntity: DrawingEntity, Codable { default: break } - case let .video(path, image, videoMirrored): - try container.encode(path, forKey: .videoPath) - 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 let .video(file): + try container.encode(file, forKey: .videoFile) case .dualVideoReference: try container.encode(true, forKey: .dualVideo) } diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift index 1eb826899c..380bb6da7c 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorComposerEntity.swift @@ -68,8 +68,8 @@ func composerEntitiesForDrawingEntity(account: Account, textScale: CGFloat, enti content = .file(file) case let .image(image, _): content = .image(image) - case let .video(path, _, _): - content = .video(path) + case let .video(file): + content = .video(file) case .dualVideoReference: return [] } @@ -135,8 +135,8 @@ private class MediaEditorComposerStaticEntity: MediaEditorComposerEntity { private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { public enum Content { case file(TelegramMediaFile) + case video(TelegramMediaFile) case image(UIImage) - case video(String) var file: TelegramMediaFile? { if case let .file(file) = self { @@ -146,6 +146,7 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { } } + let account: Account let content: Content let position: CGPoint let scale: CGFloat @@ -181,6 +182,7 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { let imagePromise = Promise() 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.position = position self.scale = scale @@ -263,27 +265,8 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { case let .image(image): self.isAnimated = false self.imagePromise.set(.single(image)) - case let .video(videoPath): + case .video: self.isAnimated = true - - let url = URL(fileURLWithPath: videoPath) - let asset = AVURLAsset(url: url) - - if let assetReader = try? AVAssetReader(asset: asset), let videoTrack = asset.tracks(withMediaType: .video).first { - let outputSettings: [String: Any] = [ - kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, - kCVPixelBufferMetalCompatibilityKey as String: true - ] - let videoOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: outputSettings) - videoOutput.alwaysCopiesSampleData = true - if assetReader.canAdd(videoOutput) { - assetReader.add(videoOutput) - } - - assetReader.startReading() - self.assetReader = assetReader - self.videoOutput = videoOutput - } } } @@ -291,51 +274,109 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { self.disposables.dispose() } - private var circleMaskFilter: CIFilter? + 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) + + if let assetReader = try? AVAssetReader(asset: asset), let videoTrack = asset.tracks(withMediaType: .video).first { + self.videoFrameRate = videoTrack.nominalFrameRate + let outputSettings: [String: Any] = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, + kCVPixelBufferMetalCompatibilityKey as String: true + ] + let videoOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: outputSettings) + videoOutput.alwaysCopiesSampleData = true + if assetReader.canAdd(videoOutput) { + assetReader.add(videoOutput) + } + + assetReader.startReading() + self.assetReader = assetReader + self.videoOutput = videoOutput + } + } + } + } + + private var videoFrameRate: Float? + private var maskFilter: CIFilter? func image(for time: CMTime, frameRate: Float, context: CIContext, completion: @escaping (CIImage?) -> Void) { + let currentTime = CMTimeGetSeconds(time) + if case .video = self.content { + if self.videoOutput == nil { + self.setupVideoOutput() + } if let videoOutput = self.videoOutput { - if let sampleBuffer = videoOutput.copyNextSampleBuffer(), let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) { - 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 frameAdvancement: Int = 0 + if let frameRate = self.videoFrameRate, frameRate > 0 { + let frameTime = 1.0 / Double(frameRate) + let frameIndex = Int(floor(currentTime / frameTime)) - var circleMaskFilter: CIFilter? - if let current = self.circleMaskFilter { - circleMaskFilter = current - } else { - let circleImage = generateImage(CGSize(width: minSide, height: minSide), scale: 1.0, rotatedContext: { size, context in - context.clear(CGRect(origin: .zero, size: size)) - context.setFillColor(UIColor.white.cgColor) - context.fillEllipse(in: CGRect(origin: .zero, size: size)) - })! - let circleMask = CIImage(image: circleImage) - if let filter = CIFilter(name: "CIBlendWithAlphaMask") { - filter.setValue(circleMask, forKey: kCIInputMaskImageKey) - self.circleMaskFilter = filter - circleMaskFilter = filter - } - } - - let _ = circleMaskFilter - if let circleMaskFilter { - circleMaskFilter.setValue(ciImage, forKey: kCIInputImageKey) - if let output = circleMaskFilter.outputImage { - ciImage = output + 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() } - completion(ciImage) + if let sampleBuffer, let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) { + var ciImage = CIImage(cvPixelBuffer: imageBuffer) + + var circleMaskFilter: CIFilter? + if let current = self.maskFilter { + circleMaskFilter = current + } else { + let circleImage = generateImage(ciImage.extent.size, scale: 1.0, rotatedContext: { size, context in + context.clear(CGRect(origin: .zero, size: size)) + context.setFillColor(UIColor.white.cgColor) + + 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) + if let filter = CIFilter(name: "CIBlendWithAlphaMask") { + filter.setValue(circleMask, forKey: kCIInputMaskImageKey) + self.maskFilter = filter + circleMaskFilter = filter + } + } + + let _ = circleMaskFilter + if let circleMaskFilter { + circleMaskFilter.setValue(ciImage, forKey: kCIInputImageKey) + if let output = circleMaskFilter.outputImage { + ciImage = output + } + } + + self.image = ciImage + completion(ciImage) + } } } else { completion(nil) } } else if self.isAnimated { - let currentTime = CMTimeGetSeconds(time) - var tintColor: UIColor? if let file = self.content.file, file.isCustomTemplateEmoji { tintColor = self.tintColor ?? UIColor(rgb: 0xffffff) @@ -389,8 +430,8 @@ private class MediaEditorComposerStickerEntity: MediaEditorComposerEntity { } } - if frameAdvancement == 0 && strongSelf.image != nil { - completion(strongSelf.image) + if frameAdvancement == 0, let image = strongSelf.image { + completion(image) } else { if let frame = takeFrame(max(1, frameAdvancement)) { var imagePixelBuffer: CVPixelBuffer? diff --git a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorDraft.swift b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorDraft.swift index 40cbe7784e..9672908567 100644 --- a/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorDraft.swift +++ b/submodules/TelegramUI/Components/MediaEditor/Sources/MediaEditorDraft.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import SwiftSignalKit +import CoreLocation import TelegramCore import TelegramUIPreferences import PersistentStringHash @@ -67,6 +68,8 @@ public final class MediaEditorDraft: Codable, Equatable { case caption case privacy case timestamp + case locationLatitude + case locationLongitude } public let path: String @@ -78,8 +81,9 @@ public final class MediaEditorDraft: Codable, Equatable { public let caption: NSAttributedString public let privacy: MediaEditorResultPrivacy? 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.isVideo = isVideo self.thumbnail = thumbnail @@ -89,6 +93,7 @@ public final class MediaEditorDraft: Codable, Equatable { self.caption = caption self.privacy = privacy self.timestamp = timestamp + self.location = location } 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 + + 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 { @@ -141,7 +152,7 @@ public final class MediaEditorDraft: Codable, Equatable { let chatInputText = ChatTextInputStateText(attributedText: self.caption) try container.encode(chatInputText, forKey: .caption) - if let privacy = self .privacy { + if let privacy = self.privacy { if let data = try? JSONEncoder().encode(privacy) { try container.encode(data, forKey: .privacy) } else { @@ -151,6 +162,14 @@ public final class MediaEditorDraft: Codable, Equatable { try container.encodeNil(forKey: .privacy) } 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) + } } } diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index a0a01f55f3..5a584363e2 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -807,7 +807,7 @@ final class MediaEditorScreenComponent: Component { containerSize: CGSize(width: 40.0, height: 40.0) ) 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 ) if let textButtonView = self.textButton.view { @@ -836,7 +836,7 @@ final class MediaEditorScreenComponent: Component { containerSize: CGSize(width: 40.0, height: 40.0) ) 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 ) if let stickerButtonView = self.stickerButton.view { @@ -1746,7 +1746,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate emojiItems, stickerItems ) |> map { emoji, stickers -> StickerPickerInputData in - return StickerPickerInputData(emoji: emoji, stickers: stickers, masks: nil) + return StickerPickerInputData(emoji: emoji, stickers: stickers, gifs: nil) } stickerPickerInputData.set(signal) @@ -2655,7 +2655,9 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate let tooltipController = SaveProgressScreen(context: self.context, content: .progress(text, 0.0)) tooltipController.cancelled = { [weak self] in if let self, let controller = self.controller { + controller.isSavingAvailable = true controller.cancelVideoExport() + controller.requestLayout(transition: .animated(duration: 0.25, curve: .easeInOut)) } } controller.present(tooltipController, in: .current) @@ -2667,37 +2669,46 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate guard let controller = self.controller else { return } - let galleryController = self.context.sharedContext.makeMediaPickerScreen(context: self.context, completion: { [weak self] result in - guard let self, let asset = result as? PHAsset else { + let galleryController = self.context.sharedContext.makeMediaPickerScreen(context: self.context, hasSearch: true, completion: { [weak self] result in + guard let self else { 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? { - let rect = CGRect(origin: .zero, size: image.size) - UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale) - let context = UIGraphicsGetCurrentContext() - - if let context = context { - let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius) - context.addPath(path.cgPath) - context.clip() - image.draw(in: rect) - } - - let newImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - return newImage + func roundedImageWithTransparentCorners(image: UIImage, cornerRadius: CGFloat) -> UIImage? { + let rect = CGRect(origin: .zero, size: image.size) + UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale) + let context = UIGraphicsGetCurrentContext() + + if let context = context { + let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius) + context.addPath(path.cgPath) + context.clip() + image.draw(in: rect) + } + + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage + } + + let completeWithImage: (UIImage) -> Void = { [weak self] image in + let updatedImage = roundedImageWithTransparentCorners(image: image, cornerRadius: floor(image.size.width * 0.03))! + 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) } - let updatedImage = roundedImageWithTransparentCorners(image: image, cornerRadius: floor(image.size.width * 0.03))! - self.interaction?.insertEntity(DrawingStickerEntity(content: .image(updatedImage, .rectangle)), scale: 2.5) } } + } else if let image = result as? UIImage { + completeWithImage(image) } }) galleryController.customModalStyleOverlayTransitionFactorUpdated = { [weak self, weak galleryController] transition in @@ -2945,7 +2956,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate switch mode { case .sticker: 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 if let self { if let content { @@ -3767,10 +3778,15 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate let duration = mediaEditor.duration ?? 0.0 var timestamp: Int32 + var location: CLLocationCoordinate2D? if case let .draft(draft, _) = subject { timestamp = draft.timestamp + location = draft.location } else { timestamp = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) + if case let .asset(asset) = subject { + location = asset.location?.coordinate + } } if let resultImage = mediaEditor.resultImage { @@ -3786,7 +3802,7 @@ public final class MediaEditorScreen: ViewController, UIDropInteractionDelegate if let thumbnailImage = generateScaledImage(image: resultImage, size: fittedSize) { let path = "\(Int64.random(in: .min ... .max)).jpg" 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())) if let 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 if let thumbnailImage = generateScaledImage(image: resultImage, size: fittedSize) { 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()) if let id { saveStorySource(engine: context.engine, item: draft, peerId: context.account.peerId, id: id) diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift index 7f15255b22..bfaece657b 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreen.swift @@ -300,6 +300,7 @@ final class ShareWithPeersScreenComponent: Component { private let categoryTemplateItem = ComponentView() private let peerTemplateItem = ComponentView() private let optionTemplateItem = ComponentView() + private let footerTemplateItem = ComponentView() private let itemContainerView: UIView private var visibleSectionHeaders: [Int: ComponentView] = [:] @@ -1033,7 +1034,7 @@ final class ShareWithPeersScreenComponent: Component { } )), maximumNumberOfLines: 0, - lineSpacing: 0.2, + lineSpacing: 0.1, highlightColor: UIColor(rgb: 0x007aff, alpha: 0.2), highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] { @@ -1166,7 +1167,7 @@ final class ShareWithPeersScreenComponent: Component { 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 { 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) { @@ -1645,7 +1646,6 @@ final class ShareWithPeersScreenComponent: Component { transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize)) - let categoryItemSize = self.categoryTemplateItem.update( transition: .immediate, component: AnyComponent(CategoryListItemComponent( @@ -1697,6 +1697,76 @@ final class ShareWithPeersScreenComponent: Component { 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] = [] if let stateValue = self.effectiveStateValue { if case .stories = component.stateContext.subject { @@ -1815,13 +1885,14 @@ final class ShareWithPeersScreenComponent: Component { if environment.inputHeight != 0.0 || !self.navigationTextFieldState.text.isEmpty { topInset = 0.0 } else { - let inset: CGFloat + var inset: CGFloat if case let .stories(editing) = component.stateContext.subject { if editing { - inset = 478.0 + inset = 351.0 } else { - inset = 630.0 + inset = 464.0 } + inset += 10.0 + environment.safeInsets.bottom + 50.0 + footersTotalHeight } else { 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))) 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 self.itemLayout = itemLayout diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD index 8a1b1d32a3..5c757f1d6e 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -84,6 +84,8 @@ swift_library( "//submodules/Speak", "//submodules/TranslateUI", "//submodules/TelegramNotices", + "//submodules/MediaPlayer:UniversalMediaPlayer", + "//submodules/TelegramUniversalVideoContent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift index f76433a039..e33421d5e5 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift @@ -758,6 +758,9 @@ final class StoryItemContentComponent: Component { if maskView.subviews.isEmpty { let referenceSize = availableSize 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 position = CGPoint(x: mediaArea.coordinates.x / 100.0 * referenceSize.width, y: mediaArea.coordinates.y / 100.0 * referenceSize.height) diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 3a473e34a0..bfbb525595 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -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) } - public func makeMediaPickerScreen(context: AccountContext, completion: @escaping (Any) -> Void) -> ViewController { - return mediaPickerController(context: context, completion: completion) + public func makeMediaPickerScreen(context: AccountContext, hasSearch: Bool, completion: @escaping (Any) -> Void) -> ViewController { + 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 { diff --git a/submodules/WebSearchUI/Sources/WebSearchController.swift b/submodules/WebSearchUI/Sources/WebSearchController.swift index 4fd1d986e2..009dcc2d8b 100644 --- a/submodules/WebSearchUI/Sources/WebSearchController.swift +++ b/submodules/WebSearchUI/Sources/WebSearchController.swift @@ -17,14 +17,15 @@ public enum WebSearchMode { public enum WebSearchControllerMode { case media(attachment: Bool, completion: (ChatContextResultCollection, TGMediaSelectionContext, TGMediaEditingContext, Bool) -> Void) + case editor(completion: (UIImage) -> Void) case avatar(initialQuery: String?, completion: (UIImage) -> Void) var mode: WebSearchMode { switch self { - case .media: - return .media - case .avatar: - return .avatar + case .media, .editor: + return .media + case .avatar: + return .avatar } } } @@ -81,7 +82,7 @@ public final class WebSearchController: ViewController { private var validLayout: ContainerViewLayout? private let context: AccountContext - private let mode: WebSearchControllerMode + let mode: WebSearchControllerMode private let peer: EnginePeer? private let chatLocation: ChatLocation? private let configuration: EngineConfiguration.SearchBots @@ -193,6 +194,8 @@ public final class WebSearchController: ViewController { var attachment = false if case let .media(attachmentValue, _) = mode { attachment = attachmentValue + } else if case .editor = mode { + attachment = true } let navigationContentNode = WebSearchNavigationContentNode(theme: presentationData.theme, strings: presentationData.strings, attachment: attachment) self.navigationContentNode = navigationContentNode @@ -218,6 +221,8 @@ public final class WebSearchController: ViewController { selectionState = TGMediaSelectionContext() case .avatar: selectionState = nil + case .editor: + selectionState = nil } let editingState = TGMediaEditingContext() self.controllerInteraction = WebSearchControllerInteraction(openResult: { [weak self] result in @@ -345,10 +350,13 @@ public final class WebSearchController: ViewController { super.viewWillAppear(animated) var select = false - if case let .avatar(initialQuery, _) = mode, let _ = initialQuery { + if case let .avatar(initialQuery, _) = self.mode, let _ = initialQuery { 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.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 } |> distinctUntilChanged - case .avatar: + case .avatar, .editor: scope = .single(.images) } @@ -467,9 +475,7 @@ public final class WebSearchController: ViewController { let delayRequest = true let signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError> = .single({ _ in return .contextRequestResult(nil, nil) }) - guard let peerId = self.peer?.id else { - return .single({ _ in return .contextRequestResult(nil, nil) }) - } + let peerId = self.peer?.id ?? self.context.account.peerId let botName: String? switch scope { diff --git a/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift b/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift index fc1f94cfff..77f646c76d 100644 --- a/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift +++ b/submodules/WebSearchUI/Sources/WebSearchControllerNode.swift @@ -791,18 +791,29 @@ class WebSearchControllerNode: ASDisplayNode { } } } else { - presentLegacyWebSearchEditor(context: self.context, theme: self.theme, result: currentResult, initialLayout: self.containerLayout?.0, updateHiddenMedia: { [weak self] id in - self?.hiddenMediaId.set(.single(id)) - }, transitionHostView: { [weak self] in - return self?.gridNode.view - }, transitionView: { [weak self] result in - return self?.transitionNode(for: result)?.transitionView() - }, completed: { [weak self] result in - if let strongSelf = self { - strongSelf.controllerInteraction.avatarCompleted(result) - strongSelf.cancel?() + 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) + } + }) } - }, present: present) + } else { + presentLegacyWebSearchEditor(context: self.context, theme: self.theme, result: currentResult, initialLayout: self.containerLayout?.0, updateHiddenMedia: { [weak self] id in + self?.hiddenMediaId.set(.single(id)) + }, transitionHostView: { [weak self] in + return self?.gridNode.view + }, transitionView: { [weak self] result in + return self?.transitionNode(for: result)?.transitionView() + }, completed: { [weak self] result in + if let strongSelf = self { + strongSelf.controllerInteraction.avatarCompleted(result) + strongSelf.cancel?() + } + }, present: present) + } } }