From 862cb0b36640bc2991b0be550031778be73845a5 Mon Sep 17 00:00:00 2001 From: Ali <> Date: Fri, 19 May 2023 18:10:12 +0400 Subject: [PATCH] [WIP] Stories --- .../Sources/ChatListController.swift | 17 +- .../Source/Base/Transition.swift | 14 +- .../Source/Components/Button.swift | 4 + .../Sources/DirectMediaImageCache.swift | 130 +- .../Display/Source/CAAnimationUtils.swift | 1 + .../Sources/ImageCompression.swift | 9 + .../Sources/SparseItemGrid.swift | 11 +- .../Sources/Network/Download.swift | 2 +- .../Messages/StoryListContext.swift | 369 ++-- submodules/TelegramUI/BUILD | 1 + .../Sources/AudioWaveformComponent.swift | 35 +- .../Sources/LottieComponent.swift | 2 + .../Sources/MediaEditorScreen.swift | 6 + .../MessageInputPanelComponent/BUILD | 3 + .../Sources/MediaPreviewPanelComponent.swift | 357 +++ .../MediaRecordingPanelComponent.swift | 230 +- .../MessageInputActionButtonComponent.swift | 87 +- .../Sources/MessageInputPanelComponent.swift | 330 ++- .../PeerInfoVisualMediaPaneNode/BUILD | 44 + .../Sources/PeerInfoStoryPaneNode.swift | 1935 +++++++++++++++++ .../PeerInfoVisualMediaPaneNode.swift | 626 +----- .../Stories/StoryContainerScreen/BUILD | 1 + .../Sources/StoryContainerScreen.swift | 15 +- .../StoryItemSetContainerComponent.swift | 79 +- ...StoryItemSetContainerViewSendMessage.swift | 106 +- .../Sources/StoryChatContent.swift | 8 +- .../Sources/StoryItemContentComponent.swift | 2 +- .../Sources/StoryPeerListComponent.swift | 9 +- .../Contents.json | 20 +- ...odernConversationAudioSlideToCancel@2x.png | Bin 496 -> 0 bytes ...odernConversationAudioSlideToCancel@3x.png | Bin 645 -> 0 bytes .../arrowleft.svg | 4 + .../ChatMessageInteractiveFileNode.swift | 4 +- .../PeerInfoGroupsInCommonPaneNode.swift | 1 + .../PeerInfo/Panes/PeerInfoListPaneNode.swift | 1 + .../PeerInfo/Panes/PeerInfoMembersPane.swift | 1 + .../Sources/PeerInfo/PeerInfoData.swift | 12 +- .../Sources/PeerInfo/PeerInfoHeaderNode.swift | 1 + .../PeerInfo/PeerInfoPaneContainerNode.swift | 45 +- .../Sources/PeerInfo/PeerInfoScreen.swift | 3 +- .../Sources/PeerInfoGifPaneNode.swift | 1 + .../Sources/TelegramRootController.swift | 16 +- 42 files changed, 3617 insertions(+), 925 deletions(-) create mode 100644 submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaPreviewPanelComponent.swift create mode 100644 submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD create mode 100644 submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift rename submodules/TelegramUI/{Sources/PeerInfo/Panes => Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources}/PeerInfoVisualMediaPaneNode.swift (79%) delete mode 100644 submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/ModernConversationAudioSlideToCancel@2x.png delete mode 100644 submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/ModernConversationAudioSlideToCancel@3x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/arrowleft.svg diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 2c72289ea5..fc7028e2bc 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -1393,12 +1393,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController guard let self else { return } + let storyContainerScreen = StoryContainerScreen( context: self.context, initialFocusedId: AnyHashable(peerId), initialContent: initialContent, transitionIn: nil, - transitionOut: { _ in + transitionOut: { _, _ in return nil } ) @@ -2481,28 +2482,18 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController initialFocusedId: initialFocusedId, initialContent: initialContent, transitionIn: transitionIn, - transitionOut: { [weak self] peerId in + transitionOut: { [weak self] peerId, _ in guard let self else { return nil } if let componentView = self.headerContentView.view as? ChatListHeaderComponent.View { if let transitionView = componentView.storyPeerListView()?.transitionViewForItem(peerId: peerId) { - //let localRect = transitionView.convert(transitionView.bounds, to: self.view) - - /*Queue.mainQueue().after(0.2 * UIView.animationDurationFactor, { [weak self] in - HapticFeedback().impact() - self?.animateRipple(centerLocation: localRect.center) - })*/ - return StoryContainerScreen.TransitionOut( destinationView: transitionView, destinationRect: transitionView.bounds, destinationCornerRadius: transitionView.bounds.height * 0.5, - completed: { [weak self] in - let _ = self - //self?.animateRipple(centerLocation: localRect.center) - } + completed: {} ) } } diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index f5ef3ae11e..860a4d3e86 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -432,7 +432,7 @@ public struct Transition { self.setTransform(layer: view.layer, transform: transform, completion: completion) } - public func setTransformAsKeyframes(view: UIView, transform: (CGFloat) -> CATransform3D, completion: ((Bool) -> Void)? = nil) { + public func setTransformAsKeyframes(view: UIView, transform: (CGFloat, Bool) -> CATransform3D, completion: ((Bool) -> Void)? = nil) { self.setTransformAsKeyframes(layer: view.layer, transform: transform, completion: completion) } @@ -477,8 +477,8 @@ public struct Transition { } } - public func setTransformAsKeyframes(layer: CALayer, transform: (CGFloat) -> CATransform3D, completion: ((Bool) -> Void)? = nil) { - let finalTransform = transform(1.0) + public func setTransformAsKeyframes(layer: CALayer, transform: (CGFloat, Bool) -> CATransform3D, completion: ((Bool) -> Void)? = nil) { + let finalTransform = transform(1.0, true) let t = layer.presentation()?.transform ?? layer.transform if CATransform3DEqualToTransform(t, finalTransform) { @@ -495,7 +495,7 @@ public struct Transition { switch self.animation { case .none: - layer.transform = transform(1.0) + layer.transform = transform(1.0, true) completion?(true) case let .curve(duration, curve): let framesPerSecond: CGFloat @@ -507,7 +507,7 @@ public struct Transition { let numValues = Int(framesPerSecond * duration) if numValues == 0 { - layer.transform = transform(1.0) + layer.transform = transform(1.0, true) completion?(true) return } @@ -516,10 +516,10 @@ public struct Transition { for i in 0 ... numValues { let t = curve.solve(at: CGFloat(i) / CGFloat(numValues)) - values.append(NSValue(caTransform3D: transform(t))) + values.append(NSValue(caTransform3D: transform(t, false))) } - layer.transform = transform(1.0) + layer.transform = transform(1.0, true) layer.animateKeyframes( values: values, duration: duration, diff --git a/submodules/ComponentFlow/Source/Components/Button.swift b/submodules/ComponentFlow/Source/Components/Button.swift index dcf4a880b8..4275ff0a2b 100644 --- a/submodules/ComponentFlow/Source/Components/Button.swift +++ b/submodules/ComponentFlow/Source/Components/Button.swift @@ -111,6 +111,10 @@ public final class Button: Component { public final class View: UIButton, ComponentTaggedView { private let contentView: ComponentHostView + public var content: UIView? { + return self.contentView.componentView + } + private var component: Button? private var currentIsHighlighted: Bool = false { didSet { diff --git a/submodules/DirectMediaImageCache/Sources/DirectMediaImageCache.swift b/submodules/DirectMediaImageCache/Sources/DirectMediaImageCache.swift index 0ae5bceca9..9483811d6a 100644 --- a/submodules/DirectMediaImageCache/Sources/DirectMediaImageCache.swift +++ b/submodules/DirectMediaImageCache/Sources/DirectMediaImageCache.swift @@ -76,8 +76,12 @@ private func storeImage(context: DrawingContext, mediaBox: MediaBox, resourceId: switch imageType { case .blurredThumbnail: representationId = "blurred32" - case let .square(width): - representationId = "shm\(width)" + case let .square(width, aspectRatio): + if aspectRatio == 1.0 { + representationId = "shm\(width)" + } else { + representationId = "shm\(width)-\(aspectRatio)" + } } let path = mediaBox.cachedRepresentationPathForId(resourceId.stringRepresentation, representationId: representationId, keepDuration: .general) @@ -229,7 +233,7 @@ public final class DirectMediaImageCache { fileprivate enum ImageType { case blurredThumbnail - case square(width: Int) + case square(width: Int, aspectRatio: CGFloat) } private let account: Account @@ -243,13 +247,17 @@ public final class DirectMediaImageCache { switch imageType { case .blurredThumbnail: representationId = "blurred32" - case let .square(width): - representationId = "shm\(width)" + case let .square(width, aspectRatio): + if aspectRatio == 1.0 { + representationId = "shm\(width)" + } else { + representationId = "shm\(width)-\(aspectRatio)" + } } return self.account.postbox.mediaBox.cachedRepresentationPathForId(resourceId.stringRepresentation, representationId: representationId, keepDuration: .general) } - private func getLoadSignal(width: Int, userLocation: MediaResourceUserLocation, userContentType: MediaResourceUserContentType, resource: MediaResourceReference, resourceSizeLimit: Int64) -> Signal? { + private func getLoadSignal(width: Int, aspectRatio: CGFloat, userLocation: MediaResourceUserLocation, userContentType: MediaResourceUserContentType, resource: MediaResourceReference, resourceSizeLimit: Int64) -> Signal? { return Signal { subscriber in let fetch = fetchedMediaResource( mediaBox: self.account.postbox.mediaBox, @@ -282,7 +290,7 @@ public final class DirectMediaImageCache { let data = dataSignal.start(next: { data in if let data = data, let image = UIImage(data: data) { - let scaledSize = CGSize(width: CGFloat(width), height: CGFloat(width)) + let scaledSize = CGSize(width: CGFloat(width), height: floor(CGFloat(width) / aspectRatio)) guard let scaledContext = DrawingContext(size: scaledSize, scale: 1.0, opaque: true) else { subscriber.putNext(nil) subscriber.putCompletion() @@ -294,7 +302,7 @@ public final class DirectMediaImageCache { context.draw(image.cgImage!, in: imageRect) } - if let scaledImage = storeImage(context: scaledContext, mediaBox: self.account.postbox.mediaBox, resourceId: resource.resource.id, imageType: .square(width: width)) { + if let scaledImage = storeImage(context: scaledContext, mediaBox: self.account.postbox.mediaBox, resourceId: resource.resource.id, imageType: .square(width: width, aspectRatio: aspectRatio)) { subscriber.putNext(scaledImage) subscriber.putCompletion() } @@ -342,8 +350,16 @@ public final class DirectMediaImageCache { private func getResource(message: Message, file: TelegramMediaFile, width: Int) -> (resource: MediaResourceReference, size: Int64)? { return self.getProgressiveSize(mediaReference: MediaReference.message(message: MessageReference(message), media: file).abstract, width: width, representations: file.previewRepresentations) } + + private func getResource(peer: PeerReference, story: StoryListContext.Item, image: TelegramMediaImage, width: Int) -> (resource: MediaResourceReference, size: Int64)? { + return self.getProgressiveSize(mediaReference: MediaReference.story(peer: peer, id: story.id, media: image).abstract, width: width, representations: image.representations) + } - private func getImageSynchronous(message: Message, userLocation: MediaResourceUserLocation, media: Media, width: Int, possibleWidths: [Int], includeBlurred: Bool) -> GetMediaResult? { + private func getResource(peer: PeerReference, story: StoryListContext.Item, file: TelegramMediaFile, width: Int) -> (resource: MediaResourceReference, size: Int64)? { + return self.getProgressiveSize(mediaReference: MediaReference.story(peer: peer, id: story.id, media: file).abstract, width: width, representations: file.previewRepresentations) + } + + private func getImageSynchronous(message: Message, userLocation: MediaResourceUserLocation, media: Media, width: Int, aspectRatio: CGFloat, possibleWidths: [Int], includeBlurred: Bool) -> GetMediaResult? { var immediateThumbnailData: Data? var resource: (resource: MediaResourceReference, size: Int64)? if let image = media as? TelegramMediaImage { @@ -367,11 +383,11 @@ public final class DirectMediaImageCache { var resultImage: UIImage? for otherWidth in possibleWidths.reversed() { if otherWidth == width { - if let data = try? Data(contentsOf: URL(fileURLWithPath: self.getCachePath(resourceId: resource.resource.resource.id, imageType: .square(width: otherWidth)))), let image = loadImage(data: data) { + if let data = try? Data(contentsOf: URL(fileURLWithPath: self.getCachePath(resourceId: resource.resource.resource.id, imageType: .square(width: otherWidth, aspectRatio: aspectRatio)))), let image = loadImage(data: data) { return GetMediaResult(image: image, blurredImage: blurredImage, loadSignal: nil) } } else { - if let data = try? Data(contentsOf: URL(fileURLWithPath: self.getCachePath(resourceId: resource.resource.resource.id, imageType: .square(width: otherWidth)))), let image = loadImage(data: data) { + if let data = try? Data(contentsOf: URL(fileURLWithPath: self.getCachePath(resourceId: resource.resource.resource.id, imageType: .square(width: otherWidth, aspectRatio: aspectRatio)))), let image = loadImage(data: data) { resultImage = image } } @@ -387,12 +403,12 @@ public final class DirectMediaImageCache { } } - return GetMediaResult(image: resultImage, blurredImage: blurredImage, loadSignal: self.getLoadSignal(width: width, userLocation: userLocation, userContentType: .image, resource: resource.resource, resourceSizeLimit: resource.size)) + return GetMediaResult(image: resultImage, blurredImage: blurredImage, loadSignal: self.getLoadSignal(width: width, aspectRatio: aspectRatio, userLocation: userLocation, userContentType: .image, resource: resource.resource, resourceSizeLimit: resource.size)) } public func getImage(message: Message, media: Media, width: Int, possibleWidths: [Int], includeBlurred: Bool = false, synchronous: Bool) -> GetMediaResult? { if synchronous { - return self.getImageSynchronous(message: message, userLocation: .peer(message.id.peerId), media: media, width: width, possibleWidths: possibleWidths, includeBlurred: includeBlurred) + return self.getImageSynchronous(message: message, userLocation: .peer(message.id.peerId), media: media, width: width, aspectRatio: 1.0, possibleWidths: possibleWidths, includeBlurred: includeBlurred) } else { var immediateThumbnailData: Data? if let image = media as? TelegramMediaImage { @@ -405,7 +421,93 @@ public final class DirectMediaImageCache { blurredImage = blurredImageValue } return GetMediaResult(image: nil, blurredImage: blurredImage, loadSignal: Signal { subscriber in - let result = self.getImageSynchronous(message: message, userLocation: .peer(message.id.peerId), media: media, width: width, possibleWidths: possibleWidths, includeBlurred: includeBlurred) + let result = self.getImageSynchronous(message: message, userLocation: .peer(message.id.peerId), media: media, width: width, aspectRatio: 1.0, possibleWidths: possibleWidths, includeBlurred: includeBlurred) + guard let result = result else { + subscriber.putNext(nil) + subscriber.putCompletion() + + return EmptyDisposable + } + + if let image = result.image { + subscriber.putNext(image) + } + + if let signal = result.loadSignal { + return signal.start(next: subscriber.putNext, error: subscriber.putError, completed: subscriber.putCompletion) + } else { + subscriber.putCompletion() + + return EmptyDisposable + } + } + |> runOn(.concurrentDefaultQueue())) + } + } + + private func getImageSynchronous(peer: PeerReference, story: StoryListContext.Item, userLocation: MediaResourceUserLocation, media: Media, width: Int, aspectRatio: CGFloat, possibleWidths: [Int], includeBlurred: Bool) -> GetMediaResult? { + var immediateThumbnailData: Data? + var resource: (resource: MediaResourceReference, size: Int64)? + if let image = media as? TelegramMediaImage { + immediateThumbnailData = image.immediateThumbnailData + resource = self.getResource(peer: peer, story: story, image: image, width: width) + } else if let file = media as? TelegramMediaFile { + immediateThumbnailData = file.immediateThumbnailData + resource = self.getResource(peer: peer, story: story, file: file, width: width) + } + + guard let resource = resource else { + return nil + } + + + var blurredImage: UIImage? + if includeBlurred, let data = immediateThumbnailData.flatMap(decodeTinyThumbnail), let image = loadImage(data: data), let blurredImageValue = generateBlurredThumbnail(image: image, adjustSaturation: true) { + blurredImage = blurredImageValue + } + + var resultImage: UIImage? + for otherWidth in possibleWidths.reversed() { + if otherWidth == width { + if let data = try? Data(contentsOf: URL(fileURLWithPath: self.getCachePath(resourceId: resource.resource.resource.id, imageType: .square(width: otherWidth, aspectRatio: aspectRatio)))), let image = loadImage(data: data) { + return GetMediaResult(image: image, blurredImage: blurredImage, loadSignal: nil) + } + } else { + if let data = try? Data(contentsOf: URL(fileURLWithPath: self.getCachePath(resourceId: resource.resource.resource.id, imageType: .square(width: otherWidth, aspectRatio: aspectRatio)))), let image = loadImage(data: data) { + resultImage = image + } + } + } + + if resultImage == nil { + if let data = try? Data(contentsOf: URL(fileURLWithPath: self.getCachePath(resourceId: resource.resource.resource.id, imageType: .blurredThumbnail))), let image = loadImage(data: data) { + resultImage = image + } else if let data = immediateThumbnailData.flatMap(decodeTinyThumbnail), let image = loadImage(data: data) { + if let blurredImageValue = generateBlurredThumbnail(image: image) { + resultImage = blurredImageValue + } + } + } + + return GetMediaResult(image: resultImage, blurredImage: blurredImage, loadSignal: self.getLoadSignal(width: width, aspectRatio: aspectRatio, userLocation: userLocation, userContentType: .image, resource: resource.resource, resourceSizeLimit: resource.size)) + } + + public func getImage(peer: PeerReference, story: StoryListContext.Item, media: Media, width: Int, aspectRatio: CGFloat, possibleWidths: [Int], includeBlurred: Bool = false, synchronous: Bool) -> GetMediaResult? { + if synchronous { + return self.getImageSynchronous(peer: peer, story: story, userLocation: .peer(peer.id), media: media, width: width, aspectRatio: aspectRatio, possibleWidths: possibleWidths, includeBlurred: includeBlurred) + } else { + var immediateThumbnailData: Data? + if let image = media as? TelegramMediaImage { + immediateThumbnailData = image.immediateThumbnailData + } else if let file = media as? TelegramMediaFile { + immediateThumbnailData = file.immediateThumbnailData + } + var blurredImage: UIImage? + if includeBlurred, let data = immediateThumbnailData.flatMap(decodeTinyThumbnail), let image = loadImage(data: data), let blurredImageValue = generateBlurredThumbnail(image: image, adjustSaturation: true) { + blurredImage = blurredImageValue + } + return GetMediaResult(image: nil, blurredImage: blurredImage, loadSignal: Signal { subscriber in + let result = self.getImageSynchronous(peer: peer, story: story, userLocation: .peer(peer.id), media: media, width: width, aspectRatio: aspectRatio, possibleWidths: possibleWidths, includeBlurred: includeBlurred) guard let result = result else { subscriber.putNext(nil) subscriber.putCompletion() diff --git a/submodules/Display/Source/CAAnimationUtils.swift b/submodules/Display/Source/CAAnimationUtils.swift index 585317db34..1e0c0999c8 100644 --- a/submodules/Display/Source/CAAnimationUtils.swift +++ b/submodules/Display/Source/CAAnimationUtils.swift @@ -249,6 +249,7 @@ public extension CALayer { animation.speed = speed animation.duration = duration animation.isAdditive = additive + animation.calculationMode = .linear if let mediaTimingFunction = mediaTimingFunction { animation.timingFunction = mediaTimingFunction } else { diff --git a/submodules/ImageCompression/Sources/ImageCompression.swift b/submodules/ImageCompression/Sources/ImageCompression.swift index 8988c56557..7fe7b191d5 100644 --- a/submodules/ImageCompression/Sources/ImageCompression.swift +++ b/submodules/ImageCompression/Sources/ImageCompression.swift @@ -3,6 +3,15 @@ import AVFoundation import UIKit import MozjpegBinding +public func scaleImageToPixelSize(image: UIImage, size: CGSize) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(size, true, 1.0) + image.draw(in: CGRect(origin: CGPoint(), size: size), blendMode: .copy, alpha: 1.0) + let result = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return result +} + public func extractImageExtraScans(_ data: Data) -> [Int] { return extractJPEGDataScans(data).map { item in return item.intValue diff --git a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift index b407a91364..883e7921fe 100644 --- a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift +++ b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift @@ -405,7 +405,11 @@ public final class SparseItemGrid: ASDisplayNode { let itemsPerRow = CGFloat(zoomLevel.rawValue) self.itemsPerRow = Int(itemsPerRow) let itemSize = floorToScreenPixels((width - (self.itemSpacing * CGFloat(self.itemsPerRow - 1))) / itemsPerRow) - self.itemSize = CGSize(width: itemSize, height: itemSize) + if let fixedItemAspect = containerLayout.fixedItemAspect { + self.itemSize = CGSize(width: itemSize, height: floor(itemSize / fixedItemAspect)) + } else { + self.itemSize = CGSize(width: itemSize, height: itemSize) + } self.lastItemSize = width - (self.itemSize.width + self.itemSpacing) * CGFloat(self.itemsPerRow - 1) } @@ -1308,6 +1312,7 @@ public final class SparseItemGrid: ASDisplayNode { var scrollIndicatorInsets: UIEdgeInsets var lockScrollingAtTop: Bool var fixedItemHeight: CGFloat? + var fixedItemAspect: CGFloat? } private var tapRecognizer: UITapGestureRecognizer? @@ -1565,9 +1570,9 @@ public final class SparseItemGrid: ASDisplayNode { } } - public func update(size: CGSize, insets: UIEdgeInsets, useSideInsets: Bool, scrollIndicatorInsets: UIEdgeInsets, lockScrollingAtTop: Bool, fixedItemHeight: CGFloat?, items: Items, theme: PresentationTheme, synchronous: SparseItemGrid.Synchronous) { + public func update(size: CGSize, insets: UIEdgeInsets, useSideInsets: Bool, scrollIndicatorInsets: UIEdgeInsets, lockScrollingAtTop: Bool, fixedItemHeight: CGFloat?, fixedItemAspect: CGFloat?, items: Items, theme: PresentationTheme, synchronous: SparseItemGrid.Synchronous) { self.theme = theme - let containerLayout = ContainerLayout(size: size, insets: insets, useSideInsets: useSideInsets, scrollIndicatorInsets: scrollIndicatorInsets, lockScrollingAtTop: lockScrollingAtTop, fixedItemHeight: fixedItemHeight) + let containerLayout = ContainerLayout(size: size, insets: insets, useSideInsets: useSideInsets, scrollIndicatorInsets: scrollIndicatorInsets, lockScrollingAtTop: lockScrollingAtTop, fixedItemHeight: fixedItemHeight, fixedItemAspect: fixedItemAspect) self.containerLayout = containerLayout self.items = items self.scrollingArea.isHidden = lockScrollingAtTop diff --git a/submodules/TelegramCore/Sources/Network/Download.swift b/submodules/TelegramCore/Sources/Network/Download.swift index ff00e74251..3bf92c09da 100644 --- a/submodules/TelegramCore/Sources/Network/Download.swift +++ b/submodules/TelegramCore/Sources/Network/Download.swift @@ -107,7 +107,7 @@ class Download: NSObject, MTRequestMessageServiceDelegate { let saveFilePart: (FunctionDescription, Buffer, DeserializeFunctionResponse) if asBigPart { let totalParts: Int32 - if let bigTotalParts = bigTotalParts { + if let bigTotalParts = bigTotalParts, bigTotalParts > 0 && bigTotalParts < Int32.max { totalParts = Int32(bigTotalParts) } else { totalParts = -1 diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift index d60011901a..ab48dd6f33 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift @@ -172,17 +172,19 @@ public final class StoryListContext { self.stateValue = State(itemSets: [], uploadProgress: nil, loadMoreToken: LoadMoreToken(value: nil)) self.state.set(.single(self.stateValue)) - let _ = (account.postbox.transaction { transaction -> Peer? in - return transaction.getPeer(account.peerId) - } - |> deliverOnMainQueue).start(next: { [weak self] peer in - guard let self, let peer else { - return + if case .all = scope { + let _ = (account.postbox.transaction { transaction -> Peer? in + return transaction.getPeer(account.peerId) } - self.stateValue = State(itemSets: [ - PeerItemSet(peerId: peer.id, peer: EnginePeer(peer), maxReadId: 0, items: [], totalCount: 0) - ], uploadProgress: nil, loadMoreToken: LoadMoreToken(value: nil)) - }) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let peer else { + return + } + self.stateValue = State(itemSets: [ + PeerItemSet(peerId: peer.id, peer: EnginePeer(peer), maxReadId: 0, items: [], totalCount: 0) + ], uploadProgress: nil, loadMoreToken: LoadMoreToken(value: nil)) + }) + } self.updatesDisposable = (account.stateManager.storyUpdates |> deliverOn(queue)).start(next: { [weak self] updates in @@ -253,7 +255,12 @@ public final class StoryListContext { items.sort(by: { lhsItem, rhsItem in if lhsItem.timestamp != rhsItem.timestamp { - return lhsItem.timestamp < rhsItem.timestamp + switch scope { + case .all: + return lhsItem.timestamp > rhsItem.timestamp + case .peer: + return lhsItem.timestamp < rhsItem.timestamp + } } return lhsItem.id < rhsItem.id }) @@ -267,13 +274,23 @@ public final class StoryListContext { } } if !found, let peer = peers[peerId] { - itemSets.insert(PeerItemSet( - peerId: peerId, - peer: EnginePeer(peer), - maxReadId: 0, - items: [item], - totalCount: 1 - ), at: 0) + let matchesScope: Bool + if case .all = scope { + matchesScope = true + } else if case .peer(peerId) = scope { + matchesScope = true + } else { + matchesScope = false + } + if matchesScope { + itemSets.insert(PeerItemSet( + peerId: peerId, + peer: EnginePeer(peer), + maxReadId: 0, + items: [item], + totalCount: 1 + ), at: 0) + } } case let .read(peerId, maxId): for i in 0 ..< itemSets.count { @@ -301,7 +318,12 @@ public final class StoryListContext { } if lhsItem.timestamp != rhsItem.timestamp { - return lhsItem.timestamp > rhsItem.timestamp + switch scope { + case .all: + return lhsItem.timestamp > rhsItem.timestamp + case .peer: + return lhsItem.timestamp < rhsItem.timestamp + } } return lhsItem.id > rhsItem.id }) @@ -342,7 +364,7 @@ public final class StoryListContext { guard let inputPeer = inputPeer else { return .single(nil) } - return account.network.request(Api.functions.stories.getUserStories(flags: 0, userId: inputPeer, offsetId: 0, limit: 100)) + return account.network.request(Api.functions.stories.getUserStories(flags: 0, userId: inputPeer, offsetId: 0, limit: 30)) |> map(Optional.init) |> `catch` { _ -> Signal in return .single(nil) @@ -433,139 +455,220 @@ public final class StoryListContext { self.isLoadingMore = true let account = self.account + let scope = self.scope self.pollDisposable?.dispose() self.pollDisposable = nil - self.loadMoreDisposable.set((account.network.request(Api.functions.stories.getAllStories(offset: loadMoreToken)) - |> map(Optional.init) - |> `catch` { _ -> Signal in - return .single(nil) - } - |> mapToSignal { result -> Signal<([PeerItemSet], LoadMoreToken?), NoError> in - guard let result else { - return .single(([], nil)) + switch scope { + case .all: + self.loadMoreDisposable.set((account.network.request(Api.functions.stories.getAllStories(offset: loadMoreToken)) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) } - return account.postbox.transaction { transaction -> ([PeerItemSet], LoadMoreToken?) in - switch result { - case let .allStories(_, userStorySets, nextOffset, users): - var parsedItemSets: [PeerItemSet] = [] - - var peers: [Peer] = [] - var peerPresences: [PeerId: Api.User] = [:] - - for user in users { - let telegramUser = TelegramUser(user: user) - peers.append(telegramUser) - peerPresences[telegramUser.id] = user - } - - updatePeers(transaction: transaction, peers: peers, update: { _, updated -> Peer in - return updated - }) - updatePeerPresences(transaction: transaction, accountPeerId: account.peerId, peerPresences: peerPresences) - - for userStories in userStorySets { - let apiUserId: Int64 - let apiStories: [Api.StoryItem] - var apiTotalCount: Int32? - var apiMaxReadId: Int32 = 0 - switch userStories { - case let .userStories(_, userId, maxReadId, stories, missingCount): - apiUserId = userId - apiStories = stories - apiTotalCount = (missingCount ?? 0) + Int32(stories.count) - apiMaxReadId = maxReadId ?? 0 + |> mapToSignal { result -> Signal<([PeerItemSet], LoadMoreToken?), NoError> in + guard let result else { + return .single(([], nil)) + } + return account.postbox.transaction { transaction -> ([PeerItemSet], LoadMoreToken?) in + switch result { + case let .allStories(_, userStorySets, nextOffset, users): + var parsedItemSets: [PeerItemSet] = [] + + var peers: [Peer] = [] + var peerPresences: [PeerId: Api.User] = [:] + + for user in users { + let telegramUser = TelegramUser(user: user) + peers.append(telegramUser) + peerPresences[telegramUser.id] = user } - let peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(apiUserId)) - for apiStory in apiStories { - if let item = _internal_parseApiStoryItem(transaction: transaction, peerId: peerId, apiStory: apiStory) { - if !parsedItemSets.isEmpty && parsedItemSets[parsedItemSets.count - 1].peerId == peerId { - parsedItemSets[parsedItemSets.count - 1].items.append(item) - } else { - parsedItemSets.append(StoryListContext.PeerItemSet( - peerId: peerId, - peer: transaction.getPeer(peerId).flatMap(EnginePeer.init), - maxReadId: apiMaxReadId, - items: [item], - totalCount: apiTotalCount.flatMap(Int.init) - )) + updatePeers(transaction: transaction, peers: peers, update: { _, updated -> Peer in + return updated + }) + updatePeerPresences(transaction: transaction, accountPeerId: account.peerId, peerPresences: peerPresences) + + for userStories in userStorySets { + let apiUserId: Int64 + let apiStories: [Api.StoryItem] + var apiTotalCount: Int32? + var apiMaxReadId: Int32 = 0 + switch userStories { + case let .userStories(_, userId, maxReadId, stories, missingCount): + apiUserId = userId + apiStories = stories + apiTotalCount = (missingCount ?? 0) + Int32(stories.count) + apiMaxReadId = maxReadId ?? 0 + } + + let peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(apiUserId)) + for apiStory in apiStories { + if let item = _internal_parseApiStoryItem(transaction: transaction, peerId: peerId, apiStory: apiStory) { + if !parsedItemSets.isEmpty && parsedItemSets[parsedItemSets.count - 1].peerId == peerId { + parsedItemSets[parsedItemSets.count - 1].items.append(item) + } else { + parsedItemSets.append(StoryListContext.PeerItemSet( + peerId: peerId, + peer: transaction.getPeer(peerId).flatMap(EnginePeer.init), + maxReadId: apiMaxReadId, + items: [item], + totalCount: apiTotalCount.flatMap(Int.init) + )) + } } } } - } - - if !parsedItemSets.contains(where: { $0.peerId == account.peerId }) { - if let peer = transaction.getPeer(account.peerId) { - parsedItemSets.insert(PeerItemSet(peerId: peer.id, peer: EnginePeer(peer), maxReadId: 0, items: [], totalCount: 0), at: 0) + + if !parsedItemSets.contains(where: { $0.peerId == account.peerId }) { + if let peer = transaction.getPeer(account.peerId) { + parsedItemSets.insert(PeerItemSet(peerId: peer.id, peer: EnginePeer(peer), maxReadId: 0, items: [], totalCount: 0), at: 0) + } } + + return (parsedItemSets, nextOffset.flatMap { LoadMoreToken(value: $0) }) } - - return (parsedItemSets, nextOffset.flatMap { LoadMoreToken(value: $0) }) } } - } - |> deliverOn(self.queue)).start(next: { [weak self] result in - guard let `self` = self else { - return - } - self.isLoadingMore = false - - var itemSets = self.stateValue.itemSets - for itemSet in result.0 { - if let index = itemSets.firstIndex(where: { $0.peerId == itemSet.peerId }) { - let currentItemSet = itemSets[index] - - var items = currentItemSet.items - for item in itemSet.items { - if !items.contains(where: { $0.id == item.id }) { - items.append(item) - } - } - - items.sort(by: { lhsItem, rhsItem in - if lhsItem.timestamp != rhsItem.timestamp { - return lhsItem.timestamp < rhsItem.timestamp - } - return lhsItem.id < rhsItem.id - }) - - itemSets[index] = PeerItemSet( - peerId: itemSet.peerId, - peer: itemSet.peer, - maxReadId: itemSet.maxReadId, - items: items, - totalCount: items.count - ) - } else { - itemSet.items.sort(by: { lhsItem, rhsItem in - if lhsItem.timestamp != rhsItem.timestamp { - return lhsItem.timestamp < rhsItem.timestamp - } - return lhsItem.id < rhsItem.id - }) - itemSets.append(itemSet) + |> deliverOn(self.queue)).start(next: { [weak self] result in + guard let `self` = self else { + return } - } - - itemSets.sort(by: { lhs, rhs in - guard let lhsItem = lhs.items.first, let rhsItem = rhs.items.first else { - if lhs.items.first != nil { - return false + self.isLoadingMore = false + + var itemSets = self.stateValue.itemSets + for itemSet in result.0 { + if let index = itemSets.firstIndex(where: { $0.peerId == itemSet.peerId }) { + let currentItemSet = itemSets[index] + + var items = currentItemSet.items + for item in itemSet.items { + if !items.contains(where: { $0.id == item.id }) { + items.append(item) + } + } + + items.sort(by: { lhsItem, rhsItem in + if lhsItem.timestamp != rhsItem.timestamp { + switch scope { + case .all: + return lhsItem.timestamp > rhsItem.timestamp + case .peer: + return lhsItem.timestamp < rhsItem.timestamp + } + } + return lhsItem.id < rhsItem.id + }) + + itemSets[index] = PeerItemSet( + peerId: itemSet.peerId, + peer: itemSet.peer, + maxReadId: itemSet.maxReadId, + items: items, + totalCount: items.count + ) } else { - return true + itemSet.items.sort(by: { lhsItem, rhsItem in + if lhsItem.timestamp != rhsItem.timestamp { + switch scope { + case .all: + return lhsItem.timestamp > rhsItem.timestamp + case .peer: + return lhsItem.timestamp < rhsItem.timestamp + } + } + return lhsItem.id < rhsItem.id + }) + itemSets.append(itemSet) } } - if lhsItem.timestamp != rhsItem.timestamp { - return lhsItem.timestamp > rhsItem.timestamp - } - return lhsItem.id > rhsItem.id - }) + itemSets.sort(by: { lhs, rhs in + guard let lhsItem = lhs.items.first, let rhsItem = rhs.items.first else { + if lhs.items.first != nil { + return false + } else { + return true + } + } + + if lhsItem.timestamp != rhsItem.timestamp { + switch scope { + case .all: + return lhsItem.timestamp > rhsItem.timestamp + case .peer: + return lhsItem.timestamp < rhsItem.timestamp + } + } + return lhsItem.id > rhsItem.id + }) + + self.stateValue = State(itemSets: itemSets, uploadProgress: self.stateValue.uploadProgress, loadMoreToken: result.1) + })) + case let .peer(peerId): + let account = self.account + let queue = self.queue - self.stateValue = State(itemSets: itemSets, uploadProgress: self.stateValue.uploadProgress, loadMoreToken: result.1) - })) + self.loadMoreDisposable.set((self.account.postbox.transaction { transaction -> Api.InputUser? in + return transaction.getPeer(peerId).flatMap(apiInputUser) + } + |> mapToSignal { inputPeer -> Signal in + guard let inputPeer = inputPeer else { + return .single(nil) + } + return account.network.request(Api.functions.stories.getUserStories(flags: 0, userId: inputPeer, offsetId: 0, limit: 30)) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> mapToSignal { stories -> Signal in + guard let stories = stories else { + return .single(nil) + } + return account.postbox.transaction { transaction -> PeerItemSet? in + switch stories { + case let .stories(_, apiStories, users): + var parsedItemSets: [PeerItemSet] = [] + + var peers: [Peer] = [] + var peerPresences: [PeerId: Api.User] = [:] + + for user in users { + let telegramUser = TelegramUser(user: user) + peers.append(telegramUser) + peerPresences[telegramUser.id] = user + } + + updatePeers(transaction: transaction, peers: peers, update: { _, updated -> Peer in + return updated + }) + updatePeerPresences(transaction: transaction, accountPeerId: account.peerId, peerPresences: peerPresences) + + for apiStory in apiStories { + if let item = _internal_parseApiStoryItem(transaction: transaction, peerId: peerId, apiStory: apiStory) { + if !parsedItemSets.isEmpty && parsedItemSets[parsedItemSets.count - 1].peerId == peerId { + parsedItemSets[parsedItemSets.count - 1].items.append(item) + parsedItemSets[parsedItemSets.count - 1].totalCount = parsedItemSets[parsedItemSets.count - 1].items.count + } else { + parsedItemSets.append(StoryListContext.PeerItemSet(peerId: peerId, peer: transaction.getPeer(peerId).flatMap(EnginePeer.init), maxReadId: 0, items: [item], totalCount: 1)) + } + } + } + + return parsedItemSets.first + } + } + } + } + |> deliverOn(queue)).start(next: { [weak self] itemSet in + guard let `self` = self, let itemSet = itemSet else { + return + } + self.isLoadingMore = false + self.stateValue.itemSets = [itemSet] + })) + } } func delete(id: Int32) { diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 587a74a9d9..307b8fa912 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -370,6 +370,7 @@ swift_library( "//submodules/TelegramUI/Components/LegacyInstantVideoController", "//submodules/TelegramUI/Components/FullScreenEffectView", "//submodules/TelegramUI/Components/ShareWithPeersScreen", + "//submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode", ] + select({ "@build_bazel_rules_apple//apple:ios_armv7": [], "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, diff --git a/submodules/TelegramUI/Components/AudioWaveformComponent/Sources/AudioWaveformComponent.swift b/submodules/TelegramUI/Components/AudioWaveformComponent/Sources/AudioWaveformComponent.swift index eca29cf6ec..be602c92a1 100644 --- a/submodules/TelegramUI/Components/AudioWaveformComponent/Sources/AudioWaveformComponent.swift +++ b/submodules/TelegramUI/Components/AudioWaveformComponent/Sources/AudioWaveformComponent.swift @@ -7,30 +7,41 @@ import UniversalMediaPlayer import SwiftSignalKit public final class AudioWaveformComponent: Component { + public enum Style { + case bottom + case middle + } + public let backgroundColor: UIColor public let foregroundColor: UIColor public let shimmerColor: UIColor? + public let style: Style public let samples: Data public let peak: Int32 public let status: Signal - public let seek: (Double) -> Void + public let seek: ((Double) -> Void)? + public let updateIsSeeking: ((Bool) -> Void)? public init( backgroundColor: UIColor, foregroundColor: UIColor, shimmerColor: UIColor?, + style: Style, samples: Data, peak: Int32, status: Signal, - seek: @escaping (Double) -> Void + seek: ((Double) -> Void)?, + updateIsSeeking: ((Bool) -> Void)? ) { self.backgroundColor = backgroundColor self.foregroundColor = foregroundColor self.shimmerColor = shimmerColor + self.style = style self.samples = samples self.peak = peak self.status = status self.seek = seek + self.updateIsSeeking = updateIsSeeking } public static func ==(lhs: AudioWaveformComponent, rhs: AudioWaveformComponent) -> Bool { @@ -43,6 +54,9 @@ public final class AudioWaveformComponent: Component { if lhs.shimmerColor != rhs.shimmerColor { return false } + if lhs.style != rhs.style { + return false + } if lhs.samples != rhs.samples { return false } @@ -199,7 +213,6 @@ public final class AudioWaveformComponent: Component { let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) panRecognizer.delegate = self self.addGestureRecognizer(panRecognizer) - panRecognizer.isEnabled = false self.panRecognizer = panRecognizer } @@ -261,6 +274,7 @@ public final class AudioWaveformComponent: Component { if let statusValue = self.playbackStatus, statusValue.duration > 0.0 { self.scrubbingBeginTimestamp = statusValue.timestamp self.scrubbingTimestampValue = statusValue.timestamp + self.component?.updateIsSeeking?(true) self.setNeedsDisplay() } } @@ -280,7 +294,8 @@ public final class AudioWaveformComponent: Component { }) if let scrubbingTimestampValue = scrubbingTimestampValue, apply { - self.component?.seek(scrubbingTimestampValue) + self.component?.seek?(scrubbingTimestampValue) + self.component?.updateIsSeeking?(false) } } @@ -523,14 +538,12 @@ public final class AudioWaveformComponent: Component { diff = sampleWidth * 1.5 let gravityMultiplierY: CGFloat - gravityMultiplierY = 1.0 - - /*switch parameters.gravity ?? .bottom { + switch component.style { case .bottom: - return 1 - case .center: - return 0.5 - }*/ + gravityMultiplierY = 1.0 + case .middle: + gravityMultiplierY = 0.5 + } context.setFillColor(component.backgroundColor.mixedWith(component.foregroundColor, alpha: colorMixFraction).cgColor) context.setBlendMode(.copy) diff --git a/submodules/TelegramUI/Components/LottieComponent/Sources/LottieComponent.swift b/submodules/TelegramUI/Components/LottieComponent/Sources/LottieComponent.swift index d167e45295..d41ac96359 100644 --- a/submodules/TelegramUI/Components/LottieComponent/Sources/LottieComponent.swift +++ b/submodules/TelegramUI/Components/LottieComponent/Sources/LottieComponent.swift @@ -244,6 +244,8 @@ public final class LottieComponent: Component { var advanceFrameCount = 1 if animationInstance.frameRate == 360 { advanceFrameCount = 6 + } else if animationInstance.frameRate == 240 { + advanceFrameCount = 4 } self.currentFrame += advanceFrameCount if self.currentFrame >= Int(animationInstance.frameCount) - 1 { diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index 9d3bd84021..d9b23b7211 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -599,10 +599,16 @@ final class MediaEditorScreenComponent: Component { //self.performSendMessageAction() }, setMediaRecordingActive: nil, + lockMediaRecording: nil, + stopAndPreviewMediaRecording: nil, + discardMediaRecordingPreview: nil, attachmentAction: nil, reactionAction: nil, audioRecorder: nil, videoRecordingStatus: nil, + isRecordingLocked: false, + recordedAudioPreview: nil, + wasRecordingDismissed: false, displayGradient: false,//component.inputHeight != 0.0, bottomInset: 0.0 //component.inputHeight != 0.0 ? 0.0 : bottomContentInset )), diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD b/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD index cf686876b6..5a3fae25d2 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/BUILD @@ -19,8 +19,11 @@ swift_library( "//submodules/TelegramUI/Components/LottieComponent", "//submodules/AccountContext", "//submodules/TelegramPresentationData", + "//submodules/ManagedAnimationNode", "//submodules/SSignalKit/SwiftSignalKit", "//submodules/Components/HierarchyTrackingLayer", + "//submodules/TelegramUI/Components/AudioWaveformComponent", + "//submodules/MediaPlayer:UniversalMediaPlayer", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaPreviewPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaPreviewPanelComponent.swift new file mode 100644 index 0000000000..297319b952 --- /dev/null +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaPreviewPanelComponent.swift @@ -0,0 +1,357 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import AppBundle +import TextFieldComponent +import BundleIconComponent +import AccountContext +import TelegramPresentationData +import ChatPresentationInterfaceState +import SwiftSignalKit +import LottieComponent +import HierarchyTrackingLayer +import ManagedAnimationNode +import AudioWaveformComponent +import UniversalMediaPlayer + +private final class PlayPauseIconNode: ManagedAnimationNode { + enum State: Equatable { + case play + case pause + } + + private let duration: Double = 0.35 + private var iconState: State = .pause + + init() { + super.init(size: CGSize(width: 28.0, height: 28.0)) + + self.enqueueState(.play, animated: false) + } + + func enqueueState(_ state: State, animated: Bool) { + guard self.iconState != state else { + return + } + + let previousState = self.iconState + self.iconState = state + + switch previousState { + case .pause: + switch state { + case .play: + if animated { + self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 41, endFrame: 83), duration: self.duration)) + } else { + self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.01)) + } + case .pause: + break + } + case .play: + switch state { + case .pause: + if animated { + self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 0, endFrame: 41), duration: self.duration)) + } else { + self.trackTo(item: ManagedAnimationItem(source: .local("anim_playpause"), frames: .range(startFrame: 41, endFrame: 41), duration: 0.01)) + } + case .play: + break + } + } + } +} + +private func textForDuration(seconds: Int32) -> String { + if seconds >= 60 * 60 { + return String(format: "%d:%02d:%02d", seconds / 3600, seconds / 60 % 60) + } else { + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } +} + +public final class MediaPreviewPanelComponent: Component { + public let context: AccountContext + public let theme: PresentationTheme + public let strings: PresentationStrings + public let mediaPreview: ChatRecordedMediaPreview + public let insets: UIEdgeInsets + + public init( + context: AccountContext, + theme: PresentationTheme, + strings: PresentationStrings, + mediaPreview: ChatRecordedMediaPreview, + insets: UIEdgeInsets + ) { + self.context = context + self.theme = theme + self.strings = strings + self.mediaPreview = mediaPreview + self.insets = insets + } + + public static func ==(lhs: MediaPreviewPanelComponent, rhs: MediaPreviewPanelComponent) -> Bool { + if lhs.context !== rhs.context { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if lhs.mediaPreview !== rhs.mediaPreview { + return false + } + if lhs.insets != rhs.insets { + return false + } + return true + } + + public final class View: UIView { + private var component: MediaPreviewPanelComponent? + private weak var state: EmptyComponentState? + + public let vibrancyContainer: UIView + + private let trackingLayer: HierarchyTrackingLayer + + private let indicator = ComponentView() + + private let timerFont: UIFont + private let timerText = ComponentView() + + private var timerTextValue: String = "0:00" + + private let playPauseIconButton: HighlightableButton + private let playPauseIconNode: PlayPauseIconNode + + private let waveform = ComponentView() + private let vibrancyWaveform = ComponentView() + + private var mediaPlayer: MediaPlayer? + private let mediaPlayerStatus = Promise(nil) + private var mediaPlayerStatusDisposable: Disposable? + + override init(frame: CGRect) { + self.trackingLayer = HierarchyTrackingLayer() + + self.timerFont = Font.with(size: 15.0, design: .camera, traits: .monospacedNumbers) + + self.vibrancyContainer = UIView() + + self.playPauseIconButton = HighlightableButton() + self.playPauseIconNode = PlayPauseIconNode() + self.playPauseIconNode.isUserInteractionEnabled = false + + super.init(frame: frame) + + self.layer.addSublayer(self.trackingLayer) + self.playPauseIconButton.addSubview(self.playPauseIconNode.view) + self.addSubview(self.playPauseIconButton) + + self.playPauseIconButton.addTarget(self, action: #selector(self.playPauseButtonPressed), for: .touchUpInside) + + self.mediaPlayerStatusDisposable = (self.mediaPlayerStatus.get() + |> deliverOnMainQueue).start(next: { [weak self] status in + guard let self else { + return + } + + if let status { + switch status.status { + case .playing, .buffering(_, true, _, _): + self.playPauseIconNode.enqueueState(.play, animated: true) + default: + self.playPauseIconNode.enqueueState(.pause, animated: true) + } + + //self.timerTextValue = textForDuration(seconds: component.mediaPreview.duration) + } else { + self.playPauseIconNode.enqueueState(.play, animated: true) + } + }) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.mediaPlayerStatusDisposable?.dispose() + } + + public func animateIn() { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + } + + public func animateOut(transition: Transition, completion: @escaping () -> Void) { + let vibrancyContainer = self.vibrancyContainer + transition.setAlpha(view: vibrancyContainer, alpha: 0.0, completion: { [weak vibrancyContainer] _ in + vibrancyContainer?.removeFromSuperview() + }) + transition.setAlpha(view: self, alpha: 0.0, completion: { _ in + completion() + }) + } + + @objc private func playPauseButtonPressed() { + guard let component = self.component else { + return + } + + if let mediaPlayer = self.mediaPlayer { + mediaPlayer.togglePlayPause() + } else { + let mediaManager = component.context.sharedContext.mediaManager + let mediaPlayer = MediaPlayer( + audioSessionManager: mediaManager.audioSession, + postbox: component.context.account.postbox, + userLocation: .other, + userContentType: .audio, + resourceReference: .standalone(resource: component.mediaPreview.resource), + streamable: .none, + video: false, + preferSoftwareDecoding: false, + enableSound: true, + fetchAutomatically: true + ) + mediaPlayer.actionAtEnd = .action { [weak mediaPlayer] in + mediaPlayer?.seek(timestamp: 0.0) + } + self.mediaPlayer = mediaPlayer + + self.mediaPlayerStatus.set(mediaPlayer.status |> map(Optional.init)) + + mediaPlayer.play() + } + } + + func update(component: MediaPreviewPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + if self.component == nil { + self.timerTextValue = textForDuration(seconds: component.mediaPreview.duration) + } + + self.component = component + self.state = state + + let timerTextSize = self.timerText.update( + transition: .immediate, + component: AnyComponent(Text(text: self.timerTextValue, font: self.timerFont, color: .white)), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + if let timerTextView = self.timerText.view { + if timerTextView.superview == nil { + self.addSubview(timerTextView) + timerTextView.layer.anchorPoint = CGPoint(x: 1.0, y: 0.5) + } + let timerTextFrame = CGRect(origin: CGPoint(x: availableSize.width - component.insets.right - 8.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - timerTextSize.height) * 0.5)), size: timerTextSize) + transition.setPosition(view: timerTextView, position: CGPoint(x: timerTextFrame.minX, y: timerTextFrame.midY)) + timerTextView.bounds = CGRect(origin: CGPoint(), size: timerTextFrame.size) + } + + let playPauseSize = CGSize(width: 28.0, height: 28.0) + var playPauseFrame = CGRect(origin: CGPoint(x: component.insets.left + 8.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - playPauseSize.height) * 0.5)), size: playPauseSize) + let playPauseButtonFrame = playPauseFrame.insetBy(dx: -8.0, dy: -8.0) + playPauseFrame = playPauseFrame.offsetBy(dx: -playPauseButtonFrame.minX, dy: -playPauseButtonFrame.minY) + transition.setFrame(view: self.playPauseIconButton, frame: playPauseButtonFrame) + transition.setFrame(view: self.playPauseIconNode.view, frame: playPauseFrame) + + let waveformFrame = CGRect(origin: CGPoint(x: component.insets.left + 47.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - 24.0) * 0.5)), size: CGSize(width: availableSize.width - component.insets.right - 47.0 - (component.insets.left + 47.0), height: 24.0)) + + let _ = self.waveform.update( + transition: transition, + component: AnyComponent(AudioWaveformComponent( + backgroundColor: UIColor.white.withAlphaComponent(0.1), + foregroundColor: UIColor.white.withAlphaComponent(1.0), + shimmerColor: nil, + style: .middle, + samples: component.mediaPreview.waveform.samples, + peak: component.mediaPreview.waveform.peak, + status: self.mediaPlayerStatus.get() |> map { value -> MediaPlayerStatus in + if let value { + return value + } else { + return MediaPlayerStatus( + generationTimestamp: 0.0, + duration: 0.0, + dimensions: CGSize(), + timestamp: 0.0, + baseRate: 1.0, + seekId: 0, + status: .paused, + soundEnabled: true + ) + } + }, + seek: { [weak self] timestamp in + guard let self, let mediaPlayer = self.mediaPlayer else { + return + } + mediaPlayer.seek(timestamp: timestamp) + }, + updateIsSeeking: { [weak self] isSeeking in + guard let self, let mediaPlayer = self.mediaPlayer else { + return + } + if isSeeking { + mediaPlayer.pause() + } else { + mediaPlayer.play() + } + } + )), + environment: {}, + containerSize: waveformFrame.size + ) + let _ = self.vibrancyWaveform.update( + transition: transition, + component: AnyComponent(AudioWaveformComponent( + backgroundColor: .white, + foregroundColor: .white, + shimmerColor: nil, + style: .middle, + samples: component.mediaPreview.waveform.samples, + peak: component.mediaPreview.waveform.peak, + status: .complete(), + seek: nil, + updateIsSeeking: nil + )), + environment: {}, + containerSize: waveformFrame.size + ) + + if let waveformView = self.waveform.view as? AudioWaveformComponent.View { + if waveformView.superview == nil { + waveformView.enableScrubbing = true + self.addSubview(waveformView) + } + transition.setFrame(view: waveformView, frame: waveformFrame) + } + if let vibrancyWaveformView = self.vibrancyWaveform.view { + if vibrancyWaveformView.superview == nil { + self.vibrancyContainer.addSubview(vibrancyWaveformView) + } + transition.setFrame(view: vibrancyWaveformView, frame: waveformFrame) + } + + transition.setFrame(view: self.vibrancyContainer, frame: CGRect(origin: CGPoint(), size: availableSize)) + + return availableSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaRecordingPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaRecordingPanelComponent.swift index 752908bbc2..3768a2664a 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaRecordingPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MediaRecordingPanelComponent.swift @@ -13,33 +13,60 @@ import LottieComponent import HierarchyTrackingLayer public final class MediaRecordingPanelComponent: Component { + public let theme: PresentationTheme + public let strings: PresentationStrings public let audioRecorder: ManagedAudioRecorder? public let videoRecordingStatus: InstantVideoControllerRecordingStatus? + public let isRecordingLocked: Bool public let cancelFraction: CGFloat + public let inputInsets: UIEdgeInsets public let insets: UIEdgeInsets + public let cancelAction: () -> Void public init( + theme: PresentationTheme, + strings: PresentationStrings, audioRecorder: ManagedAudioRecorder?, videoRecordingStatus: InstantVideoControllerRecordingStatus?, + isRecordingLocked: Bool, cancelFraction: CGFloat, - insets: UIEdgeInsets + inputInsets: UIEdgeInsets, + insets: UIEdgeInsets, + cancelAction: @escaping () -> Void ) { + self.theme = theme + self.strings = strings self.audioRecorder = audioRecorder self.videoRecordingStatus = videoRecordingStatus + self.isRecordingLocked = isRecordingLocked self.cancelFraction = cancelFraction + self.inputInsets = inputInsets self.insets = insets + self.cancelAction = cancelAction } public static func ==(lhs: MediaRecordingPanelComponent, rhs: MediaRecordingPanelComponent) -> Bool { + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } if lhs.audioRecorder !== rhs.audioRecorder { return false } if lhs.videoRecordingStatus !== rhs.videoRecordingStatus { return false } + if lhs.isRecordingLocked != rhs.isRecordingLocked { + return false + } if lhs.cancelFraction != rhs.cancelFraction { return false } + if lhs.inputInsets != rhs.inputInsets { + return false + } if lhs.insets != rhs.insets { return false } @@ -50,13 +77,21 @@ public final class MediaRecordingPanelComponent: Component { private var component: MediaRecordingPanelComponent? private weak var state: EmptyComponentState? + public let vibrancyContainer: UIView + private let trackingLayer: HierarchyTrackingLayer private let indicator = ComponentView() private let cancelContainerView: UIView + private let vibrancyCancelContainerView: UIView private let cancelIconView: UIImageView + private let vibrancyCancelIconView: UIImageView + private let vibrancyCancelText = ComponentView() private let cancelText = ComponentView() + private let vibrancyCancelButtonText = ComponentView() + private let cancelButtonText = ComponentView() + private var cancelButton: HighlightableButton? private let timerFont: UIFont private let timerText = ComponentView() @@ -68,16 +103,23 @@ public final class MediaRecordingPanelComponent: Component { override init(frame: CGRect) { self.trackingLayer = HierarchyTrackingLayer() self.cancelIconView = UIImageView() + self.vibrancyCancelIconView = UIImageView() self.timerFont = Font.with(size: 15.0, design: .camera, traits: .monospacedNumbers) + self.vibrancyContainer = UIView() + self.cancelContainerView = UIView() + self.vibrancyCancelContainerView = UIView() super.init(frame: frame) self.layer.addSublayer(self.trackingLayer) self.cancelContainerView.addSubview(self.cancelIconView) + self.vibrancyCancelContainerView.addSubview(self.vibrancyCancelIconView) + + self.vibrancyContainer.addSubview(self.vibrancyCancelContainerView) self.addSubview(self.cancelContainerView) self.trackingLayer.didEnterHierarchy = { [weak self] in @@ -97,6 +139,10 @@ public final class MediaRecordingPanelComponent: Component { } private func updateAnimations() { + guard let component = self.component else { + return + } + if let indicatorView = self.indicator.view { if indicatorView.layer.animation(forKey: "recording") == nil { let animation = CAKeyframeAnimation(keyPath: "opacity") @@ -109,7 +155,7 @@ public final class MediaRecordingPanelComponent: Component { indicatorView.layer.add(animation, forKey: "recording") } } - if self.cancelContainerView.layer.animation(forKey: "recording") == nil { + if !component.isRecordingLocked, self.cancelContainerView.layer.animation(forKey: "recording") == nil { let animation = CAKeyframeAnimation(keyPath: "position.x") animation.values = [-5.0 as NSNumber, 5.0 as NSNumber, 0.0 as NSNumber] animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber] @@ -119,26 +165,40 @@ public final class MediaRecordingPanelComponent: Component { animation.repeatCount = Float.infinity self.cancelContainerView.layer.add(animation, forKey: "recording") + self.vibrancyCancelContainerView.layer.add(animation, forKey: "recording") } } public func animateIn() { + guard let component = self.component else { + return + } if let indicatorView = self.indicator.view { - indicatorView.layer.animatePosition(from: CGPoint(x: -20.0, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + indicatorView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + indicatorView.layer.animatePosition(from: CGPoint(x: component.inputInsets.left - component.insets.left, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } if let timerTextView = self.timerText.view { - timerTextView.layer.animatePosition(from: CGPoint(x: -20.0, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + timerTextView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + timerTextView.layer.animatePosition(from: CGPoint(x: component.inputInsets.left - component.insets.left, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } self.cancelContainerView.layer.animatePosition(from: CGPoint(x: self.bounds.width, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + self.vibrancyCancelContainerView.layer.animatePosition(from: CGPoint(x: self.bounds.width, y: 0.0), to: CGPoint(), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } - public func animateOut(dismissRecording: Bool, completion: @escaping () -> Void) { - if let indicatorView = self.indicator.view as? LottieComponent.View { - if let _ = indicatorView.layer.animation(forKey: "recording") { - let fromAlpha = indicatorView.layer.presentation()?.opacity ?? indicatorView.layer.opacity - indicatorView.layer.removeAnimation(forKey: "recording") - indicatorView.layer.animateAlpha(from: CGFloat(fromAlpha), to: 1.0, duration: 0.2) - + public func animateOut(transition: Transition, dismissRecording: Bool, completion: @escaping () -> Void) { + guard let component = self.component else { + completion() + return + } + + if let indicatorView = self.indicator.view as? LottieComponent.View, let _ = indicatorView.layer.animation(forKey: "recording") { + let fromAlpha = indicatorView.layer.presentation()?.opacity ?? indicatorView.layer.opacity + indicatorView.layer.removeAnimation(forKey: "recording") + indicatorView.layer.animateAlpha(from: CGFloat(fromAlpha), to: 1.0, duration: 0.2) + } + + if dismissRecording { + if let indicatorView = self.indicator.view as? LottieComponent.View { indicatorView.playOnce(completion: { [weak indicatorView] in if let indicatorView { let transition = Transition(animation: .curve(duration: 0.3, curve: .spring)) @@ -147,19 +207,35 @@ public final class MediaRecordingPanelComponent: Component { completion() }) + } else { + completion() } } else { - completion() + if let indicatorView = self.indicator.view as? LottieComponent.View { + transition.setPosition(view: indicatorView, position: indicatorView.center.offsetBy(dx: component.inputInsets.left - component.insets.left, dy: 0.0)) + transition.setAlpha(view: indicatorView, alpha: 0.0) + } } - - let transition = Transition(animation: .curve(duration: 0.3, curve: .spring)) if let timerTextView = self.timerText.view { - transition.setAlpha(view: timerTextView, alpha: 0.0) + transition.setAlpha(view: timerTextView, alpha: 0.0, completion: { _ in + if !dismissRecording { + completion() + } + }) transition.setScale(view: timerTextView, scale: 0.001) + transition.setPosition(view: timerTextView, position: timerTextView.center.offsetBy(dx: component.inputInsets.left - component.insets.left, dy: 0.0)) } transition.setAlpha(view: self.cancelContainerView, alpha: 0.0) + transition.setAlpha(view: self.vibrancyCancelContainerView, alpha: 0.0) + } + + @objc private func cancelButtonPressed() { + guard let component = self.component else { + return + } + component.cancelAction() } func update(component: MediaRecordingPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { @@ -240,7 +316,7 @@ public final class MediaRecordingPanelComponent: Component { if indicatorView.superview == nil { self.addSubview(indicatorView) } - transition.setFrame(view: indicatorView, frame: CGRect(origin: CGPoint(x: 3.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - indicatorSize.height) * 0.5)), size: indicatorSize)) + transition.setFrame(view: indicatorView, frame: CGRect(origin: CGPoint(x: 5.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - indicatorSize.height) * 0.5)), size: indicatorSize)) } let timerTextSize = self.timerText.update( @@ -254,25 +330,48 @@ public final class MediaRecordingPanelComponent: Component { self.addSubview(timerTextView) timerTextView.layer.anchorPoint = CGPoint(x: 0.0, y: 0.5) } - let timerTextFrame = CGRect(origin: CGPoint(x: 38.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - timerTextSize.height) * 0.5)), size: timerTextSize) + let timerTextFrame = CGRect(origin: CGPoint(x: 40.0, y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - timerTextSize.height) * 0.5)), size: timerTextSize) transition.setPosition(view: timerTextView, position: CGPoint(x: timerTextFrame.minX, y: timerTextFrame.midY)) timerTextView.bounds = CGRect(origin: CGPoint(), size: timerTextFrame.size) } if self.cancelIconView.image == nil { - self.cancelIconView.image = UIImage(bundleImageName: "Chat/Input/Text/AudioRecordingCancelArrow")?.withRenderingMode(.alwaysTemplate) + let image = UIImage(bundleImageName: "Chat/Input/Text/AudioRecordingCancelArrow")?.withRenderingMode(.alwaysTemplate) + self.cancelIconView.image = image + self.vibrancyCancelIconView.image = image } - self.cancelIconView.tintColor = UIColor(white: 1.0, alpha: 0.4) + self.cancelIconView.tintColor = UIColor(white: 1.0, alpha: 0.3) + self.vibrancyCancelIconView.tintColor = .white let cancelTextSize = self.cancelText.update( transition: .immediate, - component: AnyComponent(Text(text: "Slide to cancel", font: Font.regular(15.0), color: UIColor(white: 1.0, alpha: 0.4))), + component: AnyComponent(Text(text: "Slide to cancel", font: Font.regular(15.0), color: UIColor(rgb: 0xffffff, alpha: 0.3))), + environment: {}, + containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0) + ) + let _ = self.vibrancyCancelText.update( + transition: .immediate, + component: AnyComponent(Text(text: "Slide to cancel", font: Font.regular(15.0), color: .white)), + environment: {}, + containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0) + ) + + let cancelButtonTextSize = self.cancelButtonText.update( + transition: .immediate, + component: AnyComponent(Text(text: "Cancel", font: Font.regular(17.0), color: .white)), + environment: {}, + containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0) + ) + let _ = self.vibrancyCancelButtonText.update( + transition: .immediate, + component: AnyComponent(Text(text: "Cancel", font: Font.regular(17.0), color: .clear)), environment: {}, containerSize: CGSize(width: max(30.0, availableSize.width - 100.0), height: 44.0) ) var textFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - cancelTextSize.width) * 0.5), y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - cancelTextSize.height) * 0.5)), size: cancelTextSize) + let cancelButtonTextFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - cancelButtonTextSize.width) * 0.5), y: component.insets.top + floor((availableSize.height - component.insets.top - component.insets.bottom - cancelButtonTextSize.height) * 0.5)), size: cancelButtonTextSize) let bandingStart: CGFloat = 0.0 let bandedOffset = abs(component.cancelFraction) - bandingStart @@ -282,18 +381,105 @@ public final class MediaRecordingPanelComponent: Component { textFrame.origin.x -= mappedCancelFraction * 0.5 + if component.isRecordingLocked { + if self.cancelContainerView.layer.animation(forKey: "recording") != nil { + if let presentation = self.cancelContainerView.layer.presentation() { + transition.animatePosition(view: self.cancelContainerView, from: presentation.position, to: CGPoint()) + transition.animatePosition(view: self.vibrancyCancelContainerView, from: presentation.position, to: CGPoint()) + } + self.cancelContainerView.layer.removeAnimation(forKey: "recording") + self.vibrancyCancelContainerView.layer.removeAnimation(forKey: "recording") + } + } + if let cancelTextView = self.cancelText.view { if cancelTextView.superview == nil { self.cancelContainerView.addSubview(cancelTextView) } - transition.setFrame(view: cancelTextView, frame: textFrame) + transition.setPosition(view: cancelTextView, position: textFrame.center) + transition.setBounds(view: cancelTextView, bounds: CGRect(origin: CGPoint(), size: textFrame.size)) + transition.setAlpha(view: cancelTextView, alpha: !component.isRecordingLocked ? 1.0 : 0.0) + transition.setScale(view: cancelTextView, scale: !component.isRecordingLocked ? 1.0 : 0.001) } + if let vibrancyCancelTextView = self.vibrancyCancelText.view { + if vibrancyCancelTextView.superview == nil { + self.vibrancyCancelContainerView.addSubview(vibrancyCancelTextView) + } + transition.setPosition(view: vibrancyCancelTextView, position: textFrame.center) + transition.setBounds(view: vibrancyCancelTextView, bounds: CGRect(origin: CGPoint(), size: textFrame.size)) + transition.setAlpha(view: vibrancyCancelTextView, alpha: !component.isRecordingLocked ? 1.0 : 0.0) + transition.setScale(view: vibrancyCancelTextView, scale: !component.isRecordingLocked ? 1.0 : 0.001) + } + + if let cancelButtonTextView = self.cancelButtonText.view { + if cancelButtonTextView.superview == nil { + self.cancelContainerView.addSubview(cancelButtonTextView) + } + transition.setPosition(view: cancelButtonTextView, position: cancelButtonTextFrame.center) + transition.setBounds(view: cancelButtonTextView, bounds: CGRect(origin: CGPoint(), size: cancelButtonTextFrame.size)) + transition.setAlpha(view: cancelButtonTextView, alpha: component.isRecordingLocked ? 1.0 : 0.0) + transition.setScale(view: cancelButtonTextView, scale: component.isRecordingLocked ? 1.0 : 0.001) + } + if let vibrancyCancelButtonTextView = self.vibrancyCancelButtonText.view { + if vibrancyCancelButtonTextView.superview == nil { + self.vibrancyCancelContainerView.addSubview(vibrancyCancelButtonTextView) + } + transition.setPosition(view: vibrancyCancelButtonTextView, position: cancelButtonTextFrame.center) + transition.setBounds(view: vibrancyCancelButtonTextView, bounds: CGRect(origin: CGPoint(), size: cancelButtonTextFrame.size)) + transition.setAlpha(view: vibrancyCancelButtonTextView, alpha: component.isRecordingLocked ? 1.0 : 0.0) + transition.setScale(view: vibrancyCancelButtonTextView, scale: component.isRecordingLocked ? 1.0 : 0.001) + } + + if component.isRecordingLocked { + let cancelButton: HighlightableButton + if let current = self.cancelButton { + cancelButton = current + } else { + cancelButton = HighlightableButton() + self.cancelButton = cancelButton + self.addSubview(cancelButton) + + cancelButton.highligthedChanged = { [weak self] highlighted in + guard let self else { + return + } + if highlighted { + self.cancelContainerView.alpha = 0.6 + self.vibrancyCancelContainerView.alpha = 0.6 + } else { + self.cancelContainerView.alpha = 1.0 + self.vibrancyCancelContainerView.alpha = 1.0 + self.cancelContainerView.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.2) + self.vibrancyCancelContainerView.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.2) + } + } + + cancelButton.addTarget(self, action: #selector(self.cancelButtonPressed), for: .touchUpInside) + } + + cancelButton.frame = CGRect(origin: CGPoint(x: cancelButtonTextFrame.minX - 8.0, y: 0.0), size: CGSize(width: cancelButtonTextFrame.width + 8.0 * 2.0, height: availableSize.height)) + } else if let cancelButton = self.cancelButton { + cancelButton.removeFromSuperview() + } + if let image = self.cancelIconView.image { - transition.setFrame(view: self.cancelIconView, frame: CGRect(origin: CGPoint(x: textFrame.minX - 4.0 - image.size.width, y: textFrame.minY + floor((textFrame.height - image.size.height) * 0.5)), size: image.size)) + let iconFrame = CGRect(origin: CGPoint(x: textFrame.minX - 4.0 - image.size.width, y: textFrame.minY + floor((textFrame.height - image.size.height) * 0.5)), size: image.size) + + transition.setPosition(view: self.cancelIconView, position: iconFrame.center) + transition.setBounds(view: self.cancelIconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) + transition.setAlpha(view: self.cancelIconView, alpha: !component.isRecordingLocked ? 1.0 : 0.0) + transition.setScale(view: self.cancelIconView, scale: !component.isRecordingLocked ? 1.0 : 0.001) + + transition.setPosition(view: self.vibrancyCancelIconView, position: iconFrame.center) + transition.setBounds(view: self.vibrancyCancelIconView, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) + transition.setAlpha(view: self.vibrancyCancelIconView, alpha: !component.isRecordingLocked ? 1.0 : 0.0) + transition.setScale(view: self.vibrancyCancelIconView, scale: !component.isRecordingLocked ? 1.0 : 0.001) } self.updateAnimations() + transition.setFrame(view: self.vibrancyContainer, frame: CGRect(origin: CGPoint(), size: availableSize)) + return availableSize } } diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputActionButtonComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputActionButtonComponent.swift index 81d4549624..a0da688d27 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputActionButtonComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputActionButtonComponent.swift @@ -8,6 +8,19 @@ import AccountContext import TelegramPresentationData import ChatPresentationInterfaceState +private extension MessageInputActionButtonComponent.Mode { + var iconName: String? { + switch self { + case .delete: + return "Chat/Context Menu/Delete" + case .attach: + return "Chat/Input/Text/IconAttachment" + default: + return nil + } + } +} + public final class MessageInputActionButtonComponent: Component { public enum Mode { case none @@ -15,6 +28,8 @@ public final class MessageInputActionButtonComponent: Component { case apply case voiceInput case videoInput + case delete + case attach } public enum Action { @@ -26,6 +41,8 @@ public final class MessageInputActionButtonComponent: Component { public let action: (Mode, Action, Bool) -> Void public let switchMediaInputMode: () -> Void public let updateMediaCancelFraction: (CGFloat) -> Void + public let lockMediaRecording: () -> Void + public let stopAndPreviewMediaRecording: () -> Void public let context: AccountContext public let theme: PresentationTheme public let strings: PresentationStrings @@ -38,6 +55,8 @@ public final class MessageInputActionButtonComponent: Component { action: @escaping (Mode, Action, Bool) -> Void, switchMediaInputMode: @escaping () -> Void, updateMediaCancelFraction: @escaping (CGFloat) -> Void, + lockMediaRecording: @escaping () -> Void, + stopAndPreviewMediaRecording: @escaping () -> Void, context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, @@ -49,6 +68,8 @@ public final class MessageInputActionButtonComponent: Component { self.action = action self.switchMediaInputMode = switchMediaInputMode self.updateMediaCancelFraction = updateMediaCancelFraction + self.lockMediaRecording = lockMediaRecording + self.stopAndPreviewMediaRecording = stopAndPreviewMediaRecording self.context = context self.theme = theme self.strings = strings @@ -162,6 +183,12 @@ public final class MessageInputActionButtonComponent: Component { break } } + micButton.stopRecording = { [weak self] in + guard let self, let component = self.component else { + return + } + component.stopAndPreviewMediaRecording() + } micButton.endRecording = { [weak self] sendMedia in guard let self, let component = self.component else { return @@ -173,6 +200,12 @@ public final class MessageInputActionButtonComponent: Component { break } } + micButton.updateLocked = { [weak self] _ in + guard let self, let component = self.component else { + return + } + component.lockMediaRecording() + } micButton.switchMode = { [weak self] in guard let self, let component = self.component else { return @@ -187,29 +220,33 @@ public final class MessageInputActionButtonComponent: Component { } } - if self.sendIconView.image == nil { - self.sendIconView.image = generateImage(CGSize(width: 33.0, height: 33.0), rotatedContext: { size, context in - context.clear(CGRect(origin: CGPoint(), size: size)) - context.setFillColor(UIColor.white.cgColor) - context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) - context.setBlendMode(.copy) - context.setStrokeColor(UIColor.clear.cgColor) - context.setLineWidth(2.0) - context.setLineCap(.round) - context.setLineJoin(.round) - - context.translateBy(x: 5.45, y: 4.0) - - context.saveGState() - context.translateBy(x: 4.0, y: 4.0) - let _ = try? drawSvgPath(context, path: "M1,7 L7,1 L13,7 S ") - context.restoreGState() - - context.saveGState() - context.translateBy(x: 10.0, y: 4.0) - let _ = try? drawSvgPath(context, path: "M1,16 V1 S ") - context.restoreGState() - }) + if self.sendIconView.image == nil || previousComponent?.mode.iconName != component.mode.iconName { + if let iconName = component.mode.iconName { + self.sendIconView.image = generateTintedImage(image: UIImage(bundleImageName: iconName), color: .white) + } else { + self.sendIconView.image = generateImage(CGSize(width: 33.0, height: 33.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.white.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + context.setBlendMode(.copy) + context.setStrokeColor(UIColor.clear.cgColor) + context.setLineWidth(2.0) + context.setLineCap(.round) + context.setLineJoin(.round) + + context.translateBy(x: 5.45, y: 4.0) + + context.saveGState() + context.translateBy(x: 4.0, y: 4.0) + let _ = try? drawSvgPath(context, path: "M1,7 L7,1 L13,7 S ") + context.restoreGState() + + context.saveGState() + context.translateBy(x: 10.0, y: 4.0) + let _ = try? drawSvgPath(context, path: "M1,16 V1 S ") + context.restoreGState() + }) + } } var sendAlpha: CGFloat = 0.0 @@ -218,7 +255,7 @@ public final class MessageInputActionButtonComponent: Component { switch component.mode { case .none: break - case .send, .apply: + case .send, .apply, .attach, .delete: sendAlpha = 1.0 case .videoInput, .voiceInput: microphoneAlpha = 1.0 @@ -248,7 +285,7 @@ public final class MessageInputActionButtonComponent: Component { if previousComponent?.mode != component.mode { switch component.mode { - case .none, .send, .apply, .voiceInput: + case .none, .send, .apply, .voiceInput, .attach, .delete: micButton.updateMode(mode: .audio, animated: !transition.animation.isImmediate) case .videoInput: micButton.updateMode(mode: .video, animated: !transition.animation.isImmediate) diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift index bca2a19ab8..af9963af27 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -8,6 +8,7 @@ import BundleIconComponent import AccountContext import TelegramPresentationData import ChatPresentationInterfaceState +import LottieComponent public final class MessageInputPanelComponent: Component { public enum Style { @@ -31,10 +32,16 @@ public final class MessageInputPanelComponent: Component { public let presentController: (ViewController) -> Void public let sendMessageAction: () -> Void public let setMediaRecordingActive: ((Bool, Bool, Bool) -> Void)? + public let lockMediaRecording: (() -> Void)? + public let stopAndPreviewMediaRecording: (() -> Void)? + public let discardMediaRecordingPreview: (() -> Void)? public let attachmentAction: (() -> Void)? public let reactionAction: ((UIView) -> Void)? public let audioRecorder: ManagedAudioRecorder? public let videoRecordingStatus: InstantVideoControllerRecordingStatus? + public let isRecordingLocked: Bool + public let recordedAudioPreview: ChatRecordedMediaPreview? + public let wasRecordingDismissed: Bool public let displayGradient: Bool public let bottomInset: CGFloat @@ -48,10 +55,16 @@ public final class MessageInputPanelComponent: Component { presentController: @escaping (ViewController) -> Void, sendMessageAction: @escaping () -> Void, setMediaRecordingActive: ((Bool, Bool, Bool) -> Void)?, + lockMediaRecording: (() -> Void)?, + stopAndPreviewMediaRecording: (() -> Void)?, + discardMediaRecordingPreview: (() -> Void)?, attachmentAction: (() -> Void)?, reactionAction: ((UIView) -> Void)?, audioRecorder: ManagedAudioRecorder?, videoRecordingStatus: InstantVideoControllerRecordingStatus?, + isRecordingLocked: Bool, + recordedAudioPreview: ChatRecordedMediaPreview?, + wasRecordingDismissed: Bool, displayGradient: Bool, bottomInset: CGFloat ) { @@ -64,10 +77,16 @@ public final class MessageInputPanelComponent: Component { self.presentController = presentController self.sendMessageAction = sendMessageAction self.setMediaRecordingActive = setMediaRecordingActive + self.lockMediaRecording = lockMediaRecording + self.stopAndPreviewMediaRecording = stopAndPreviewMediaRecording + self.discardMediaRecordingPreview = discardMediaRecordingPreview self.attachmentAction = attachmentAction self.reactionAction = reactionAction self.audioRecorder = audioRecorder self.videoRecordingStatus = videoRecordingStatus + self.isRecordingLocked = isRecordingLocked + self.wasRecordingDismissed = wasRecordingDismissed + self.recordedAudioPreview = recordedAudioPreview self.displayGradient = displayGradient self.bottomInset = bottomInset } @@ -97,6 +116,15 @@ public final class MessageInputPanelComponent: Component { if lhs.videoRecordingStatus !== rhs.videoRecordingStatus { return false } + if lhs.isRecordingLocked != rhs.isRecordingLocked { + return false + } + if lhs.wasRecordingDismissed != rhs.wasRecordingDismissed { + return false + } + if lhs.recordedAudioPreview !== rhs.recordedAudioPreview { + return false + } if lhs.displayGradient != rhs.displayGradient { return false } @@ -123,13 +151,17 @@ public final class MessageInputPanelComponent: Component { private let textFieldExternalState = TextFieldComponent.ExternalState() private let attachmentButton = ComponentView() + private var deleteMediaPreviewButton: ComponentView? private let inputActionButton = ComponentView() private let stickerButton = ComponentView() private let reactionButton = ComponentView() + private var mediaRecordingVibrancyContainer: UIView private var mediaRecordingPanel: ComponentView? private weak var dismissingMediaRecordingPanel: UIView? + private var mediaPreviewPanel: ComponentView? + private var currentMediaInputIsVoice: Bool = true private var mediaCancelFraction: CGFloat = 0.0 @@ -145,6 +177,9 @@ public final class MessageInputPanelComponent: Component { let vibrancyEffectView = UIVisualEffectView(effect: vibrancyEffect) self.vibrancyEffectView = vibrancyEffectView + self.mediaRecordingVibrancyContainer = UIView() + self.vibrancyEffectView.contentView.addSubview(self.mediaRecordingVibrancyContainer) + self.gradientView = UIImageView() self.bottomGradientView = UIView() @@ -181,20 +216,31 @@ public final class MessageInputPanelComponent: Component { } } + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let result = super.hitTest(point, with: event) + + return result + } + func update(component: MessageInputPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { var insets = UIEdgeInsets(top: 14.0, left: 7.0, bottom: 6.0, right: 7.0) + if let _ = component.attachmentAction { insets.left = 41.0 } if let _ = component.setMediaRecordingActive { insets.right = 41.0 } + + let mediaInsets = UIEdgeInsets(top: insets.top, left: 7.0, bottom: insets.bottom, right: insets.right) + let baseFieldHeight: CGFloat = 40.0 self.component = component self.state = state let hasMediaRecording = component.audioRecorder != nil || component.videoRecordingStatus != nil + let hasMediaEditing = component.recordedAudioPreview != nil let topGradientHeight: CGFloat = 32.0 if self.gradientView.image == nil { @@ -264,13 +310,20 @@ public final class MessageInputPanelComponent: Component { } let fieldFrame = CGRect(origin: CGPoint(x: insets.left, y: insets.top), size: CGSize(width: availableSize.width - insets.left - insets.right, height: textFieldSize.height)) - transition.setFrame(view: self.vibrancyEffectView, frame: CGRect(origin: CGPoint(), size: fieldFrame.size)) - transition.setAlpha(view: self.vibrancyEffectView, alpha: (component.audioRecorder != nil || component.videoRecordingStatus != nil) ? 0.0 : 1.0) - transition.setFrame(view: self.fieldBackgroundView, frame: fieldFrame) - self.fieldBackgroundView.update(size: fieldFrame.size, cornerRadius: baseFieldHeight * 0.5, transition: transition.containedViewLayoutTransition) + let fieldBackgroundFrame: CGRect + if hasMediaRecording { + fieldBackgroundFrame = CGRect(origin: CGPoint(x: mediaInsets.left, y: insets.top), size: CGSize(width: availableSize.width - mediaInsets.left - mediaInsets.right, height: textFieldSize.height)) + } else { + fieldBackgroundFrame = fieldFrame + } - let gradientFrame = CGRect(origin: CGPoint(x: 0.0, y: -topGradientHeight), size: CGSize(width: availableSize.width, height: topGradientHeight + fieldFrame.maxY + insets.bottom)) + transition.setFrame(view: self.vibrancyEffectView, frame: CGRect(origin: CGPoint(), size: fieldBackgroundFrame.size)) + + transition.setFrame(view: self.fieldBackgroundView, frame: fieldBackgroundFrame) + self.fieldBackgroundView.update(size: fieldBackgroundFrame.size, cornerRadius: baseFieldHeight * 0.5, transition: transition.containedViewLayoutTransition) + + let gradientFrame = CGRect(origin: CGPoint(x: fieldBackgroundFrame.minX - fieldFrame.minX, y: -topGradientHeight), size: CGSize(width: availableSize.width - (fieldBackgroundFrame.minX - fieldFrame.minX), height: topGradientHeight + fieldBackgroundFrame.maxY + insets.bottom)) transition.setFrame(view: self.gradientView, frame: gradientFrame) transition.setFrame(view: self.bottomGradientView, frame: CGRect(origin: CGPoint(x: 0.0, y: gradientFrame.maxY), size: CGSize(width: availableSize.width, height: component.bottomInset))) transition.setAlpha(view: self.gradientView, alpha: component.displayGradient ? 1.0 : 0.0) @@ -282,7 +335,7 @@ public final class MessageInputPanelComponent: Component { } else { placeholderOriginX = floorToScreenPixels((availableSize.width - placeholderSize.width) / 2.0) } - let placeholderFrame = CGRect(origin: CGPoint(x: placeholderOriginX, y: floor((fieldFrame.height - placeholderSize.height) * 0.5)), size: placeholderSize) + let placeholderFrame = CGRect(origin: CGPoint(x: placeholderOriginX, y: floor((fieldBackgroundFrame.height - placeholderSize.height) * 0.5)), size: placeholderSize) if let placeholderView = self.placeholder.view, let vibrancyPlaceholderView = self.vibrancyPlaceholder.view { if vibrancyPlaceholderView.superview == nil { vibrancyPlaceholderView.layer.anchorPoint = CGPoint() @@ -298,6 +351,9 @@ public final class MessageInputPanelComponent: Component { } transition.setPosition(view: placeholderView, position: placeholderFrame.origin) placeholderView.bounds = CGRect(origin: CGPoint(), size: placeholderFrame.size) + + transition.setAlpha(view: placeholderView, alpha: (hasMediaRecording || hasMediaEditing) ? 0.0 : 1.0) + transition.setAlpha(view: vibrancyPlaceholderView, alpha: (hasMediaRecording || hasMediaEditing) ? 0.0 : 1.0) } let size = CGSize(width: availableSize.width, height: textFieldSize.height + insets.top + insets.bottom) @@ -306,46 +362,151 @@ public final class MessageInputPanelComponent: Component { if textFieldView.superview == nil { self.addSubview(textFieldView) } - transition.setFrame(view: textFieldView, frame: CGRect(origin: CGPoint(x: fieldFrame.minX, y: fieldFrame.maxY - textFieldSize.height), size: textFieldSize)) - transition.setAlpha(view: textFieldView, alpha: (component.audioRecorder != nil || component.videoRecordingStatus != nil) ? 0.0 : 1.0) + transition.setFrame(view: textFieldView, frame: CGRect(origin: CGPoint(x: fieldBackgroundFrame.minX, y: fieldBackgroundFrame.maxY - textFieldSize.height), size: textFieldSize)) + transition.setAlpha(view: textFieldView, alpha: (hasMediaRecording || hasMediaEditing) ? 0.0 : 1.0) } - if let attachmentAction = component.attachmentAction { + if component.attachmentAction != nil { + let attachmentButtonMode: MessageInputActionButtonComponent.Mode + attachmentButtonMode = .attach + let attachmentButtonSize = self.attachmentButton.update( transition: transition, - component: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Text/IconAttachment", - tintColor: .white - )), - action: { - attachmentAction() - } - ).minSize(CGSize(width: 41.0, height: baseFieldHeight))), + component: AnyComponent(MessageInputActionButtonComponent( + mode: attachmentButtonMode, + action: { [weak self] mode, action, sendAction in + guard let self, let component = self.component, case .up = action else { + return + } + + switch mode { + case .delete: + break + case .attach: + component.attachmentAction?() + default: + break + } + }, + switchMediaInputMode: { + }, + updateMediaCancelFraction: { _ in + }, + lockMediaRecording: { + }, + stopAndPreviewMediaRecording: { + }, + context: component.context, + theme: component.theme, + strings: component.strings, + presentController: component.presentController, + audioRecorder: nil, + videoRecordingStatus: nil + )), environment: {}, - containerSize: CGSize(width: 41.0, height: baseFieldHeight) + containerSize: CGSize(width: 33.0, height: baseFieldHeight) ) if let attachmentButtonView = self.attachmentButton.view { if attachmentButtonView.superview == nil { self.addSubview(attachmentButtonView) } - transition.setFrame(view: attachmentButtonView, frame: CGRect(origin: CGPoint(x: floor((insets.left - attachmentButtonSize.width) * 0.5), y: size.height - insets.bottom - baseFieldHeight + floor((baseFieldHeight - attachmentButtonSize.height) * 0.5)), size: attachmentButtonSize)) + let attachmentButtonFrame = CGRect(origin: CGPoint(x: floor((insets.left - attachmentButtonSize.width) * 0.5) + (fieldBackgroundFrame.minX - fieldFrame.minX), y: size.height - insets.bottom - baseFieldHeight + floor((baseFieldHeight - attachmentButtonSize.height) * 0.5)), size: attachmentButtonSize) + transition.setPosition(view: attachmentButtonView, position: attachmentButtonFrame.center) + transition.setBounds(view: attachmentButtonView, bounds: CGRect(origin: CGPoint(), size: attachmentButtonFrame.size)) + transition.setAlpha(view: attachmentButtonView, alpha: (hasMediaRecording || hasMediaEditing) ? 0.0 : 1.0) + transition.setScale(view: attachmentButtonView, scale: hasMediaEditing ? 0.001 : 1.0) } } + if hasMediaEditing { + let deleteMediaPreviewButton: ComponentView + var deleteMediaPreviewButtonTransition = transition + if let current = self.deleteMediaPreviewButton { + deleteMediaPreviewButton = current + } else { + if !transition.animation.isImmediate { + deleteMediaPreviewButtonTransition = .immediate + } + deleteMediaPreviewButton = ComponentView() + self.deleteMediaPreviewButton = deleteMediaPreviewButton + } + let buttonSize = CGSize(width: 40.0, height: 40.0) + let deleteMediaPreviewButtonFrame = CGRect(origin: CGPoint(x: 1.0 + (fieldBackgroundFrame.minX - fieldFrame.minX), y: 3.0 + floor((size.height - buttonSize.height) * 0.5)), size: CGSize(width: buttonSize.width, height: buttonSize.height)) + let _ = deleteMediaPreviewButton.update( + transition: deleteMediaPreviewButtonTransition, + component: AnyComponent(Button( + content: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent(name: "BinBlue"), + color: .white, + startingPosition: .begin + )), + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.discardMediaRecordingPreview?() + } + ).minSize(buttonSize)), + environment: {}, + containerSize: buttonSize + ) + if let deleteMediaPreviewButtonView = deleteMediaPreviewButton.view { + if deleteMediaPreviewButtonView.superview == nil { + self.addSubview(deleteMediaPreviewButtonView) + transition.animateAlpha(view: deleteMediaPreviewButtonView, from: 0.0, to: 1.0) + transition.animatePosition(view: deleteMediaPreviewButtonView, from: CGPoint(x: mediaInsets.left - insets.left, y: 0.0), to: CGPoint(), additive: true) + } + deleteMediaPreviewButtonTransition.setFrame(view: deleteMediaPreviewButtonView, frame: deleteMediaPreviewButtonFrame) + } + } else if let deleteMediaPreviewButton = self.deleteMediaPreviewButton { + self.deleteMediaPreviewButton = nil + if let deleteMediaPreviewButtonView = deleteMediaPreviewButton.view { + if component.wasRecordingDismissed, let deleteMediaPreviewButtonView = deleteMediaPreviewButtonView as? Button.View, let animationView = deleteMediaPreviewButtonView.content as? LottieComponent.View { + if let attachmentButtonView = self.attachmentButton.view { + attachmentButtonView.isHidden = true + } + animationView.playOnce(completion: { [weak self, weak deleteMediaPreviewButtonView] in + guard let self, let deleteMediaPreviewButtonView else { + return + } + let transition = Transition(animation: .curve(duration: 0.3, curve: .spring)) + transition.setAlpha(view: deleteMediaPreviewButtonView, alpha: 0.0, completion: { [weak deleteMediaPreviewButtonView] _ in + deleteMediaPreviewButtonView?.removeFromSuperview() + }) + transition.setScale(view: deleteMediaPreviewButtonView, scale: 0.001) + + if let attachmentButtonView = self.attachmentButton.view { + attachmentButtonView.isHidden = false + + transition.animateAlpha(view: attachmentButtonView, from: 0.0, to: attachmentButtonView.alpha) + transition.animateScale(view: attachmentButtonView, from: 0.001, to: 1.0) + } + }) + } else { + transition.setAlpha(view: deleteMediaPreviewButtonView, alpha: 0.0, completion: { [weak deleteMediaPreviewButtonView] _ in + deleteMediaPreviewButtonView?.removeFromSuperview() + }) + transition.setScale(view: deleteMediaPreviewButtonView, scale: 0.001) + } + } + } let inputActionButtonMode: MessageInputActionButtonComponent.Mode if case .editor = component.style { inputActionButtonMode = self.textFieldExternalState.isEditing ? .apply : .none } else { - inputActionButtonMode = self.textFieldExternalState.hasText ? .send : (self.currentMediaInputIsVoice ? .voiceInput : .videoInput) + if hasMediaEditing { + inputActionButtonMode = .send + } else { + inputActionButtonMode = self.textFieldExternalState.hasText ? .send : (self.currentMediaInputIsVoice ? .voiceInput : .videoInput) + } } let inputActionButtonSize = self.inputActionButton.update( transition: transition, component: AnyComponent(MessageInputActionButtonComponent( mode: inputActionButtonMode, action: { [weak self] mode, action, sendAction in - guard let self else { + guard let self, let component = self.component else { return } @@ -354,9 +515,11 @@ public final class MessageInputPanelComponent: Component { break case .send: if case .up = action { - if case .text("") = self.getSendMessageInput() { + if component.recordedAudioPreview != nil { + component.sendMessageAction() + } else if case .text("") = self.getSendMessageInput() { } else { - self.component?.sendMessageAction() + component.sendMessageAction() } } case .apply: @@ -364,7 +527,9 @@ public final class MessageInputPanelComponent: Component { self.component?.sendMessageAction() } case .voiceInput, .videoInput: - self.component?.setMediaRecordingActive?(action == .down, mode == .videoInput, sendAction) + component.setMediaRecordingActive?(action == .down, mode == .videoInput, sendAction) + default: + break } }, switchMediaInputMode: { [weak self] in @@ -383,6 +548,18 @@ public final class MessageInputPanelComponent: Component { self.state?.updated(transition: .immediate) } }, + lockMediaRecording: { [weak self] in + guard let self, let component = self.component else { + return + } + component.lockMediaRecording?() + }, + stopAndPreviewMediaRecording: { [weak self] in + guard let self, let component = self.component else { + return + } + component.stopAndPreviewMediaRecording?() + }, context: component.context, theme: component.theme, strings: component.strings, @@ -406,7 +583,7 @@ public final class MessageInputPanelComponent: Component { transition.setFrame(view: inputActionButtonView, frame: CGRect(origin: CGPoint(x: inputActionButtonOriginX, y: size.height - insets.bottom - baseFieldHeight + floorToScreenPixels((baseFieldHeight - inputActionButtonSize.height) * 0.5)), size: inputActionButtonSize)) } - var fieldIconNextX = fieldFrame.maxX - 2.0 + var fieldIconNextX = fieldBackgroundFrame.maxX - 2.0 if case .story = component.style { let stickerButtonSize = self.stickerButton.update( transition: transition, @@ -429,12 +606,12 @@ public final class MessageInputPanelComponent: Component { if stickerButtonView.superview == nil { self.addSubview(stickerButtonView) } - let stickerIconFrame = CGRect(origin: CGPoint(x: fieldIconNextX - stickerButtonSize.width, y: fieldFrame.minY + floor((fieldFrame.height - stickerButtonSize.height) * 0.5)), size: stickerButtonSize) + let stickerIconFrame = CGRect(origin: CGPoint(x: fieldIconNextX - stickerButtonSize.width, y: fieldBackgroundFrame.minY + floor((fieldBackgroundFrame.height - stickerButtonSize.height) * 0.5)), size: stickerButtonSize) transition.setPosition(view: stickerButtonView, position: stickerIconFrame.center) transition.setBounds(view: stickerButtonView, bounds: CGRect(origin: CGPoint(), size: stickerIconFrame.size)) - transition.setAlpha(view: stickerButtonView, alpha: (self.textFieldExternalState.hasText || hasMediaRecording) ? 0.0 : 1.0) - transition.setScale(view: stickerButtonView, scale: self.textFieldExternalState.hasText ? 0.1 : 1.0) + transition.setAlpha(view: stickerButtonView, alpha: (self.textFieldExternalState.hasText || hasMediaRecording || hasMediaEditing) ? 0.0 : 1.0) + transition.setScale(view: stickerButtonView, scale: (self.textFieldExternalState.hasText || hasMediaRecording || hasMediaEditing) ? 0.1 : 1.0) fieldIconNextX -= stickerButtonSize.width + 2.0 } @@ -462,19 +639,18 @@ public final class MessageInputPanelComponent: Component { if reactionButtonView.superview == nil { self.addSubview(reactionButtonView) } - let reactionIconFrame = CGRect(origin: CGPoint(x: fieldIconNextX - reactionButtonSize.width, y: fieldFrame.minY + 1.0 + floor((fieldFrame.height - reactionButtonSize.height) * 0.5)), size: reactionButtonSize) + let reactionIconFrame = CGRect(origin: CGPoint(x: fieldIconNextX - reactionButtonSize.width, y: fieldBackgroundFrame.minY + 1.0 + floor((fieldBackgroundFrame.height - reactionButtonSize.height) * 0.5)), size: reactionButtonSize) transition.setPosition(view: reactionButtonView, position: reactionIconFrame.center) transition.setBounds(view: reactionButtonView, bounds: CGRect(origin: CGPoint(), size: reactionIconFrame.size)) - transition.setAlpha(view: reactionButtonView, alpha: (self.textFieldExternalState.hasText || hasMediaRecording) ? 0.0 : 1.0) - transition.setScale(view: reactionButtonView, scale: self.textFieldExternalState.hasText ? 0.1 : 1.0) + transition.setAlpha(view: reactionButtonView, alpha: (self.textFieldExternalState.hasText || hasMediaRecording || hasMediaEditing) ? 0.0 : 1.0) + transition.setScale(view: reactionButtonView, scale: (self.textFieldExternalState.hasText || hasMediaRecording || hasMediaEditing) ? 0.1 : 1.0) fieldIconNextX -= reactionButtonSize.width + 2.0 } } self.fieldBackgroundView.updateColor(color: self.textFieldExternalState.isEditing || component.style == .editor ? UIColor(white: 0.0, alpha: 0.5) : UIColor(white: 1.0, alpha: 0.09), transition: transition.containedViewLayoutTransition) - transition.setAlpha(view: self.fieldBackgroundView, alpha: hasMediaRecording ? 0.0 : 1.0) if let placeholder = self.placeholder.view, let vibrancyPlaceholderView = self.vibrancyPlaceholder.view { placeholder.isHidden = self.textFieldExternalState.hasText vibrancyPlaceholderView.isHidden = placeholder.isHidden @@ -483,7 +659,7 @@ public final class MessageInputPanelComponent: Component { component.externalState.isEditing = self.textFieldExternalState.isEditing component.externalState.hasText = self.textFieldExternalState.hasText - if component.audioRecorder != nil || component.videoRecordingStatus != nil { + if hasMediaRecording { if let dismissingMediaRecordingPanel = self.dismissingMediaRecordingPanel { self.dismissingMediaRecordingPanel = nil transition.setAlpha(view: dismissingMediaRecordingPanel, alpha: 0.0, completion: { [weak dismissingMediaRecordingPanel] _ in @@ -504,10 +680,20 @@ public final class MessageInputPanelComponent: Component { let _ = mediaRecordingPanel.update( transition: mediaRecordingPanelTransition, component: AnyComponent(MediaRecordingPanelComponent( + theme: component.theme, + strings: component.strings, audioRecorder: component.audioRecorder, videoRecordingStatus: component.videoRecordingStatus, + isRecordingLocked: component.isRecordingLocked, cancelFraction: self.mediaCancelFraction, - insets: insets + inputInsets: insets, + insets: mediaInsets, + cancelAction: { [weak self] in + guard let self, let component = self.component else { + return + } + component.setMediaRecordingActive?(false, false, false) + } )), environment: {}, containerSize: size @@ -516,17 +702,18 @@ public final class MessageInputPanelComponent: Component { var animateIn = false if mediaRecordingPanelView.superview == nil { animateIn = true - self.insertSubview(mediaRecordingPanelView, at: 0) + self.insertSubview(mediaRecordingPanelView, aboveSubview: self.fieldBackgroundView) + + self.mediaRecordingVibrancyContainer.addSubview(mediaRecordingPanelView.vibrancyContainer) } mediaRecordingPanelTransition.setFrame(view: mediaRecordingPanelView, frame: CGRect(origin: CGPoint(), size: size)) + + transition.setFrame(view: self.mediaRecordingVibrancyContainer, frame: CGRect(origin: CGPoint(x: -fieldBackgroundFrame.minX, y: -fieldBackgroundFrame.minY), size: size)) + if animateIn && !transition.animation.isImmediate { mediaRecordingPanelView.animateIn() } } - - if let attachmentButtonView = self.attachmentButton.view { - transition.setAlpha(view: attachmentButtonView, alpha: 0.0) - } } else { if let mediaRecordingPanel = self.mediaRecordingPanel { self.mediaRecordingPanel = nil @@ -541,7 +728,11 @@ public final class MessageInputPanelComponent: Component { self.dismissingMediaRecordingPanel = mediaRecordingPanel.view if let mediaRecordingPanelView = mediaRecordingPanel.view as? MediaRecordingPanelComponent.View { - mediaRecordingPanelView.animateOut(dismissRecording: true, completion: { [weak self, weak mediaRecordingPanelView] in + let wasRecordingDismissed = component.wasRecordingDismissed + if wasRecordingDismissed, let attachmentButtonView = self.attachmentButton.view { + attachmentButtonView.isHidden = true + } + mediaRecordingPanelView.animateOut(transition: transition, dismissRecording: wasRecordingDismissed, completion: { [weak self, weak mediaRecordingPanelView] in let transition = Transition(animation: .curve(duration: 0.3, curve: .spring)) if let mediaRecordingPanelView = mediaRecordingPanelView { @@ -553,8 +744,10 @@ public final class MessageInputPanelComponent: Component { guard let self else { return } - if self.mediaRecordingPanel == nil, let attachmentButtonView = self.attachmentButton.view { - transition.setAlpha(view: attachmentButtonView, alpha: 1.0) + if wasRecordingDismissed, self.mediaRecordingPanel == nil, let attachmentButtonView = self.attachmentButton.view { + attachmentButtonView.isHidden = false + + transition.animateAlpha(view: attachmentButtonView, from: 0.0, to: attachmentButtonView.alpha) transition.animateScale(view: attachmentButtonView, from: 0.001, to: 1.0) } }) @@ -562,6 +755,57 @@ public final class MessageInputPanelComponent: Component { } } + if let recordedAudioPreview = component.recordedAudioPreview { + let mediaPreviewPanel: ComponentView + var mediaPreviewPanelTransition = transition + if let current = self.mediaPreviewPanel { + mediaPreviewPanel = current + } else { + mediaPreviewPanelTransition = .immediate + mediaPreviewPanel = ComponentView() + self.mediaPreviewPanel = mediaPreviewPanel + } + + let _ = mediaPreviewPanel.update( + transition: mediaPreviewPanelTransition, + component: AnyComponent(MediaPreviewPanelComponent( + context: component.context, + theme: component.theme, + strings: component.strings, + mediaPreview: recordedAudioPreview, + insets: insets + )), + environment: {}, + containerSize: size + ) + if let mediaPreviewPanelView = mediaPreviewPanel.view as? MediaPreviewPanelComponent.View { + var animateIn = false + if mediaPreviewPanelView.superview == nil { + animateIn = true + self.insertSubview(mediaPreviewPanelView, aboveSubview: self.fieldBackgroundView) + + self.mediaRecordingVibrancyContainer.addSubview(mediaPreviewPanelView.vibrancyContainer) + } + mediaPreviewPanelTransition.setFrame(view: mediaPreviewPanelView, frame: CGRect(origin: CGPoint(), size: size)) + + transition.setFrame(view: self.mediaRecordingVibrancyContainer, frame: CGRect(origin: CGPoint(x: -fieldBackgroundFrame.minX, y: -fieldBackgroundFrame.minY), size: size)) + + if animateIn && !transition.animation.isImmediate { + mediaPreviewPanelView.animateIn() + } + } + } else { + if let mediaPreviewPanel = self.mediaPreviewPanel { + self.mediaPreviewPanel = nil + + if let mediaPreviewPanelView = mediaPreviewPanel.view as? MediaPreviewPanelComponent.View { + mediaPreviewPanelView.animateOut(transition: transition, completion: { [weak mediaPreviewPanelView] in + mediaPreviewPanelView?.removeFromSuperview() + }) + } + } + } + return size } } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD new file mode 100644 index 0000000000..35136edf64 --- /dev/null +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/BUILD @@ -0,0 +1,44 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "PeerInfoVisualMediaPaneNode", + module_name = "PeerInfoVisualMediaPaneNode", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/AsyncDisplayKit", + "//submodules/TelegramCore", + "//submodules/Postbox", + "//submodules/TelegramPresentationData", + "//submodules/AccountContext", + "//submodules/ContextUI", + "//submodules/PhotoResources", + "//submodules/RadialStatusNode", + "//submodules/TelegramStringFormatting", + "//submodules/GridMessageSelectionNode", + "//submodules/MediaPlayer:UniversalMediaPlayer", + "//submodules/ListMessageItem", + "//submodules/ChatMessageInteractiveMediaBadge", + "//submodules/SparseItemGrid", + "//submodules/ShimmerEffect", + "//submodules/DirectMediaImageCache", + "//submodules/ComponentFlow", + "//submodules/TelegramNotices", + "//submodules/TelegramUIPreferences", + "//submodules/CheckNode", + "//submodules/AppBundle", + "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/InvisibleInkDustNode", + "//submodules/MediaPickerUI", + "//submodules/TelegramUI/Components/Stories/StoryContainerScreen", + "//submodules/TelegramUI/Components/Stories/StoryContentComponent", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift new file mode 100644 index 0000000000..7a42de06ef --- /dev/null +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoStoryPaneNode.swift @@ -0,0 +1,1935 @@ +import AsyncDisplayKit +import AVFoundation +import Display +import TelegramCore +import SwiftSignalKit +import Postbox +import TelegramPresentationData +import AccountContext +import ContextUI +import PhotoResources +import RadialStatusNode +import TelegramStringFormatting +import GridMessageSelectionNode +import UniversalMediaPlayer +import ListMessageItem +import ChatMessageInteractiveMediaBadge +import SparseItemGrid +import ShimmerEffect +import QuartzCore +import DirectMediaImageCache +import ComponentFlow +import TelegramNotices +import TelegramUIPreferences +import CheckNode +import AppBundle +import ChatControllerInteraction +import InvisibleInkDustNode +import MediaPickerUI +import StoryContainerScreen +import StoryContentComponent + +private let mediaBadgeBackgroundColor = UIColor(white: 0.0, alpha: 0.6) +private let mediaBadgeTextColor = UIColor.white + +private final class VisualMediaItemInteraction { + let openMessage: (Message) -> Void + let openMessageContextActions: (Message, ASDisplayNode, CGRect, ContextGesture?) -> Void + let toggleSelection: (MessageId, Bool) -> Void + + var hiddenMedia: [MessageId: [Media]] = [:] + var selectedMessageIds: Set? + + init( + openMessage: @escaping (Message) -> Void, + openMessageContextActions: @escaping (Message, ASDisplayNode, CGRect, ContextGesture?) -> Void, + toggleSelection: @escaping (MessageId, Bool) -> Void + ) { + self.openMessage = openMessage + self.openMessageContextActions = openMessageContextActions + self.toggleSelection = toggleSelection + } +} + +private final class VisualMediaHoleAnchor: SparseItemGrid.HoleAnchor { + let storyId: Int32 + override var id: AnyHashable { + return AnyHashable(self.storyId) + } + + let indexValue: Int + override var index: Int { + return self.indexValue + } + + let localMonthTimestamp: Int32 + override var tag: Int32 { + return self.localMonthTimestamp + } + + init(index: Int, storyId: Int32, localMonthTimestamp: Int32) { + self.indexValue = index + self.storyId = storyId + self.localMonthTimestamp = localMonthTimestamp + } +} + +private final class VisualMediaItem: SparseItemGrid.Item { + let indexValue: Int + override var index: Int { + return self.indexValue + } + let localMonthTimestamp: Int32 + let peer: PeerReference + let story: StoryListContext.Item + + override var id: AnyHashable { + return AnyHashable(self.story.id) + } + + override var tag: Int32 { + return self.localMonthTimestamp + } + + override var holeAnchor: SparseItemGrid.HoleAnchor { + return VisualMediaHoleAnchor(index: self.index, storyId: self.story.id, localMonthTimestamp: self.localMonthTimestamp) + } + + init(index: Int, peer: PeerReference, story: StoryListContext.Item, localMonthTimestamp: Int32) { + self.indexValue = index + self.peer = peer + self.story = story + self.localMonthTimestamp = localMonthTimestamp + } +} + +private struct Month: Equatable { + var packedValue: Int32 + + init(packedValue: Int32) { + self.packedValue = packedValue + } + + init(localTimestamp: Int32) { + var time: time_t = time_t(localTimestamp) + var timeinfo: tm = tm() + gmtime_r(&time, &timeinfo) + + let year = UInt32(timeinfo.tm_year) + let month = UInt32(timeinfo.tm_mon) + + self.packedValue = Int32(bitPattern: year | (month << 16)) + } + + var year: Int32 { + return Int32(bitPattern: (UInt32(bitPattern: self.packedValue) >> 0) & 0xffff) + } + + var month: Int32 { + return Int32(bitPattern: (UInt32(bitPattern: self.packedValue) >> 16) & 0xffff) + } +} + +private let durationFont = Font.regular(12.0) +private let minDurationImage: UIImage = { + let image = generateImage(CGSize(width: 20.0, height: 20.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor(white: 0.0, alpha: 0.5).cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + if let image = UIImage(bundleImageName: "Chat/GridPlayIcon") { + UIGraphicsPushContext(context) + image.draw(in: CGRect(origin: CGPoint(x: (size.width - image.size.width) / 2.0, y: (size.height - image.size.height) / 2.0), size: image.size)) + UIGraphicsPopContext() + } + }) + return image! +}() + +private final class DurationLayer: CALayer { + override init() { + super.init() + + self.contentsGravity = .topRight + self.contentsScale = UIScreenScale + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func action(forKey event: String) -> CAAction? { + return nullAction + } + + func update(duration: Int32, isMin: Bool) { + if isMin { + self.contents = minDurationImage.cgImage + } else { + let string = NSAttributedString(string: stringForDuration(duration), font: durationFont, textColor: .white) + let bounds = string.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil) + let textSize = CGSize(width: ceil(bounds.width), height: ceil(bounds.height)) + let sideInset: CGFloat = 6.0 + let verticalInset: CGFloat = 2.0 + let image = generateImage(CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height + verticalInset * 2.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + context.setFillColor(UIColor(white: 0.0, alpha: 0.5).cgColor) + context.setBlendMode(.copy) + context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.height, height: size.height))) + context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - size.height, y: 0.0), size: CGSize(width: size.height, height: size.height))) + context.fill(CGRect(origin: CGPoint(x: size.height / 2.0, y: 0.0), size: CGSize(width: size.width - size.height, height: size.height))) + + context.setBlendMode(.normal) + UIGraphicsPushContext(context) + string.draw(in: bounds.offsetBy(dx: sideInset, dy: verticalInset)) + UIGraphicsPopContext() + }) + self.contents = image?.cgImage + } + } +} + +private protocol ItemLayer: SparseItemGridLayer { + var item: VisualMediaItem? { get set } + var durationLayer: DurationLayer? { get set } + var minFactor: CGFloat { get set } + var selectionLayer: GridMessageSelectionLayer? { get set } + var disposable: Disposable? { get set } + + var hasContents: Bool { get set } + func setSpoilerContents(_ contents: Any?) + + func updateDuration(duration: Int32?, isMin: Bool, minFactor: CGFloat) + func updateSelection(theme: CheckNodeTheme, isSelected: Bool?, animated: Bool) + func updateHasSpoiler(hasSpoiler: Bool) + + func bind(item: VisualMediaItem) + func unbind() +} + +private final class GenericItemLayer: CALayer, ItemLayer { + var item: VisualMediaItem? + var durationLayer: DurationLayer? + var minFactor: CGFloat = 1.0 + var selectionLayer: GridMessageSelectionLayer? + var dustLayer: MediaDustLayer? + var disposable: Disposable? + + var hasContents: Bool = false + + override init() { + super.init() + + self.contentsGravity = .resize + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.disposable?.dispose() + } + + func getContents() -> Any? { + return self.contents + } + + func setContents(_ contents: Any?) { + if let image = contents as? UIImage { + self.contents = image.cgImage + } + } + + func setSpoilerContents(_ contents: Any?) { + if let image = contents as? UIImage { + self.dustLayer?.contents = image.cgImage + } + } + + override func action(forKey event: String) -> CAAction? { + return nullAction + } + + func bind(item: VisualMediaItem) { + self.item = item + } + + func updateDuration(duration: Int32?, isMin: Bool, minFactor: CGFloat) { + self.minFactor = minFactor + + if let duration = duration { + if let durationLayer = self.durationLayer { + durationLayer.update(duration: duration, isMin: isMin) + } else { + let durationLayer = DurationLayer() + durationLayer.update(duration: duration, isMin: isMin) + self.addSublayer(durationLayer) + durationLayer.frame = CGRect(origin: CGPoint(x: self.bounds.width - 3.0, y: self.bounds.height - 3.0), size: CGSize()) + durationLayer.transform = CATransform3DMakeScale(minFactor, minFactor, 1.0) + self.durationLayer = durationLayer + } + } else if let durationLayer = self.durationLayer { + self.durationLayer = nil + durationLayer.removeFromSuperlayer() + } + } + + func updateSelection(theme: CheckNodeTheme, isSelected: Bool?, animated: Bool) { + if let isSelected = isSelected { + if let selectionLayer = self.selectionLayer { + selectionLayer.updateSelected(isSelected, animated: animated) + } else { + let selectionLayer = GridMessageSelectionLayer(theme: theme) + selectionLayer.updateSelected(isSelected, animated: false) + self.selectionLayer = selectionLayer + self.addSublayer(selectionLayer) + if !self.bounds.isEmpty { + selectionLayer.frame = CGRect(origin: CGPoint(), size: self.bounds.size) + selectionLayer.updateLayout(size: self.bounds.size) + if animated { + selectionLayer.animateIn() + } + } + } + } else if let selectionLayer = self.selectionLayer { + self.selectionLayer = nil + if animated { + selectionLayer.animateOut { [weak selectionLayer] in + selectionLayer?.removeFromSuperlayer() + } + } else { + selectionLayer.removeFromSuperlayer() + } + } + } + + func updateHasSpoiler(hasSpoiler: Bool) { + if hasSpoiler { + if let _ = self.dustLayer { + } else { + let dustLayer = MediaDustLayer() + self.dustLayer = dustLayer + self.addSublayer(dustLayer) + if !self.bounds.isEmpty { + dustLayer.frame = CGRect(origin: CGPoint(), size: self.bounds.size) + dustLayer.updateLayout(size: self.bounds.size) + } + } + } else if let dustLayer = self.dustLayer { + self.dustLayer = nil + dustLayer.removeFromSuperlayer() + } + } + + func unbind() { + self.item = nil + } + + func needsShimmer() -> Bool { + return !self.hasContents + } + + func update(size: CGSize) { + /*if let durationLayer = self.durationLayer { + durationLayer.frame = CGRect(origin: CGPoint(x: size.width - 3.0, y: size.height - 3.0), size: CGSize()) + }*/ + } +} + +private final class CaptureProtectedItemLayer: AVSampleBufferDisplayLayer, ItemLayer { + var item: VisualMediaItem? + var durationLayer: DurationLayer? + var minFactor: CGFloat = 1.0 + var selectionLayer: GridMessageSelectionLayer? + var dustLayer: MediaDustLayer? + var disposable: Disposable? + + var hasContents: Bool = false + + override init() { + super.init() + + self.contentsGravity = .resize + if #available(iOS 13.0, *) { + self.preventsCapture = true + self.preventsDisplaySleepDuringVideoPlayback = false + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.disposable?.dispose() + } + + override func action(forKey event: String) -> CAAction? { + return nullAction + } + + private var layerContents: Any? + func getContents() -> Any? { + return self.layerContents + } + + func setContents(_ contents: Any?) { + self.layerContents = contents + + if let image = contents as? UIImage { + self.layerContents = image.cgImage + if let cmSampleBuffer = image.cmSampleBuffer { + self.enqueue(cmSampleBuffer) + } + } + } + + func setSpoilerContents(_ contents: Any?) { + if let image = contents as? UIImage { + self.dustLayer?.contents = image.cgImage + } + } + + func bind(item: VisualMediaItem) { + self.item = item + } + + func updateDuration(duration: Int32?, isMin: Bool, minFactor: CGFloat) { + self.minFactor = minFactor + + if let duration = duration { + if let durationLayer = self.durationLayer { + durationLayer.update(duration: duration, isMin: isMin) + } else { + let durationLayer = DurationLayer() + durationLayer.update(duration: duration, isMin: isMin) + self.addSublayer(durationLayer) + durationLayer.frame = CGRect(origin: CGPoint(x: self.bounds.width - 3.0, y: self.bounds.height - 3.0), size: CGSize()) + durationLayer.transform = CATransform3DMakeScale(minFactor, minFactor, 1.0) + self.durationLayer = durationLayer + } + } else if let durationLayer = self.durationLayer { + self.durationLayer = nil + durationLayer.removeFromSuperlayer() + } + } + + func updateSelection(theme: CheckNodeTheme, isSelected: Bool?, animated: Bool) { + if let isSelected = isSelected { + if let selectionLayer = self.selectionLayer { + selectionLayer.updateSelected(isSelected, animated: animated) + } else { + let selectionLayer = GridMessageSelectionLayer(theme: theme) + selectionLayer.updateSelected(isSelected, animated: false) + self.selectionLayer = selectionLayer + self.addSublayer(selectionLayer) + if !self.bounds.isEmpty { + selectionLayer.frame = CGRect(origin: CGPoint(), size: self.bounds.size) + selectionLayer.updateLayout(size: self.bounds.size) + if animated { + selectionLayer.animateIn() + } + } + } + } else if let selectionLayer = self.selectionLayer { + self.selectionLayer = nil + if animated { + selectionLayer.animateOut { [weak selectionLayer] in + selectionLayer?.removeFromSuperlayer() + } + } else { + selectionLayer.removeFromSuperlayer() + } + } + } + + func updateHasSpoiler(hasSpoiler: Bool) { + if hasSpoiler { + if let _ = self.dustLayer { + } else { + let dustLayer = MediaDustLayer() + self.dustLayer = dustLayer + self.addSublayer(dustLayer) + if !self.bounds.isEmpty { + dustLayer.frame = CGRect(origin: CGPoint(), size: self.bounds.size) + dustLayer.updateLayout(size: self.bounds.size) + } + } + } else if let dustLayer = self.dustLayer { + self.dustLayer = nil + dustLayer.removeFromSuperlayer() + } + } + + func unbind() { + self.item = nil + } + + func needsShimmer() -> Bool { + return !self.hasContents + } + + func update(size: CGSize) { + /*if let durationLayer = self.durationLayer { + durationLayer.frame = CGRect(origin: CGPoint(x: size.width - 3.0, y: size.height - 3.0), size: CGSize()) + }*/ + } +} + +private final class SparseItemGridBindingImpl: SparseItemGridBinding { + let context: AccountContext + let chatLocation: ChatLocation + let directMediaImageCache: DirectMediaImageCache + let captureProtected: Bool + var strings: PresentationStrings + let chatControllerInteraction: ChatControllerInteraction + var chatPresentationData: ChatPresentationData + var checkNodeTheme: CheckNodeTheme + + var loadHoleImpl: ((SparseItemGrid.HoleAnchor, SparseItemGrid.HoleLocation) -> Signal)? + var onTapImpl: ((VisualMediaItem) -> Void)? + var onTagTapImpl: (() -> Void)? + var didScrollImpl: (() -> Void)? + var coveringInsetOffsetUpdatedImpl: ((ContainedViewLayoutTransition) -> Void)? + var onBeginFastScrollingImpl: (() -> Void)? + var getShimmerColorsImpl: (() -> SparseItemGrid.ShimmerColors)? + var updateShimmerLayersImpl: ((SparseItemGridDisplayItem) -> Void)? + + var revealedSpoilerMessageIds = Set() + + private var shimmerImages: [CGFloat: UIImage] = [:] + + init(context: AccountContext, chatLocation: ChatLocation, chatControllerInteraction: ChatControllerInteraction, directMediaImageCache: DirectMediaImageCache, captureProtected: Bool) { + self.context = context + self.chatLocation = chatLocation + self.chatControllerInteraction = chatControllerInteraction + self.directMediaImageCache = directMediaImageCache + self.captureProtected = false + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + self.strings = presentationData.strings + + let themeData = ChatPresentationThemeData(theme: presentationData.theme, wallpaper: presentationData.chatWallpaper) + self.chatPresentationData = ChatPresentationData(theme: themeData, fontSize: presentationData.chatFontSize, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, disableAnimations: true, largeEmoji: presentationData.largeEmoji, chatBubbleCorners: presentationData.chatBubbleCorners, animatedEmojiScale: 1.0) + + self.checkNodeTheme = CheckNodeTheme(theme: presentationData.theme, style: .overlay, hasInset: true) + } + + func updatePresentationData(presentationData: PresentationData) { + self.strings = presentationData.strings + + let themeData = ChatPresentationThemeData(theme: presentationData.theme, wallpaper: presentationData.chatWallpaper) + self.chatPresentationData = ChatPresentationData(theme: themeData, fontSize: presentationData.chatFontSize, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, disableAnimations: true, largeEmoji: presentationData.largeEmoji, chatBubbleCorners: presentationData.chatBubbleCorners, animatedEmojiScale: 1.0) + + self.checkNodeTheme = CheckNodeTheme(theme: presentationData.theme, style: .overlay, hasInset: true) + } + + func getSeparatorColor() -> UIColor { + return self.chatPresentationData.theme.theme.list.itemPlainSeparatorColor + } + + func createLayer() -> SparseItemGridLayer? { + if self.captureProtected { + return CaptureProtectedItemLayer() + } else { + return GenericItemLayer() + } + } + + func createView() -> SparseItemGridView? { + return nil + } + + func createShimmerLayer() -> SparseItemGridShimmerLayer? { + return nil + } + + private static let widthSpecs: ([Int], [Int]) = { + let list: [(Int, Int)] = [ + (50, 64), + (100, 150), + (140, 200), + (Int.max, 280) + ] + return (list.map(\.0), list.map(\.1)) + }() + + func bindLayers(items: [SparseItemGrid.Item], layers: [SparseItemGridDisplayItem], size: CGSize, insets: UIEdgeInsets, synchronous: SparseItemGrid.Synchronous) { + for i in 0 ..< items.count { + guard let item = items[i] as? VisualMediaItem else { + continue + } + + let displayItem = layers[i] + + guard let layer = displayItem.layer as? ItemLayer else { + continue + } + if layer.bounds.isEmpty { + continue + } + + var imageWidthSpec: Int = SparseItemGridBindingImpl.widthSpecs.1[0] + for i in 0 ..< SparseItemGridBindingImpl.widthSpecs.0.count { + if Int(layer.bounds.width) <= SparseItemGridBindingImpl.widthSpecs.0[i] { + imageWidthSpec = SparseItemGridBindingImpl.widthSpecs.1[i] + break + } + } + + let story = item.story + let hasSpoiler = false + layer.updateHasSpoiler(hasSpoiler: hasSpoiler) + + var selectedMedia: Media? + if let image = story.media._asMedia() as? TelegramMediaImage { + selectedMedia = image + } else if let file = story.media._asMedia() as? TelegramMediaFile { + selectedMedia = file + } + + if let selectedMedia = selectedMedia { + if let result = directMediaImageCache.getImage(peer: item.peer, story: story, media: selectedMedia, width: imageWidthSpec, aspectRatio: 0.56, possibleWidths: SparseItemGridBindingImpl.widthSpecs.1, includeBlurred: hasSpoiler, synchronous: synchronous == .full) { + if let image = result.image { + layer.setContents(image) + switch synchronous { + case .none: + layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, completion: { [weak self, weak layer, weak displayItem] _ in + layer?.hasContents = true + if let displayItem = displayItem { + self?.updateShimmerLayersImpl?(displayItem) + } + }) + default: + layer.hasContents = true + } + } + if let image = result.blurredImage { + layer.setSpoilerContents(image) + } + if let loadSignal = result.loadSignal { + layer.disposable?.dispose() + let startTimestamp = CFAbsoluteTimeGetCurrent() + layer.disposable = (loadSignal + |> deliverOnMainQueue).start(next: { [weak self, weak layer, weak displayItem] image in + guard let layer = layer else { + return + } + let deltaTime = CFAbsoluteTimeGetCurrent() - startTimestamp + let synchronousValue: Bool + switch synchronous { + case .none, .full: + synchronousValue = false + case .semi: + synchronousValue = deltaTime < 0.1 + } + + if let contents = layer.getContents(), !synchronousValue { + let copyLayer = GenericItemLayer() + copyLayer.contents = contents + copyLayer.contentsRect = layer.contentsRect + copyLayer.frame = layer.bounds + if let durationLayer = layer.durationLayer { + layer.insertSublayer(copyLayer, below: durationLayer) + } else { + layer.addSublayer(copyLayer) + } + copyLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak copyLayer] _ in + copyLayer?.removeFromSuperlayer() + }) + + layer.setContents(image) + layer.hasContents = true + if let displayItem = displayItem { + self?.updateShimmerLayersImpl?(displayItem) + } + } else { + layer.setContents(image) + + if !synchronousValue { + layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, completion: { [weak layer] _ in + layer?.hasContents = true + if let displayItem = displayItem { + self?.updateShimmerLayersImpl?(displayItem) + } + }) + } else { + layer.hasContents = true + if let displayItem = displayItem { + self?.updateShimmerLayersImpl?(displayItem) + } + } + } + }) + } + } + + var duration: Int32? + var isMin: Bool = false + if let file = selectedMedia as? TelegramMediaFile, !file.isAnimated { + duration = file.duration + isMin = layer.bounds.width < 80.0 + } + layer.updateDuration(duration: duration, isMin: isMin, minFactor: min(1.0, layer.bounds.height / 74.0)) + } + + if let selectionState = self.chatControllerInteraction.selectionState { + //TODO:selection + let _ = selectionState + layer.updateSelection(theme: self.checkNodeTheme, isSelected: false, animated: false) + } else { + layer.updateSelection(theme: self.checkNodeTheme, isSelected: nil, animated: false) + } + + layer.bind(item: item) + } + } + + func unbindLayer(layer: SparseItemGridLayer) { + guard let layer = layer as? ItemLayer else { + return + } + layer.unbind() + } + + func scrollerTextForTag(tag: Int32) -> String? { + let month = Month(packedValue: tag) + return stringForMonth(strings: self.strings, month: month.month, ofYear: month.year) + } + + func loadHole(anchor: SparseItemGrid.HoleAnchor, at location: SparseItemGrid.HoleLocation) -> Signal { + if let loadHoleImpl = self.loadHoleImpl { + return loadHoleImpl(anchor, location) + } else { + return .never() + } + } + + func onTap(item: SparseItemGrid.Item) { + guard let item = item as? VisualMediaItem else { + return + } + self.onTapImpl?(item) + } + + func onTagTap() { + self.onTagTapImpl?() + } + + func didScroll() { + self.didScrollImpl?() + } + + func coveringInsetOffsetUpdated(transition: ContainedViewLayoutTransition) { + self.coveringInsetOffsetUpdatedImpl?(transition) + } + + func onBeginFastScrolling() { + self.onBeginFastScrollingImpl?() + } + + func getShimmerColors() -> SparseItemGrid.ShimmerColors { + if let getShimmerColorsImpl = self.getShimmerColorsImpl { + return getShimmerColorsImpl() + } else { + return SparseItemGrid.ShimmerColors(background: 0xffffff, foreground: 0xffffff) + } + } +} + +public final class PeerInfoStoryPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScrollViewDelegate, UIGestureRecognizerDelegate { + public enum ContentType { + case photoOrVideo + case photo + case video + } + + public struct ZoomLevel { + fileprivate var value: SparseItemGrid.ZoomLevel + + init(_ value: SparseItemGrid.ZoomLevel) { + self.value = value + } + + var rawValue: Int32 { + return Int32(self.value.rawValue) + } + + public init(rawValue: Int32) { + self.value = SparseItemGrid.ZoomLevel(rawValue: Int(rawValue)) + } + } + + private let context: AccountContext + private let peerId: PeerId + private let chatLocation: ChatLocation + private let chatLocationContextHolder: Atomic + private let chatControllerInteraction: ChatControllerInteraction + public private(set) var contentType: ContentType + private var contentTypePromise: ValuePromise + + public weak var parentController: ViewController? + + private let contextGestureContainerNode: ContextControllerSourceNode + private let itemGrid: SparseItemGrid + private let itemGridBinding: SparseItemGridBindingImpl + private let directMediaImageCache: DirectMediaImageCache + private var items: SparseItemGrid.Items? + private var didUpdateItemsOnce: Bool = false + + private var isDeceleratingAfterTracking = false + + private var _itemInteraction: VisualMediaItemInteraction? + private var itemInteraction: VisualMediaItemInteraction { + return self._itemInteraction! + } + + private var currentParams: (size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData)? + + private let ready = Promise() + private var didSetReady: Bool = false + public var isReady: Signal { + return self.ready.get() + } + + private let statusPromise = Promise(nil) + public var status: Signal { + self.statusPromise.get() + } + + public var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)? + public var tabBarOffset: CGFloat { + return self.itemGrid.coveringInsetOffset + } + + private let listDisposable = MetaDisposable() + private var hiddenMediaDisposable: Disposable? + + private var numberOfItemsToRequest: Int = 50 + private var isRequestingView: Bool = false + private var isFirstHistoryView: Bool = true + + private var decelerationAnimator: ConstantDisplayLinkAnimator? + + private var animationTimer: SwiftSignalKit.Timer? + + public private(set) var calendarSource: SparseMessageCalendar? + private var listSource: StoryListContext + + public var openCurrentDate: (() -> Void)? + public var paneDidScroll: (() -> Void)? + + private weak var currentGestureItem: SparseItemGridDisplayItem? + + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + + public init(context: AccountContext, chatControllerInteraction: ChatControllerInteraction, peerId: PeerId, chatLocation: ChatLocation, chatLocationContextHolder: Atomic, contentType: ContentType, captureProtected: Bool) { + self.context = context + self.peerId = peerId + self.chatLocation = chatLocation + self.chatLocationContextHolder = chatLocationContextHolder + self.chatControllerInteraction = chatControllerInteraction + self.contentType = contentType + self.contentTypePromise = ValuePromise(contentType) + + self.presentationData = self.context.sharedContext.currentPresentationData.with { $0 } + + self.contextGestureContainerNode = ContextControllerSourceNode() + self.itemGrid = SparseItemGrid(theme: self.presentationData.theme) + self.directMediaImageCache = DirectMediaImageCache(account: context.account) + + self.itemGridBinding = SparseItemGridBindingImpl( + context: context, + chatLocation: .peer(id: peerId), + chatControllerInteraction: chatControllerInteraction, + directMediaImageCache: self.directMediaImageCache, + captureProtected: captureProtected + ) + + self.listSource = context.engine.messages.peerStories(id: self.peerId) + //self.listSource = context.engine.messages.allStories() + self.calendarSource = nil + + super.init() + + let _ = (ApplicationSpecificNotice.getSharedMediaScrollingTooltip(accountManager: context.sharedContext.accountManager) + |> deliverOnMainQueue).start(next: { [weak self] count in + guard let strongSelf = self else { + return + } + if count < 1 { + strongSelf.itemGrid.updateScrollingAreaTooltip(tooltip: SparseItemGridScrollingArea.DisplayTooltip(animation: "anim_infotip", text: strongSelf.itemGridBinding.chatPresentationData.strings.SharedMedia_FastScrollTooltip, completed: { + guard let strongSelf = self else { + return + } + let _ = ApplicationSpecificNotice.incrementSharedMediaScrollingTooltip(accountManager: strongSelf.context.sharedContext.accountManager, count: 1).start() + })) + } + }) + + self.itemGridBinding.loadHoleImpl = { [weak self] hole, location in + guard let strongSelf = self else { + return .never() + } + return strongSelf.loadHole(anchor: hole, at: location) + } + + self.itemGridBinding.onTapImpl = { [weak self] item in + guard let self else { + return + } + if let selectionState = self.chatControllerInteraction.selectionState { + let _ = selectionState + //TODO:selection + /*var toggledValue = true + if selectionState.selectedIds.contains(item.message.id) { + toggledValue = false + } + strongSelf.chatControllerInteraction.toggleMessagesSelection([item.message.id], toggledValue)*/ + } else { + let _ = (StoryChatContent.stories( + context: self.context, + storyList: self.listSource, + focusItem: item.story.id + ) + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] initialContent in + guard let self, let navigationController = self.chatControllerInteraction.navigationController() else { + return + } + + + var transitionIn: StoryContainerScreen.TransitionIn? + + let story = item.story + var foundItemLayer: SparseItemGridLayer? + self.itemGrid.forEachVisibleItem { item in + guard let itemLayer = item.layer as? ItemLayer else { + return + } + if let listItem = itemLayer.item, listItem.story.id == story.id { + foundItemLayer = itemLayer + } + } + if let foundItemLayer { + let itemRect = self.itemGrid.frameForItem(layer: foundItemLayer) + transitionIn = StoryContainerScreen.TransitionIn( + sourceView: self.view, + sourceRect: self.itemGrid.view.convert(itemRect, to: self.view), + sourceCornerRadius: 0.0 + ) + } + + let storyContainerScreen = StoryContainerScreen( + context: self.context, + initialFocusedId: AnyHashable(peerId), + initialContent: initialContent, + transitionIn: transitionIn, + transitionOut: { [weak self] _, itemId in + guard let self else { + return nil + } + + var foundItemLayer: SparseItemGridLayer? + self.itemGrid.forEachVisibleItem { item in + guard let itemLayer = item.layer as? ItemLayer else { + return + } + if let listItem = itemLayer.item, AnyHashable(listItem.story.id) == itemId { + foundItemLayer = itemLayer + } + } + if let foundItemLayer { + let itemRect = self.itemGrid.frameForItem(layer: foundItemLayer) + return StoryContainerScreen.TransitionOut( + destinationView: self.view, + destinationRect: self.itemGrid.view.convert(itemRect, to: self.view), + destinationCornerRadius: 0.0, + completed: {} + ) + } + + return nil + } + ) + navigationController.pushViewController(storyContainerScreen) + }) + //TODO:open + //let _ = strongSelf.chatControllerInteraction.openMessage(item.message, .default) + } + } + + self.itemGridBinding.onTagTapImpl = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.openCurrentDate?() + } + + self.itemGridBinding.didScrollImpl = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.paneDidScroll?() + + strongSelf.cancelPreviewGestures() + } + + self.itemGridBinding.coveringInsetOffsetUpdatedImpl = { [weak self] transition in + guard let strongSelf = self else { + return + } + strongSelf.tabBarOffsetUpdated?(transition) + } + + var processedOnBeginFastScrolling = false + self.itemGridBinding.onBeginFastScrollingImpl = { [weak self] in + guard let strongSelf = self else { + return + } + if processedOnBeginFastScrolling { + return + } + processedOnBeginFastScrolling = true + + let _ = (ApplicationSpecificNotice.getSharedMediaFastScrollingTooltip(accountManager: strongSelf.context.sharedContext.accountManager) + |> deliverOnMainQueue).start(next: { count in + guard let strongSelf = self else { + return + } + if count < 1 { + let _ = ApplicationSpecificNotice.incrementSharedMediaFastScrollingTooltip(accountManager: strongSelf.context.sharedContext.accountManager).start() + + var currentNode: ASDisplayNode = strongSelf + var result: PeerInfoScreenNodeProtocol? + while true { + if let currentNode = currentNode as? PeerInfoScreenNodeProtocol { + result = currentNode + break + } else if let supernode = currentNode.supernode { + currentNode = supernode + } else { + break + } + } + if let result = result { + result.displaySharedMediaFastScrollingTooltip() + } + } + }) + } + + self.itemGridBinding.getShimmerColorsImpl = { [weak self] in + guard let strongSelf = self, let presentationData = strongSelf.currentParams?.presentationData else { + return SparseItemGrid.ShimmerColors(background: 0xffffff, foreground: 0xffffff) + } + + let backgroundColor = presentationData.theme.list.mediaPlaceholderColor + let foregroundColor = presentationData.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.6) + + return SparseItemGrid.ShimmerColors(background: backgroundColor.argb, foreground: foregroundColor.argb) + } + + self.itemGridBinding.updateShimmerLayersImpl = { [weak self] layer in + self?.itemGrid.updateShimmerLayers(item: layer) + } + + self.itemGrid.cancelExternalContentGestures = { [weak self] in + self?.contextGestureContainerNode.cancelGesture() + } + + self.itemGrid.zoomLevelUpdated = { [weak self] zoomLevel in + guard let strongSelf = self else { + return + } + let _ = strongSelf + //let _ = updateVisualMediaStoredState(engine: strongSelf.context.engine, peerId: strongSelf.peerId, messageTag: strongSelf.stateTag, state: VisualMediaStoredState(zoomLevel: Int32(zoomLevel.rawValue))).start() + } + + self._itemInteraction = VisualMediaItemInteraction( + openMessage: { [weak self] message in + let _ = self?.chatControllerInteraction.openMessage(message, .default) + }, + openMessageContextActions: { [weak self] message, sourceNode, sourceRect, gesture in + self?.chatControllerInteraction.openMessageContextActions(message, sourceNode, sourceRect, gesture) + }, + toggleSelection: { [weak self] id, value in + self?.chatControllerInteraction.toggleMessagesSelection([id], value) + } + ) + self.itemInteraction.selectedMessageIds = chatControllerInteraction.selectionState.flatMap { $0.selectedIds } + + self.contextGestureContainerNode.isGestureEnabled = true + self.contextGestureContainerNode.addSubnode(self.itemGrid) + self.addSubnode(self.contextGestureContainerNode) + + self.contextGestureContainerNode.shouldBegin = { [weak self] point in + guard let strongSelf = self else { + return false + } + guard let item = strongSelf.itemGrid.item(at: point) else { + return false + } + + if let result = strongSelf.view.hitTest(point, with: nil) { + if result.asyncdisplaykit_node is SparseItemGridScrollingArea { + return false + } + } + + strongSelf.currentGestureItem = item + + return true + } + + self.contextGestureContainerNode.customActivationProgress = { [weak self] progress, update in + guard let strongSelf = self, let currentGestureItem = strongSelf.currentGestureItem else { + return + } + guard let itemLayer = currentGestureItem.layer else { + return + } + + let targetContentRect = CGRect(origin: CGPoint(), size: itemLayer.bounds.size) + + let scaleSide = itemLayer.bounds.width + let minScale: CGFloat = max(0.7, (scaleSide - 15.0) / scaleSide) + let currentScale = 1.0 * (1.0 - progress) + minScale * progress + + let originalCenterOffsetX: CGFloat = itemLayer.bounds.width / 2.0 - targetContentRect.midX + let scaledCenterOffsetX: CGFloat = originalCenterOffsetX * currentScale + + let originalCenterOffsetY: CGFloat = itemLayer.bounds.height / 2.0 - targetContentRect.midY + let scaledCenterOffsetY: CGFloat = originalCenterOffsetY * currentScale + + let scaleMidX: CGFloat = scaledCenterOffsetX - originalCenterOffsetX + let scaleMidY: CGFloat = scaledCenterOffsetY - originalCenterOffsetY + + switch update { + case .update: + let sublayerTransform = CATransform3DTranslate(CATransform3DScale(CATransform3DIdentity, currentScale, currentScale, 1.0), scaleMidX, scaleMidY, 0.0) + itemLayer.transform = sublayerTransform + case .begin: + let sublayerTransform = CATransform3DTranslate(CATransform3DScale(CATransform3DIdentity, currentScale, currentScale, 1.0), scaleMidX, scaleMidY, 0.0) + itemLayer.transform = sublayerTransform + case .ended: + let sublayerTransform = CATransform3DTranslate(CATransform3DScale(CATransform3DIdentity, currentScale, currentScale, 1.0), scaleMidX, scaleMidY, 0.0) + let previousTransform = itemLayer.transform + itemLayer.transform = sublayerTransform + + itemLayer.animate(from: NSValue(caTransform3D: previousTransform), to: NSValue(caTransform3D: sublayerTransform), keyPath: "transform", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2) + } + } + + self.contextGestureContainerNode.activated = { [weak self] gesture, _ in + guard let strongSelf = self, let currentGestureItem = strongSelf.currentGestureItem else { + return + } + strongSelf.currentGestureItem = nil + + guard let itemLayer = currentGestureItem.layer as? ItemLayer else { + return + } + guard let story = itemLayer.item?.story else { + return + } + let rect = strongSelf.itemGrid.frameForItem(layer: itemLayer) + + //TODO:context menu + let _ = story + let _ = rect + let _ = gesture + //strongSelf.chatControllerInteraction.openMessageContextActions(message, strongSelf, rect, gesture) + + strongSelf.itemGrid.cancelGestures() + } + + /*self.storedStateDisposable = (visualMediaStoredState(engine: context.engine, peerId: peerId, messageTag: self.stateTag) + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let strongSelf = self else { + return + } + if let value = value { + strongSelf.updateZoomLevel(level: ZoomLevel(rawValue: value.zoomLevel)) + } + strongSelf.requestHistoryAroundVisiblePosition(synchronous: false, reloadAtTop: false) + })*/ + + //TODO:hidden media + /*self.hiddenMediaDisposable = context.sharedContext.mediaManager.galleryHiddenMediaManager.hiddenIds().start(next: { [weak self] ids in + guard let strongSelf = self else { + return + } + var hiddenMedia: [MessageId: [Media]] = [:] + for id in ids { + if case let .chat(accountId, messageId, media) = id, accountId == strongSelf.context.account.id { + hiddenMedia[messageId] = [media] + } + } + strongSelf.itemInteraction.hiddenMedia = hiddenMedia + + if let items = strongSelf.items { + for item in items.items { + if let item = item as? VisualMediaItem { + if hiddenMedia[item.message.id] != nil { + strongSelf.itemGrid.ensureItemVisible(index: item.index) + break + } + } + } + } + + strongSelf.updateHiddenMedia() + })*/ + + /*let animationTimer = SwiftSignalKit.Timer(timeout: 0.3, repeat: true, completion: { [weak self] in + guard let strongSelf = self else { + return + } + for (_, itemNode) in strongSelf.visibleMediaItems { + itemNode.tick() + } + }, queue: .mainQueue()) + self.animationTimer = animationTimer + animationTimer.start()*/ + + /*self.statusPromise.set((self.contentTypePromise.get() + |> distinctUntilChanged + |> mapToSignal { contentType -> Signal<(ContentType, [MessageTags: Int32]), NoError> in + var summaries: [MessageTags] = [] + switch contentType { + case .photoOrVideo: + summaries.append(.photo) + summaries.append(.video) + case .photo: + summaries.append(.photo) + case .video: + summaries.append(.video) + case .gifs: + summaries.append(.gif) + case .files: + summaries.append(.file) + case .voiceAndVideoMessages: + summaries.append(.voiceOrInstantVideo) + case .music: + summaries.append(.music) + } + + return context.engine.data.subscribe(EngineDataMap( + summaries.map { TelegramEngine.EngineData.Item.Messages.MessageCount(peerId: peerId, threadId: chatLocation.threadId, tag: $0) } + )) + |> map { summaries -> (ContentType, [MessageTags: Int32]) in + var result: [MessageTags: Int32] = [:] + for (key, count) in summaries { + result[key.tag] = count.flatMap(Int32.init) ?? 0 + } + return (contentType, result) + } + } + |> distinctUntilChanged(isEqual: { lhs, rhs in + if lhs.0 != rhs.0 { + return false + } + if lhs.1 != rhs.1 { + return false + } + return true + }) + |> map { contentType, dict -> PeerInfoStatusData? in + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + + switch contentType { + case .photoOrVideo: + let photoCount: Int32 = dict[.photo] ?? 0 + let videoCount: Int32 = dict[.video] ?? 0 + + if photoCount != 0 && videoCount != 0 { + return PeerInfoStatusData(text: "\(presentationData.strings.SharedMedia_PhotoCount(Int32(photoCount))), \(presentationData.strings.SharedMedia_VideoCount(Int32(videoCount)))", isActivity: false, key: .media) + } else if photoCount != 0 { + return PeerInfoStatusData(text: presentationData.strings.SharedMedia_PhotoCount(Int32(photoCount)), isActivity: false, key: .media) + } else if videoCount != 0 { + return PeerInfoStatusData(text: presentationData.strings.SharedMedia_VideoCount(Int32(videoCount)), isActivity: false, key: .media) + } else { + return nil + } + case .photo: + let photoCount: Int32 = dict[.photo] ?? 0 + + if photoCount != 0 { + return PeerInfoStatusData(text: presentationData.strings.SharedMedia_PhotoCount(Int32(photoCount)), isActivity: false, key: .media) + } else { + return nil + } + case .video: + let videoCount: Int32 = dict[.video] ?? 0 + + if videoCount != 0 { + return PeerInfoStatusData(text: presentationData.strings.SharedMedia_VideoCount(Int32(videoCount)), isActivity: false, key: .media) + } else { + return nil + } + case .gifs: + let gifCount: Int32 = dict[.gif] ?? 0 + + if gifCount != 0 { + return PeerInfoStatusData(text: presentationData.strings.SharedMedia_GifCount(Int32(gifCount)), isActivity: false, key: .gifs) + } else { + return nil + } + case .files: + let fileCount: Int32 = dict[.file] ?? 0 + + if fileCount != 0 { + return PeerInfoStatusData(text: presentationData.strings.SharedMedia_FileCount(Int32(fileCount)), isActivity: false, key: .files) + } else { + return nil + } + case .voiceAndVideoMessages: + let itemCount: Int32 = dict[.voiceOrInstantVideo] ?? 0 + + if itemCount != 0 { + return PeerInfoStatusData(text: presentationData.strings.SharedMedia_VoiceMessageCount(Int32(itemCount)), isActivity: false, key: .voice) + } else { + return nil + } + case .music: + let itemCount: Int32 = dict[.music] ?? 0 + + if itemCount != 0 { + return PeerInfoStatusData(text: presentationData.strings.SharedMedia_MusicCount(Int32(itemCount)), isActivity: false, key: .music) + } else { + return nil + } + } + }))*/ + + self.presentationDataDisposable = (self.context.sharedContext.presentationData + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + guard let strongSelf = self else { + return + } + + strongSelf.itemGridBinding.updatePresentationData(presentationData: presentationData) + + strongSelf.itemGrid.updatePresentationData(theme: presentationData.theme) + }) + + self.requestHistoryAroundVisiblePosition(synchronous: false, reloadAtTop: false) + } + + deinit { + self.listDisposable.dispose() + self.hiddenMediaDisposable?.dispose() + self.animationTimer?.invalidate() + self.presentationDataDisposable?.dispose() + } + + public func loadHole(anchor: SparseItemGrid.HoleAnchor, at location: SparseItemGrid.HoleLocation) -> Signal { + //TODO:load more + /*guard let anchor = anchor as? VisualMediaHoleAnchor else { + return .never() + } + let mappedDirection: SparseMessageList.LoadHoleDirection + switch location { + case .around: + mappedDirection = .around + case .toLower: + mappedDirection = .later + case .toUpper: + mappedDirection = .earlier + } + let listSource = self.listSource + return Signal { subscriber in + listSource.loadHole(anchor: anchor.messageId, direction: mappedDirection, completion: { + subscriber.putCompletion() + }) + + return EmptyDisposable + }*/ + + return .never() + } + + public func updateContentType(contentType: ContentType) { + /*if self.contentType == contentType { + return + } + self.contentType = contentType + self.contentTypePromise.set(contentType) + + self.itemGrid.hideScrollingArea() + + var threadId: Int64? + if case let .replyThread(message) = chatLocation { + threadId = Int64(message.messageId.id) + } + + self.listSource = self.context.engine.messages.sparseMessageList(peerId: self.peerId, threadId: threadId, tag: tagMaskForType(self.contentType)) + self.isRequestingView = false + self.requestHistoryAroundVisiblePosition(synchronous: true, reloadAtTop: true)*/ + } + + public func updateZoomLevel(level: ZoomLevel) { + self.itemGrid.setZoomLevel(level: level.value) + + //let _ = updateVisualMediaStoredState(engine: self.context.engine, peerId: self.peerId, messageTag: self.stateTag, state: VisualMediaStoredState(zoomLevel: level.rawValue)).start() + } + + public func ensureMessageIsVisible(id: MessageId) { + } + + private func requestHistoryAroundVisiblePosition(synchronous: Bool, reloadAtTop: Bool) { + if self.isRequestingView { + return + } + self.isRequestingView = true + var firstTime = true + let queue = Queue() + + self.listDisposable.set((self.listSource.state + |> deliverOn(queue)).start(next: { [weak self] list in + guard let self else { + return + } + + let timezoneOffset = Int32(TimeZone.current.secondsFromGMT()) + + var mappedItems: [SparseItemGrid.Item] = [] + let mappedHoles: [SparseItemGrid.HoleAnchor] = [] + var totalCount: Int = 0 + if let itemSet = list.itemSets.first(where: { $0.peerId == self.peerId }), let peer = itemSet.peer, let peerReference = PeerReference(peer._asPeer()) { + for item in itemSet.items { + mappedItems.append(VisualMediaItem( + index: mappedItems.count, + peer: peerReference, + story: item, + localMonthTimestamp: Month(localTimestamp: item.timestamp + timezoneOffset).packedValue + )) + } + totalCount = itemSet.totalCount ?? mappedItems.count + totalCount = max(mappedItems.count, totalCount) + } + + Queue.mainQueue().async { [weak self] in + guard let strongSelf = self else { + return + } + + let items = SparseItemGrid.Items( + items: mappedItems, + holeAnchors: mappedHoles, + count: totalCount, + itemBinding: strongSelf.itemGridBinding + ) + + let currentSynchronous = synchronous && firstTime + let currentReloadAtTop = reloadAtTop && firstTime + firstTime = false + strongSelf.updateHistory(items: items, synchronous: currentSynchronous, reloadAtTop: currentReloadAtTop) + strongSelf.isRequestingView = false + } + })) + } + + private func updateHistory(items: SparseItemGrid.Items, synchronous: Bool, reloadAtTop: Bool) { + self.items = items + + if let (size, topInset, sideInset, bottomInset, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData) = self.currentParams { + var gridSnapshot: UIView? + if reloadAtTop { + gridSnapshot = self.itemGrid.view.snapshotView(afterScreenUpdates: false) + } + self.update(size: size, topInset: topInset, sideInset: sideInset, bottomInset: bottomInset, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, presentationData: presentationData, synchronous: false, transition: .immediate) + if let gridSnapshot = gridSnapshot { + self.view.addSubview(gridSnapshot) + gridSnapshot.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak gridSnapshot] _ in + gridSnapshot?.removeFromSuperview() + }) + } + } + + if !self.didSetReady { + self.didSetReady = true + self.ready.set(.single(true)) + } + } + + public func scrollToTop() -> Bool { + return self.itemGrid.scrollToTop() + } + + public func hitTestResultForScrolling() -> UIView? { + return self.itemGrid.hitTestResultForScrolling() + } + + public func brieflyDisableTouchActions() { + self.itemGrid.brieflyDisableTouchActions() + } + + public func findLoadedMessage(id: MessageId) -> Message? { + return nil + } + + public func updateHiddenMedia() { + //TODO:updateHiddenMedia + /*self.itemGrid.forEachVisibleItem { item in + guard let itemLayer = item.layer as? ItemLayer else { + return + } + if let item = itemLayer.item { + if self.itemInteraction.hiddenMedia[item.message.id] != nil { + itemLayer.isHidden = true + itemLayer.updateHasSpoiler(hasSpoiler: false) + self.itemGridBinding.revealedSpoilerMessageIds.insert(item.message.id) + } else { + itemLayer.isHidden = false + } + } else { + itemLayer.isHidden = false + } + }*/ + } + + public func transferVelocity(_ velocity: CGFloat) { + self.itemGrid.transferVelocity(velocity) + } + + public func cancelPreviewGestures() { + } + + public func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + return nil + + /*var foundItemLayer: SparseItemGridLayer? + self.itemGrid.forEachVisibleItem { item in + guard let itemLayer = item.layer as? ItemLayer else { + return + } + if let item = itemLayer.item, item.message.id == messageId { + foundItemLayer = itemLayer + } + } + if let itemLayer = foundItemLayer { + let itemFrame = self.view.convert(self.itemGrid.frameForItem(layer: itemLayer), from: self.itemGrid.view) + let proxyNode = ASDisplayNode() + proxyNode.frame = itemFrame + if let contents = itemLayer.getContents() { + if let image = contents as? UIImage { + proxyNode.contents = image.cgImage + } else { + proxyNode.contents = contents + } + } + proxyNode.isHidden = true + self.addSubnode(proxyNode) + + let escapeNotification = EscapeNotification { + proxyNode.removeFromSupernode() + } + + return (proxyNode, proxyNode.bounds, { + let view = UIView() + view.frame = proxyNode.frame + view.layer.contents = proxyNode.layer.contents + escapeNotification.keep() + return (view, nil) + }) + } + return nil*/ + } + + public func addToTransitionSurface(view: UIView) { + self.itemGrid.addToTransitionSurface(view: view) + } + + private var gridSelectionGesture: MediaPickerGridSelectionGesture? + + override public func didLoad() { + super.didLoad() + + let selectionRecognizer = MediaListSelectionRecognizer(target: self, action: #selector(self.selectionPanGesture(_:))) + selectionRecognizer.shouldBegin = { + return true + } + self.view.addGestureRecognizer(selectionRecognizer) + } + + private var selectionPanState: (selecting: Bool, initialMessageId: EngineMessage.Id, toggledMessageIds: [[EngineMessage.Id]])? + private var selectionScrollActivationTimer: SwiftSignalKit.Timer? + private var selectionScrollDisplayLink: ConstantDisplayLinkAnimator? + private var selectionScrollDelta: CGFloat? + private var selectionLastLocation: CGPoint? + + private func storyAtPoint(_ location: CGPoint) -> StoryViewList.Item? { + return nil + } + + @objc private func selectionPanGesture(_ recognizer: UIGestureRecognizer) -> Void { + //TODO:selection + /*let location = recognizer.location(in: self.view) + switch recognizer.state { + case .began: + if let message = self.messageAtPoint(location) { + let selecting = !(self.chatControllerInteraction.selectionState?.selectedIds.contains(message.id) ?? false) + self.selectionPanState = (selecting, message.id, []) + self.chatControllerInteraction.toggleMessagesSelection([message.id], selecting) + } + case .changed: + self.handlePanSelection(location: location) + self.selectionLastLocation = location + case .ended, .failed, .cancelled: + self.selectionPanState = nil + self.selectionScrollDisplayLink = nil + self.selectionScrollActivationTimer?.invalidate() + self.selectionScrollActivationTimer = nil + self.selectionScrollDelta = nil + self.selectionLastLocation = nil + self.selectionScrollSkipUpdate = false + case .possible: + break + @unknown default: + fatalError() + }*/ + } + + private func handlePanSelection(location: CGPoint) { + /*var location = location + if location.y < 0.0 { + location.y = 5.0 + } else if location.y > self.frame.height { + location.y = self.frame.height - 5.0 + } + + var hasState = false + if let state = self.selectionPanState { + hasState = true + if let message = self.messageAtPoint(location) { + if message.id == state.initialMessageId { + if !state.toggledMessageIds.isEmpty { + self.chatControllerInteraction.toggleMessagesSelection(state.toggledMessageIds.flatMap { $0.compactMap({ $0 }) }, !state.selecting) + self.selectionPanState = (state.selecting, state.initialMessageId, []) + } + } else if state.toggledMessageIds.last?.first != message.id { + var updatedToggledMessageIds: [[EngineMessage.Id]] = [] + var previouslyToggled = false + for i in (0 ..< state.toggledMessageIds.count) { + if let messageId = state.toggledMessageIds[i].first { + if messageId == message.id { + previouslyToggled = true + updatedToggledMessageIds = Array(state.toggledMessageIds.prefix(i + 1)) + + let messageIdsToToggle = Array(state.toggledMessageIds.suffix(state.toggledMessageIds.count - i - 1)).flatMap { $0 } + self.chatControllerInteraction.toggleMessagesSelection(messageIdsToToggle, !state.selecting) + break + } + } + } + + if !previouslyToggled { + updatedToggledMessageIds = state.toggledMessageIds + let isSelected = self.chatControllerInteraction.selectionState?.selectedIds.contains(message.id) ?? false + if state.selecting != isSelected { + updatedToggledMessageIds.append([message.id]) + self.chatControllerInteraction.toggleMessagesSelection([message.id], state.selecting) + } + } + + self.selectionPanState = (state.selecting, state.initialMessageId, updatedToggledMessageIds) + } + } + } + guard hasState else { + return + } + let scrollingAreaHeight: CGFloat = 50.0 + if location.y < scrollingAreaHeight || location.y > self.frame.height - scrollingAreaHeight { + if location.y < self.frame.height / 2.0 { + self.selectionScrollDelta = (scrollingAreaHeight - location.y) / scrollingAreaHeight + } else { + self.selectionScrollDelta = -(scrollingAreaHeight - min(scrollingAreaHeight, max(0.0, (self.frame.height - location.y)))) / scrollingAreaHeight + } + if let displayLink = self.selectionScrollDisplayLink { + displayLink.isPaused = false + } else { + if let _ = self.selectionScrollActivationTimer { + } else { + let timer = SwiftSignalKit.Timer(timeout: 0.45, repeat: false, completion: { [weak self] in + self?.setupSelectionScrolling() + }, queue: .mainQueue()) + timer.start() + self.selectionScrollActivationTimer = timer + } + } + } else { + self.selectionScrollDisplayLink?.isPaused = true + self.selectionScrollActivationTimer?.invalidate() + self.selectionScrollActivationTimer = nil + }*/ + } + + private var selectionScrollSkipUpdate = false + private func setupSelectionScrolling() { + self.selectionScrollDisplayLink = ConstantDisplayLinkAnimator(update: { [weak self] in + self?.selectionScrollActivationTimer = nil + if let strongSelf = self, let delta = strongSelf.selectionScrollDelta { + let distance: CGFloat = 15.0 * min(1.0, 0.15 + abs(delta * delta)) + let direction: ListViewScrollDirection = delta > 0.0 ? .up : .down + let _ = strongSelf.itemGrid.scrollWithDelta(direction == .up ? -distance : distance) + + if let location = strongSelf.selectionLastLocation { + if !strongSelf.selectionScrollSkipUpdate { + strongSelf.handlePanSelection(location: location) + } + strongSelf.selectionScrollSkipUpdate = !strongSelf.selectionScrollSkipUpdate + } + } + }) + self.selectionScrollDisplayLink?.isPaused = false + } + + override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + let location = gestureRecognizer.location(in: gestureRecognizer.view) + if location.x < 44.0 { + return false + } + return true + } + + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer.state != .failed, let otherGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer { + otherGestureRecognizer.isEnabled = false + otherGestureRecognizer.isEnabled = true + return true + } else { + return false + } + } + + public func updateSelectedMessages(animated: Bool) { + /*switch self.contentType { + case .files, .music, .voiceAndVideoMessages: + self.itemGrid.forEachVisibleItem { item in + guard let itemView = item.view as? ItemView, let (size, topInset, sideInset, bottomInset, _, _, _, _) = self.currentParams else { + return + } + if let item = itemView.item { + itemView.bind( + item: item, + presentationData: self.itemGridBinding.chatPresentationData, + context: self.itemGridBinding.context, + chatLocation: self.itemGridBinding.chatLocation, + interaction: self.itemGridBinding.listItemInteraction, + isSelected: self.chatControllerInteraction.selectionState?.selectedIds.contains(item.message.id), + size: CGSize(width: size.width, height: itemView.bounds.height), + insets: UIEdgeInsets(top: topInset, left: sideInset, bottom: bottomInset, right: sideInset) + ) + } + } + case .photo, .video, .photoOrVideo, .gifs: + self.itemGrid.forEachVisibleItem { item in + guard let itemLayer = item.layer as? ItemLayer, let item = itemLayer.item else { + return + } + itemLayer.updateSelection(theme: self.itemGridBinding.checkNodeTheme, isSelected: self.chatControllerInteraction.selectionState?.selectedIds.contains(item.message.id), animated: animated) + } + + let isSelecting = self.chatControllerInteraction.selectionState != nil + self.itemGrid.pinchEnabled = !isSelecting + + if isSelecting { + if self.gridSelectionGesture == nil { + let selectionGesture = MediaPickerGridSelectionGesture() + selectionGesture.delegate = self + selectionGesture.sideInset = 44.0 + selectionGesture.updateIsScrollEnabled = { [weak self] isEnabled in + self?.itemGrid.isScrollEnabled = isEnabled + } + selectionGesture.itemAt = { [weak self] point in + if let strongSelf = self, let itemLayer = strongSelf.itemGrid.item(at: point)?.layer as? ItemLayer, let messageId = itemLayer.item?.message.id { + return (messageId, strongSelf.chatControllerInteraction.selectionState?.selectedIds.contains(messageId) ?? false) + } else { + return nil + } + } + selectionGesture.updateSelection = { [weak self] messageId, selected in + if let strongSelf = self { + strongSelf.chatControllerInteraction.toggleMessagesSelection([messageId], selected) + } + } + self.itemGrid.view.addGestureRecognizer(selectionGesture) + self.gridSelectionGesture = selectionGesture + } + } else if let gridSelectionGesture = self.gridSelectionGesture { + self.itemGrid.view.removeGestureRecognizer(gridSelectionGesture) + self.gridSelectionGesture = nil + } + }*/ + } + + public func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { + self.currentParams = (size, topInset, sideInset, bottomInset, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData) + + transition.updateFrame(node: self.contextGestureContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) + + transition.updateFrame(node: self.itemGrid, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) + if let items = self.items { + let wasFirstTime = !self.didUpdateItemsOnce + self.didUpdateItemsOnce = true + let fixedItemHeight: CGFloat? + let isList = false + switch self.contentType { + default: + fixedItemHeight = nil + } + + let fixedItemAspect: CGFloat? = 9.0 / 16.0 + + self.itemGrid.update(size: size, insets: UIEdgeInsets(top: topInset, left: sideInset, bottom: bottomInset, right: sideInset), useSideInsets: !isList, scrollIndicatorInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset), lockScrollingAtTop: isScrollingLockedAtTop, fixedItemHeight: fixedItemHeight, fixedItemAspect: fixedItemAspect, items: items, theme: self.itemGridBinding.chatPresentationData.theme.theme, synchronous: wasFirstTime ? .full : .none) + } + } + + public func currentTopTimestamp() -> Int32? { + var timestamp: Int32? + self.itemGrid.forEachVisibleItem { item in + guard let itemLayer = item.layer as? ItemLayer else { + return + } + if let item = itemLayer.item { + if let timestampValue = timestamp { + timestamp = max(timestampValue, item.story.timestamp) + } else { + timestamp = item.story.timestamp + } + } + } + return timestamp + } + + public func scrollToTimestamp(timestamp: Int32) { + if let items = self.items, !items.items.isEmpty { + var previousIndex: Int? + for item in items.items { + guard let item = item as? VisualMediaItem else { + continue + } + if item.story.timestamp <= timestamp { + break + } + previousIndex = item.index + } + if previousIndex == nil { + previousIndex = (items.items[0] as? VisualMediaItem)?.index + } + if let index = previousIndex { + self.itemGrid.scrollToItem(at: index) + + if let item = self.itemGrid.item(at: index) { + if let layer = item.layer as? ItemLayer { + Queue.mainQueue().after(0.1, { [weak layer] in + guard let layer = layer else { + return + } + + let overlayLayer = SimpleLayer() + overlayLayer.backgroundColor = UIColor(white: 1.0, alpha: 0.6).cgColor + overlayLayer.frame = layer.bounds + layer.addSublayer(overlayLayer) + overlayLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.8, delay: 0.3, removeOnCompletion: false, completion: { [weak overlayLayer] _ in + overlayLayer?.removeFromSuperlayer() + }) + }) + } + } + } + } + } + + public func scrollToItem(index: Int) { + guard let _ = self.items else { + return + } + self.itemGrid.scrollToItem(at: index) + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let result = super.hitTest(point, with: event) else { + return nil + } + /*if self.decelerationAnimator != nil { + self.decelerationAnimator?.isPaused = true + self.decelerationAnimator = nil + + return self.scrollNode.view + }*/ + return result + } + + public func availableZoomLevels() -> (decrement: ZoomLevel?, increment: ZoomLevel?) { + let levels = self.itemGrid.availableZoomLevels() + return (levels.decrement.flatMap(ZoomLevel.init), levels.increment.flatMap(ZoomLevel.init)) + } +} + +private class MediaListSelectionRecognizer: UIPanGestureRecognizer { + private let selectionGestureActivationThreshold: CGFloat = 5.0 + + var recognized: Bool? = nil + var initialLocation: CGPoint = CGPoint() + + public var shouldBegin: (() -> Bool)? + + public override init(target: Any?, action: Selector?) { + super.init(target: target, action: action) + + self.minimumNumberOfTouches = 2 + self.maximumNumberOfTouches = 2 + } + + public override func reset() { + super.reset() + + self.recognized = nil + } + + public override func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + if let shouldBegin = self.shouldBegin, !shouldBegin() { + self.state = .failed + } else { + let touch = touches.first! + self.initialLocation = touch.location(in: self.view) + } + } + + public override func touchesMoved(_ touches: Set, with event: UIEvent) { + let location = touches.first!.location(in: self.view) + let translation = location.offsetBy(dx: -self.initialLocation.x, dy: -self.initialLocation.y) + + let touchesArray = Array(touches) + if self.recognized == nil, touchesArray.count == 2 { + if let firstTouch = touchesArray.first, let secondTouch = touchesArray.last { + let firstLocation = firstTouch.location(in: self.view) + let secondLocation = secondTouch.location(in: self.view) + + func distance(_ v1: CGPoint, _ v2: CGPoint) -> CGFloat { + let dx = v1.x - v2.x + let dy = v1.y - v2.y + return sqrt(dx * dx + dy * dy) + } + if distance(firstLocation, secondLocation) > 200.0 { + self.state = .failed + } + } + if self.state != .failed && (abs(translation.y) >= selectionGestureActivationThreshold) { + self.recognized = true + } + } + + if let recognized = self.recognized, recognized { + super.touchesMoved(touches, with: event) + } + } +} diff --git a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift similarity index 79% rename from submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift rename to submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift index e18aeb60ea..d71d77a8ee 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift @@ -27,6 +27,55 @@ import ChatControllerInteraction import InvisibleInkDustNode import MediaPickerUI +public enum PeerInfoPaneKey: Int32 { + case members + case stories + case media + case files + case music + case voice + case links + case gifs + case groupsInCommon +} + +public struct PeerInfoStatusData: Equatable { + public var text: String + public var isActivity: Bool + public var key: PeerInfoPaneKey? + + public init( + text: String, + isActivity: Bool, + key: PeerInfoPaneKey? + ) { + self.text = text + self.isActivity = isActivity + self.key = key + } +} + +public protocol PeerInfoPaneNode: ASDisplayNode { + var isReady: Signal { get } + + var parentController: ViewController? { get set } + + var status: Signal { get } + var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)? { get set } + var tabBarOffset: CGFloat { get } + + func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) + func scrollToTop() -> Bool + func transferVelocity(_ velocity: CGFloat) + func cancelPreviewGestures() + func findLoadedMessage(id: MessageId) -> Message? + func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? + func addToTransitionSurface(view: UIView) + func updateHiddenMedia() + func updateSelectedMessages(animated: Bool) + func ensureMessageIsVisible(id: MessageId) +} + private final class FrameSequenceThumbnailNode: ASDisplayNode { private let context: AccountContext private let file: FileMediaReference @@ -147,493 +196,6 @@ private final class VisualMediaItemInteraction { } } -/*private final class VisualMediaItemNode: ASDisplayNode { - private let context: AccountContext - private let interaction: VisualMediaItemInteraction - - private var videoLayerFrameManager: SoftwareVideoLayerFrameManager? - private var sampleBufferLayer: SampleBufferLayer? - private var displayLink: ConstantDisplayLinkAnimator? - private var displayLinkTimestamp: Double = 0.0 - - private var frameSequenceThumbnailNode: FrameSequenceThumbnailNode? - - private let containerNode: ContextControllerSourceNode - - private var placeholderNode: ShimmerEffectNode? - private var absoluteLocation: (CGRect, CGSize)? - - private let imageNode: TransformImageNode - private var statusNode: RadialStatusNode - private let mediaBadgeNode: ChatMessageInteractiveMediaBadge - private var selectionNode: GridMessageSelectionNode? - - private let fetchStatusDisposable = MetaDisposable() - private let fetchDisposable = MetaDisposable() - private var resourceStatus: MediaResourceStatus? - - private var item: (VisualMediaItem, Media?, CGSize, CGSize?)? - private var theme: PresentationTheme? - - private var hasVisibility: Bool = false - - init(context: AccountContext, interaction: VisualMediaItemInteraction) { - self.context = context - self.interaction = interaction - - self.containerNode = ContextControllerSourceNode() - self.imageNode = TransformImageNode() - self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.6)) - let progressDiameter: CGFloat = 40.0 - self.statusNode.frame = CGRect(x: 0.0, y: 0.0, width: progressDiameter, height: progressDiameter) - self.statusNode.isUserInteractionEnabled = false - - self.mediaBadgeNode = ChatMessageInteractiveMediaBadge() - self.mediaBadgeNode.frame = CGRect(origin: CGPoint(x: 6.0, y: 6.0), size: CGSize(width: 50.0, height: 50.0)) - - let shimmerNode = ShimmerEffectNode() - self.placeholderNode = shimmerNode - - super.init() - - self.addSubnode(self.containerNode) - self.containerNode.addSubnode(self.imageNode) - self.containerNode.addSubnode(self.mediaBadgeNode) - - self.containerNode.activated = { [weak self] gesture, _ in - guard let strongSelf = self, let item = strongSelf.item, let message = item.0.message else { - return - } - strongSelf.interaction.openMessageContextActions(message, strongSelf.containerNode, strongSelf.containerNode.bounds, gesture) - } - } - - deinit { - self.fetchStatusDisposable.dispose() - self.fetchDisposable.dispose() - } - - override func didLoad() { - super.didLoad() - - let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) - recognizer.tapActionAtPoint = { _ in - return .waitForSingleTap - } - self.imageNode.view.addGestureRecognizer(recognizer) - - self.mediaBadgeNode.pressed = { [weak self] in - self?.progressPressed() - } - } - - func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { - self.absoluteLocation = (rect, containerSize) - if let shimmerNode = self.placeholderNode { - shimmerNode.updateAbsoluteRect(rect, within: containerSize) - } - } - - @objc func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { - if case .ended = recognizer.state { - if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation { - if case .tap = gesture { - if let (item, _, _, _) = self.item, let message = item.message { - var media: Media? - for value in message.media { - if let image = value as? TelegramMediaImage { - media = image - break - } else if let file = value as? TelegramMediaFile { - media = file - break - } - } - - if let media = media { - if let file = media as? TelegramMediaFile { - if isMediaStreamable(message: message, media: file) { - self.interaction.openMessage(message) - } else { - self.progressPressed() - } - } else { - self.interaction.openMessage(message) - } - } - } - } - } - } - } - - private func progressPressed() { - guard let message = self.item?.0.message else { - return - } - - var media: Media? - for value in message.media { - if let image = value as? TelegramMediaImage { - media = image - break - } else if let file = value as? TelegramMediaFile { - media = file - break - } - } - - if let resourceStatus = self.resourceStatus, let file = media as? TelegramMediaFile { - switch resourceStatus { - case .Fetching: - messageMediaFileCancelInteractiveFetch(context: self.context, messageId: message.id, file: file) - case .Local: - self.interaction.openMessage(message) - case .Remote: - self.fetchDisposable.set(messageMediaFileInteractiveFetched(context: self.context, message: message, file: file, userInitiated: true).start()) - } - } - } - - func cancelPreviewGesture() { - self.containerNode.cancelGesture() - } - - func update(size: CGSize, item: VisualMediaItem?, theme: PresentationTheme, synchronousLoad: Bool) { - if item === self.item?.0 && size == self.item?.2 { - return - } - self.theme = theme - var media: Media? - if let item = item, let message = item.message { - for value in message.media { - if let image = value as? TelegramMediaImage { - media = image - break - } else if let file = value as? TelegramMediaFile { - media = file - break - } - } - } - - if let shimmerNode = self.placeholderNode { - shimmerNode.frame = CGRect(origin: CGPoint(), size: size) - if let (rect, size) = self.absoluteLocation { - shimmerNode.updateAbsoluteRect(rect, within: size) - } - - var shapes: [ShimmerEffectNode.Shape] = [] - shapes.append(.rect(rect: CGRect(origin: CGPoint(), size: size))) - - shimmerNode.update(backgroundColor: theme.list.itemBlocksBackgroundColor, foregroundColor: theme.list.mediaPlaceholderColor, shimmeringColor: theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, size: size) - } - - if let item = item, let message = item.message, let file = media as? TelegramMediaFile, file.isAnimated { - if self.videoLayerFrameManager == nil { - let sampleBufferLayer: SampleBufferLayer - if let current = self.sampleBufferLayer { - sampleBufferLayer = current - } else { - sampleBufferLayer = takeSampleBufferLayer() - self.sampleBufferLayer = sampleBufferLayer - self.imageNode.layer.addSublayer(sampleBufferLayer.layer) - } - - self.videoLayerFrameManager = SoftwareVideoLayerFrameManager(account: self.context.account, fileReference: FileMediaReference.message(message: MessageReference(message), media: file), layerHolder: sampleBufferLayer) - self.videoLayerFrameManager?.start() - } - } else { - if let sampleBufferLayer = self.sampleBufferLayer { - sampleBufferLayer.layer.removeFromSuperlayer() - self.sampleBufferLayer = nil - } - self.videoLayerFrameManager = nil - } - - if let item = item, let message = item.message, let media = media, (self.item?.1 == nil || !media.isEqual(to: self.item!.1!)) { - var mediaDimensions: CGSize? - if let image = media as? TelegramMediaImage, let largestSize = largestImageRepresentation(image.representations)?.dimensions { - mediaDimensions = largestSize.cgSize - - if let placeholderNode = self.placeholderNode, placeholderNode.supernode == nil { - self.containerNode.insertSubnode(placeholderNode, at: 0) - } - self.imageNode.imageUpdated = { [weak self] image in - guard let strongSelf = self else { - return - } - if image != nil { - strongSelf.placeholderNode?.removeFromSupernode() - } - } - - self.imageNode.setSignal(mediaGridMessagePhoto(account: context.account, photoReference: .message(message: MessageReference(message), media: image), fullRepresentationSize: CGSize(width: 300.0, height: 300.0), synchronousLoad: synchronousLoad), attemptSynchronously: synchronousLoad, dispatchOnDisplayLink: true) - - self.fetchStatusDisposable.set(nil) - self.statusNode.transitionToState(.none, completion: { [weak self] in - self?.statusNode.isHidden = true - }) - self.mediaBadgeNode.isHidden = true - self.resourceStatus = nil - } else if let file = media as? TelegramMediaFile, file.isVideo { - if let placeholderNode = self.placeholderNode, placeholderNode.supernode == nil { - self.containerNode.insertSubnode(placeholderNode, at: 0) - } - self.imageNode.imageUpdated = { [weak self] image in - guard let strongSelf = self else { - return - } - if image != nil { - strongSelf.placeholderNode?.removeFromSupernode() - } - } - - mediaDimensions = file.dimensions?.cgSize - self.imageNode.setSignal(mediaGridMessageVideo(postbox: context.account.postbox, videoReference: .message(message: MessageReference(message), media: file), synchronousLoad: synchronousLoad, autoFetchFullSizeThumbnail: true), attemptSynchronously: synchronousLoad) - - self.mediaBadgeNode.isHidden = file.isAnimated - - self.resourceStatus = nil - - self.item = (item, media, size, mediaDimensions) - - self.fetchStatusDisposable.set((messageMediaFileStatus(context: context, messageId: message.id, file: file) - |> deliverOnMainQueue).start(next: { [weak self] status in - if let strongSelf = self, let (item, _, _, _) = strongSelf.item, let message = item.message { - strongSelf.resourceStatus = status - - let isStreamable = isMediaStreamable(message: message, media: file) - - var statusState: RadialStatusNodeState = .none - if isStreamable || file.isAnimated { - statusState = .none - } else { - switch status { - case let .Fetching(_, progress): - let adjustedProgress = max(progress, 0.027) - statusState = .progress(color: .white, lineWidth: nil, value: CGFloat(adjustedProgress), cancelEnabled: true, animateRotation: true) - case .Local: - statusState = .none - case .Remote: - statusState = .download(.white) - } - } - - switch statusState { - case .none: - break - default: - strongSelf.statusNode.isHidden = false - } - - strongSelf.statusNode.transitionToState(statusState, animated: true, completion: { - if let strongSelf = self { - if case .none = statusState { - strongSelf.statusNode.isHidden = true - } - } - }) - - if let duration = file.duration { - let durationString = stringForDuration(duration) - - var badgeContent: ChatMessageInteractiveMediaBadgeContent? - var mediaDownloadState: ChatMessageInteractiveMediaDownloadState? - - if isStreamable { - switch status { - case let .Fetching(_, progress): - let progressString = String(format: "%d%%", Int(progress * 100.0)) - badgeContent = .text(inset: 12.0, backgroundColor: mediaBadgeBackgroundColor, foregroundColor: mediaBadgeTextColor, text: NSAttributedString(string: progressString)) - mediaDownloadState = .compactFetching(progress: 0.0) - case .Local: - badgeContent = .text(inset: 0.0, backgroundColor: mediaBadgeBackgroundColor, foregroundColor: mediaBadgeTextColor, text: NSAttributedString(string: durationString)) - case .Remote: - badgeContent = .text(inset: 12.0, backgroundColor: mediaBadgeBackgroundColor, foregroundColor: mediaBadgeTextColor, text: NSAttributedString(string: durationString)) - mediaDownloadState = .compactRemote - } - } else { - badgeContent = .text(inset: 0.0, backgroundColor: mediaBadgeBackgroundColor, foregroundColor: mediaBadgeTextColor, text: NSAttributedString(string: durationString)) - } - - strongSelf.mediaBadgeNode.update(theme: nil, content: badgeContent, mediaDownloadState: mediaDownloadState, alignment: .right, animated: false, badgeAnimated: false) - } - } - })) - if self.statusNode.supernode == nil { - self.imageNode.addSubnode(self.statusNode) - } - } else { - self.mediaBadgeNode.isHidden = true - } - self.item = (item, media, size, mediaDimensions) - - self.updateHiddenMedia() - } else { - if let placeholderNode = self.placeholderNode, placeholderNode.supernode == nil { - self.containerNode.insertSubnode(placeholderNode, at: 0) - } - } - - let progressDiameter: CGFloat = 40.0 - self.statusNode.frame = CGRect(origin: CGPoint(x: floor((size.width - progressDiameter) / 2.0), y: floor((size.height - progressDiameter) / 2.0)), size: CGSize(width: progressDiameter, height: progressDiameter)) - - self.mediaBadgeNode.frame = CGRect(origin: CGPoint(x: size.width - 3.0, y: size.height - 18.0 - 3.0), size: CGSize(width: 50.0, height: 50.0)) - - self.selectionNode?.frame = CGRect(origin: CGPoint(), size: size) - - if let (item, media, _, mediaDimensions) = self.item { - self.item = (item, media, size, mediaDimensions) - - let imageFrame = CGRect(origin: CGPoint(), size: size) - - self.containerNode.frame = imageFrame - self.imageNode.frame = imageFrame - if let sampleBufferLayer = self.sampleBufferLayer { - sampleBufferLayer.layer.frame = imageFrame - } - - if let mediaDimensions = mediaDimensions { - let imageSize = mediaDimensions.aspectFilled(imageFrame.size) - self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageFrame.size, intrinsicInsets: UIEdgeInsets(), emptyColor: theme.list.mediaPlaceholderColor))() - } - - self.updateSelectionState(animated: false) - } - } - - func updateIsVisible(_ isVisible: Bool) { - self.hasVisibility = isVisible - if let _ = self.videoLayerFrameManager { - let displayLink: ConstantDisplayLinkAnimator - if let current = self.displayLink { - displayLink = current - } else { - displayLink = ConstantDisplayLinkAnimator { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.videoLayerFrameManager?.tick(timestamp: strongSelf.displayLinkTimestamp) - strongSelf.displayLinkTimestamp += 1.0 / 30.0 - } - displayLink.frameInterval = 2 - self.displayLink = displayLink - } - } - self.displayLink?.isPaused = !self.hasVisibility || self.isHidden - - /*if isVisible { - if let item = self.item?.0, let file = self.item?.1 as? TelegramMediaFile, !file.isAnimated { - if self.frameSequenceThumbnailNode == nil { - let frameSequenceThumbnailNode = FrameSequenceThumbnailNode(context: context, file: .message(message: MessageReference(item.message), media: file)) - self.frameSequenceThumbnailNode = frameSequenceThumbnailNode - self.imageNode.addSubnode(frameSequenceThumbnailNode) - } - if let frameSequenceThumbnailNode = self.frameSequenceThumbnailNode { - let size = self.bounds.size - frameSequenceThumbnailNode.frame = CGRect(origin: CGPoint(), size: size) - frameSequenceThumbnailNode.updateLayout(size: size) - } - } else { - if let frameSequenceThumbnailNode = self.frameSequenceThumbnailNode { - self.frameSequenceThumbnailNode = nil - frameSequenceThumbnailNode.removeFromSupernode() - } - } - } else { - if let frameSequenceThumbnailNode = self.frameSequenceThumbnailNode { - self.frameSequenceThumbnailNode = nil - frameSequenceThumbnailNode.removeFromSupernode() - } - }*/ - - self.frameSequenceThumbnailNode?.updateIsPlaying(isVisible) - } - - func tick() { - self.frameSequenceThumbnailNode?.tick() - } - - func updateSelectionState(animated: Bool) { - if let (item, _, _, _) = self.item, let message = item.message, let theme = self.theme { - self.containerNode.isGestureEnabled = self.interaction.selectedMessageIds == nil - - if let selectedIds = self.interaction.selectedMessageIds { - let selected = selectedIds.contains(message.id) - - if let selectionNode = self.selectionNode { - selectionNode.updateSelected(selected, animated: animated) - selectionNode.frame = CGRect(origin: CGPoint(), size: self.bounds.size) - } else { - let selectionNode = GridMessageSelectionNode(theme: theme, toggle: { [weak self] value in - if let strongSelf = self, let messageId = strongSelf.item?.0.message?.id { - var toggledValue = true - if let selectedMessageIds = strongSelf.interaction.selectedMessageIds, selectedMessageIds.contains(messageId) { - toggledValue = false - } - strongSelf.interaction.toggleSelection(messageId, toggledValue) - } - }) - - selectionNode.frame = CGRect(origin: CGPoint(), size: self.bounds.size) - self.containerNode.addSubnode(selectionNode) - self.selectionNode = selectionNode - selectionNode.updateSelected(selected, animated: false) - if animated { - selectionNode.animateIn() - } - } - } else { - if let selectionNode = self.selectionNode { - self.selectionNode = nil - if animated { - selectionNode.animateOut { [weak selectionNode] in - selectionNode?.removeFromSupernode() - } - } else { - selectionNode.removeFromSupernode() - } - } - } - } - } - - func transitionNode() -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { - let imageNode = self.imageNode - return (self.imageNode, self.imageNode.bounds, { [weak self, weak imageNode] in - var statusNodeHidden = false - var accessoryHidden = false - if let strongSelf = self { - statusNodeHidden = strongSelf.statusNode.isHidden - accessoryHidden = strongSelf.mediaBadgeNode.isHidden - strongSelf.statusNode.isHidden = true - strongSelf.mediaBadgeNode.isHidden = true - } - let view = imageNode?.view.snapshotView(afterScreenUpdates: false) - if let strongSelf = self { - strongSelf.statusNode.isHidden = statusNodeHidden - strongSelf.mediaBadgeNode.isHidden = accessoryHidden - } - return (view, nil) - }) - } - - func updateHiddenMedia() { - if let (item, _, _, _) = self.item { - if let _ = self.interaction.hiddenMedia[item.id] { - self.isHidden = true - } else { - self.isHidden = false - } - } else { - self.isHidden = false - } - self.displayLink?.isPaused = !self.hasVisibility || self.isHidden - } -} -*/ - private final class VisualMediaHoleAnchor: SparseItemGrid.HoleAnchor { let messageId: MessageId override var id: AnyHashable { @@ -1632,8 +1194,12 @@ private func tagMaskForType(_ type: PeerInfoVisualMediaPaneNode.ContentType) -> } } -final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScrollViewDelegate, UIGestureRecognizerDelegate { - enum ContentType { +public protocol PeerInfoScreenNodeProtocol: AnyObject { + func displaySharedMediaFastScrollingTooltip() +} + +public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScrollViewDelegate, UIGestureRecognizerDelegate { + public enum ContentType { case photoOrVideo case photo case video @@ -1643,7 +1209,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro case music } - struct ZoomLevel { + public struct ZoomLevel { fileprivate var value: SparseItemGrid.ZoomLevel init(_ value: SparseItemGrid.ZoomLevel) { @@ -1664,10 +1230,10 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro private let chatLocation: ChatLocation private let chatLocationContextHolder: Atomic private let chatControllerInteraction: ChatControllerInteraction - private(set) var contentType: ContentType + public private(set) var contentType: ContentType private var contentTypePromise: ValuePromise - weak var parentController: ViewController? + public weak var parentController: ViewController? private let contextGestureContainerNode: ContextControllerSourceNode private let itemGrid: SparseItemGrid @@ -1687,17 +1253,17 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro private let ready = Promise() private var didSetReady: Bool = false - var isReady: Signal { + public var isReady: Signal { return self.ready.get() } private let statusPromise = Promise(nil) - var status: Signal { + public var status: Signal { self.statusPromise.get() } - var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)? - var tabBarOffset: CGFloat { + public var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)? + public var tabBarOffset: CGFloat { return self.itemGrid.coveringInsetOffset } @@ -1712,11 +1278,11 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro private var animationTimer: SwiftSignalKit.Timer? - private(set) var calendarSource: SparseMessageCalendar? + public private(set) var calendarSource: SparseMessageCalendar? private var listSource: SparseMessageList - var openCurrentDate: (() -> Void)? - var paneDidScroll: (() -> Void)? + public var openCurrentDate: (() -> Void)? + public var paneDidScroll: (() -> Void)? private let stateTag: MessageTags private var storedStateDisposable: Disposable? @@ -1726,7 +1292,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro private var presentationData: PresentationData private var presentationDataDisposable: Disposable? - init(context: AccountContext, chatControllerInteraction: ChatControllerInteraction, peerId: PeerId, chatLocation: ChatLocation, chatLocationContextHolder: Atomic, contentType: ContentType, captureProtected: Bool) { + public init(context: AccountContext, chatControllerInteraction: ChatControllerInteraction, peerId: PeerId, chatLocation: ChatLocation, chatLocationContextHolder: Atomic, contentType: ContentType, captureProtected: Bool) { self.context = context self.peerId = peerId self.chatLocation = chatLocation @@ -1889,9 +1455,9 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro let _ = ApplicationSpecificNotice.incrementSharedMediaFastScrollingTooltip(accountManager: strongSelf.context.sharedContext.accountManager).start() var currentNode: ASDisplayNode = strongSelf - var result: PeerInfoScreenNode? + var result: PeerInfoScreenNodeProtocol? while true { - if let currentNode = currentNode as? PeerInfoScreenNode { + if let currentNode = currentNode as? PeerInfoScreenNodeProtocol { result = currentNode break } else if let supernode = currentNode.supernode { @@ -2221,7 +1787,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro self.presentationDataDisposable?.dispose() } - func loadHole(anchor: SparseItemGrid.HoleAnchor, at location: SparseItemGrid.HoleLocation) -> Signal { + public func loadHole(anchor: SparseItemGrid.HoleAnchor, at location: SparseItemGrid.HoleLocation) -> Signal { guard let anchor = anchor as? VisualMediaHoleAnchor else { return .never() } @@ -2244,7 +1810,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } } - func updateContentType(contentType: ContentType) { + public func updateContentType(contentType: ContentType) { if self.contentType == contentType { return } @@ -2263,13 +1829,13 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro self.requestHistoryAroundVisiblePosition(synchronous: true, reloadAtTop: true) } - func updateZoomLevel(level: ZoomLevel) { + public func updateZoomLevel(level: ZoomLevel) { self.itemGrid.setZoomLevel(level: level.value) let _ = updateVisualMediaStoredState(engine: self.context.engine, peerId: self.peerId, messageTag: self.stateTag, state: VisualMediaStoredState(zoomLevel: level.rawValue)).start() } - func ensureMessageIsVisible(id: MessageId) { + public func ensureMessageIsVisible(id: MessageId) { } private func requestHistoryAroundVisiblePosition(synchronous: Bool, reloadAtTop: Bool) { @@ -2342,19 +1908,19 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } } - func scrollToTop() -> Bool { + public func scrollToTop() -> Bool { return self.itemGrid.scrollToTop() } - func hitTestResultForScrolling() -> UIView? { + public func hitTestResultForScrolling() -> UIView? { return self.itemGrid.hitTestResultForScrolling() } - func brieflyDisableTouchActions() { + public func brieflyDisableTouchActions() { self.itemGrid.brieflyDisableTouchActions() } - func findLoadedMessage(id: MessageId) -> Message? { + public func findLoadedMessage(id: MessageId) -> Message? { guard let items = self.items else { return nil } @@ -2369,7 +1935,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro return nil } - func updateHiddenMedia() { + public func updateHiddenMedia() { self.itemGrid.forEachVisibleItem { item in guard let itemLayer = item.layer as? ItemLayer else { return @@ -2388,11 +1954,11 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } } - func transferVelocity(_ velocity: CGFloat) { + public func transferVelocity(_ velocity: CGFloat) { self.itemGrid.transferVelocity(velocity) } - func cancelPreviewGestures() { + public func cancelPreviewGestures() { self.itemGrid.forEachVisibleItem { item in guard let itemView = item.view as? ItemView else { return @@ -2403,7 +1969,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } } - func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + public func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { var foundItemLayer: SparseItemGridLayer? self.itemGrid.forEachVisibleItem { item in guard let itemLayer = item.layer as? ItemLayer else { @@ -2442,14 +2008,14 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro return nil } - func addToTransitionSurface(view: UIView) { + public func addToTransitionSurface(view: UIView) { self.itemGrid.addToTransitionSurface(view: view) } private var gridSelectionGesture: MediaPickerGridSelectionGesture? private var listSelectionGesture: MediaListSelectionRecognizer? - override func didLoad() { + override public func didLoad() { super.didLoad() let selectionRecognizer = MediaListSelectionRecognizer(target: self, action: #selector(self.selectionPanGesture(_:))) @@ -2594,7 +2160,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro self.selectionScrollDisplayLink?.isPaused = false } - override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { let location = gestureRecognizer.location(in: gestureRecognizer.view) if location.x < 44.0 { return false @@ -2602,7 +2168,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro return true } - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer.state != .failed, let otherGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer { otherGestureRecognizer.isEnabled = false otherGestureRecognizer.isEnabled = true @@ -2612,7 +2178,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } } - func updateSelectedMessages(animated: Bool) { + public func updateSelectedMessages(animated: Bool) { switch self.contentType { case .files, .music, .voiceAndVideoMessages: self.itemGrid.forEachVisibleItem { item in @@ -2673,7 +2239,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } } - func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { + public func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { self.currentParams = (size, topInset, sideInset, bottomInset, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData) transition.updateFrame(node: self.contextGestureContainerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height))) @@ -2746,11 +2312,11 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro fixedItemHeight = nil } - self.itemGrid.update(size: size, insets: UIEdgeInsets(top: topInset, left: sideInset, bottom: bottomInset, right: sideInset), useSideInsets: !isList, scrollIndicatorInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset), lockScrollingAtTop: isScrollingLockedAtTop, fixedItemHeight: fixedItemHeight, items: items, theme: self.itemGridBinding.chatPresentationData.theme.theme, synchronous: wasFirstTime ? .full : .none) + self.itemGrid.update(size: size, insets: UIEdgeInsets(top: topInset, left: sideInset, bottom: bottomInset, right: sideInset), useSideInsets: !isList, scrollIndicatorInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset), lockScrollingAtTop: isScrollingLockedAtTop, fixedItemHeight: fixedItemHeight, fixedItemAspect: nil, items: items, theme: self.itemGridBinding.chatPresentationData.theme.theme, synchronous: wasFirstTime ? .full : .none) } } - func currentTopTimestamp() -> Int32? { + public func currentTopTimestamp() -> Int32? { var timestamp: Int32? self.itemGrid.forEachVisibleItem { item in guard let itemLayer = item.layer as? ItemLayer else { @@ -2767,7 +2333,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro return timestamp } - func scrollToTimestamp(timestamp: Int32) { + public func scrollToTimestamp(timestamp: Int32) { if let items = self.items, !items.items.isEmpty { var previousIndex: Int? for item in items.items { @@ -2806,14 +2372,14 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } } - func scrollToItem(index: Int) { + public func scrollToItem(index: Int) { guard let _ = self.items else { return } self.itemGrid.scrollToItem(at: index) } - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard let result = super.hitTest(point, with: event) else { return nil } @@ -2826,21 +2392,21 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro return result } - func availableZoomLevels() -> (decrement: ZoomLevel?, increment: ZoomLevel?) { + public func availableZoomLevels() -> (decrement: ZoomLevel?, increment: ZoomLevel?) { let levels = self.itemGrid.availableZoomLevels() return (levels.decrement.flatMap(ZoomLevel.init), levels.increment.flatMap(ZoomLevel.init)) } } -final class VisualMediaStoredState: Codable { - let zoomLevel: Int32 +public final class VisualMediaStoredState: Codable { + public let zoomLevel: Int32 public init(zoomLevel: Int32) { self.zoomLevel = zoomLevel } } -func visualMediaStoredState(engine: TelegramEngine, peerId: PeerId, messageTag: MessageTags) -> Signal { +public func visualMediaStoredState(engine: TelegramEngine, peerId: PeerId, messageTag: MessageTags) -> Signal { let key = ValueBoxKey(length: 8 + 4) key.setInt64(0, value: peerId.toInt64()) key.setUInt32(8, value: messageTag.rawValue) @@ -2851,7 +2417,7 @@ func visualMediaStoredState(engine: TelegramEngine, peerId: PeerId, messageTag: } } -func updateVisualMediaStoredState(engine: TelegramEngine, peerId: PeerId, messageTag: MessageTags, state: VisualMediaStoredState?) -> Signal { +public func updateVisualMediaStoredState(engine: TelegramEngine, peerId: PeerId, messageTag: MessageTags, state: VisualMediaStoredState?) -> Signal { let key = ValueBoxKey(length: 8 + 4) key.setInt64(0, value: peerId.toInt64()) key.setUInt32(8, value: messageTag.rawValue) diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD index 27cf910d61..756ffd8d42 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/BUILD @@ -50,6 +50,7 @@ swift_library( "//submodules/ReactionSelectionNode", "//submodules/ContextUI", "//submodules/AvatarNode", + "//submodules/ChatPresentationInterfaceState", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift index 2881f7b1e1..04ccb133d8 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -36,14 +36,14 @@ private final class StoryContainerScreenComponent: Component { let initialFocusedId: AnyHashable? let initialContent: [StoryContentItemSlice] let transitionIn: StoryContainerScreen.TransitionIn? - let transitionOut: (EnginePeer.Id) -> StoryContainerScreen.TransitionOut? + let transitionOut: (EnginePeer.Id, AnyHashable) -> StoryContainerScreen.TransitionOut? init( context: AccountContext, initialFocusedId: AnyHashable?, initialContent: [StoryContentItemSlice], transitionIn: StoryContainerScreen.TransitionIn?, - transitionOut: @escaping (EnginePeer.Id) -> StoryContainerScreen.TransitionOut? + transitionOut: @escaping (EnginePeer.Id, AnyHashable) -> StoryContainerScreen.TransitionOut? ) { self.context = context self.initialFocusedId = initialFocusedId @@ -130,6 +130,7 @@ private final class StoryContainerScreenComponent: Component { override init(frame: CGRect) { self.backgroundLayer = SimpleLayer() self.backgroundLayer.backgroundColor = UIColor.black.cgColor + self.backgroundLayer.zPosition = -1000.0 super.init(frame: frame) @@ -347,7 +348,7 @@ private final class StoryContainerScreenComponent: Component { self.isAnimatingOut = true self.state?.updated(transition: .immediate) - if let component = self.component, let focusedItemSet = self.focusedItemSet, let peerId = focusedItemSet.base as? EnginePeer.Id, let itemSetView = self.visibleItemSetViews[focusedItemSet], let itemSetComponentView = itemSetView.view.view as? StoryItemSetContainerComponent.View, let transitionOut = component.transitionOut(peerId) { + if let component = self.component, let focusedItemSet = self.focusedItemSet, let peerId = focusedItemSet.base as? EnginePeer.Id, let itemSetView = self.visibleItemSetViews[focusedItemSet], let itemSetComponentView = itemSetView.view.view as? StoryItemSetContainerComponent.View, let focusedItemId = itemSetComponentView.focusedItemId, let transitionOut = component.transitionOut(peerId, focusedItemId) { let transition = Transition(animation: .curve(duration: 0.25, curve: .easeInOut)) transition.setAlpha(layer: self.backgroundLayer, alpha: 0.0) @@ -654,10 +655,12 @@ private final class StoryContainerScreenComponent: Component { if let previousRotationFraction = itemSetView.rotationFraction { let fromT = previousRotationFraction let toT = panFraction - itemSetTransition.setTransformAsKeyframes(view: itemSetView, transform: { sourceT in + itemSetTransition.setTransformAsKeyframes(view: itemSetView, transform: { sourceT, isFinal in let t = fromT * (1.0 - sourceT) + toT * sourceT if abs((t + cubeAdditionalRotationFraction) - 0.0) < 0.0001 { - return CATransform3DIdentity + if isFinal { + return CATransform3DIdentity + } } return calculateCubeTransform(rotationFraction: t + cubeAdditionalRotationFraction, sideAngle: sideAngle, cubeSize: itemFrame.size) @@ -776,7 +779,7 @@ public class StoryContainerScreen: ViewControllerComponentContainer { initialFocusedId: AnyHashable?, initialContent: [StoryContentItemSlice], transitionIn: TransitionIn?, - transitionOut: @escaping (EnginePeer.Id) -> TransitionOut? + transitionOut: @escaping (EnginePeer.Id, AnyHashable) -> TransitionOut? ) { self.context = context diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index bc5aa58465..426be1a695 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -250,27 +250,12 @@ public final class StoryItemSetContainerComponent: Component { self.sendMessageContext.audioRecorderValue = audioRecorder self.component?.controller()?.lockOrientation = audioRecorder != nil - /*strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { - $0.updatedInputTextPanelState { panelState in - let isLocked = strongSelf.lockMediaRecordingRequestId == strongSelf.beginMediaRecordingRequestId - if let audioRecorder = audioRecorder { - if panelState.mediaRecordingState == nil { - return panelState.withUpdatedMediaRecordingState(.audio(recorder: audioRecorder, isLocked: isLocked)) - } - } else { - if case .waitingForPreview = panelState.mediaRecordingState { - return panelState - } - return panelState.withUpdatedMediaRecordingState(nil) - } - return panelState - } - })*/ - self.audioRecorderStatusDisposable?.dispose() self.audioRecorderStatusDisposable = nil if let audioRecorder = audioRecorder { + self.sendMessageContext.wasRecordingDismissed = false + if !audioRecorder.beginWithTone { HapticFeedback().impact(.light) } @@ -281,7 +266,7 @@ public final class StoryItemSetContainerComponent: Component { return } if case .stopped = value { - self.sendMessageContext.stopMediaRecorder() + self.sendMessageContext.stopMediaRecording(view: self) } }) } @@ -300,15 +285,14 @@ public final class StoryItemSetContainerComponent: Component { self.sendMessageContext.videoRecorderValue = videoRecorder if let videoRecorder = videoRecorder { + self.sendMessageContext.wasRecordingDismissed = false HapticFeedback().impact(.light) videoRecorder.onDismiss = { [weak self] isCancelled in guard let self else { return } - //self?.chatDisplayNode.updateRecordedMediaDeleted(isCancelled) - //self?.beginMediaRecordingRequestId += 1 - //self?.lockMediaRecordingRequestId = nil + self.sendMessageContext.wasRecordingDismissed = true self.sendMessageContext.videoRecorder.set(.single(nil)) } videoRecorder.onStop = { [weak self] in @@ -327,9 +311,9 @@ public final class StoryItemSetContainerComponent: Component { } self.component?.controller()?.present(videoRecorder, in: .window(.root)) - /*if strongSelf.lockMediaRecordingRequestId == strongSelf.beginMediaRecordingRequestId { + if self.sendMessageContext.isMediaRecordingLocked { videoRecorder.lockVideo() - }*/ + } } if let previousVideoRecorderValue { @@ -353,7 +337,17 @@ public final class StoryItemSetContainerComponent: Component { } func isPointInsideContentArea(point: CGPoint) -> Bool { - return self.contentContainerView.frame.contains(point) + if let inputPanelView = self.inputPanel.view { + if inputPanelView.frame.contains(point) { + return false + } + } + + if self.contentContainerView.frame.contains(point) { + return true + } + + return false } @objc public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool { @@ -539,7 +533,7 @@ public final class StoryItemSetContainerComponent: Component { for (_, visibleItem) in self.visibleItems { if let view = visibleItem.view.view { if let view = view as? StoryContentItem.View { - view.setIsProgressPaused(self.inputPanelExternalState.isEditing || component.isProgressPaused || self.reactionItems != nil || self.actionSheet != nil || self.contextController != nil || self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil) + view.setIsProgressPaused(self.inputPanelExternalState.isEditing || component.isProgressPaused || self.displayReactions || self.actionSheet != nil || self.contextController != nil || self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil) } } } @@ -813,6 +807,25 @@ public final class StoryItemSetContainerComponent: Component { } self.sendMessageContext.setMediaRecordingActive(view: self, isActive: isActive, isVideo: isVideo, sendAction: sendAction) }, + lockMediaRecording: { [weak self] in + guard let self else { + return + } + self.sendMessageContext.lockMediaRecording() + self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .spring))) + }, + stopAndPreviewMediaRecording: { [weak self] in + guard let self else { + return + } + self.sendMessageContext.stopMediaRecording(view: self) + }, + discardMediaRecordingPreview: { [weak self] in + guard let self else { + return + } + self.sendMessageContext.discardMediaRecordingPreview(view: self) + }, attachmentAction: { [weak self] in guard let self else { return @@ -843,6 +856,9 @@ public final class StoryItemSetContainerComponent: Component { }, audioRecorder: self.sendMessageContext.audioRecorderValue, videoRecordingStatus: self.sendMessageContext.videoRecorderValue?.audioStatus, + isRecordingLocked: self.sendMessageContext.isMediaRecordingLocked, + recordedAudioPreview: self.sendMessageContext.recordedAudioPreview, + wasRecordingDismissed: self.sendMessageContext.wasRecordingDismissed, displayGradient: component.inputHeight != 0.0, bottomInset: component.inputHeight != 0.0 ? 0.0 : bottomContentInset )), @@ -1159,7 +1175,18 @@ public final class StoryItemSetContainerComponent: Component { let reactionsAnchorRect = CGRect(origin: CGPoint(x: inputPanelFrame.maxX - 40.0, y: inputPanelFrame.minY + 9.0), size: CGSize(width: 32.0, height: 32.0)).insetBy(dx: -4.0, dy: -4.0) - if let reactionItems = self.reactionItems, (self.displayReactions || self.inputPanelExternalState.isEditing) { + var effectiveDisplayReactions = self.displayReactions + if self.inputPanelExternalState.isEditing && !self.inputPanelExternalState.hasText { + effectiveDisplayReactions = true + } + if self.sendMessageContext.audioRecorderValue != nil || self.sendMessageContext.videoRecorderValue != nil { + effectiveDisplayReactions = false + } + if self.sendMessageContext.recordedAudioPreview != nil { + effectiveDisplayReactions = false + } + + if let reactionItems = self.reactionItems, effectiveDisplayReactions { let reactionContextNode: ReactionContextNode var reactionContextNodeTransition = transition if let current = self.reactionContextNode { diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift index 826f991b34..cdaecd0af4 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift @@ -3,6 +3,7 @@ import SwiftSignalKit import TelegramCore import AccountContext import Display +import ComponentFlow import MessageInputPanelComponent import UndoUI import AttachmentUI @@ -29,12 +30,14 @@ import TelegramPresentationData import LegacyInstantVideoController import TelegramPresentationData import ShareController +import ChatPresentationInterfaceState final class StoryItemSetContainerSendMessage { weak var attachmentController: AttachmentController? var audioRecorderValue: ManagedAudioRecorder? var audioRecorder = Promise() + var recordedAudioPreview: ChatRecordedMediaPreview? var videoRecorderValue: InstantVideoController? var tempVideoRecorderValue: InstantVideoController? @@ -42,6 +45,9 @@ final class StoryItemSetContainerSendMessage { let controllerNavigationDisposable = MetaDisposable() let enqueueMediaMessageDisposable = MetaDisposable() + private(set) var isMediaRecordingLocked: Bool = false + var wasRecordingDismissed: Bool = false + deinit { self.controllerNavigationDisposable.dispose() self.enqueueMediaMessageDisposable.dispose() @@ -63,26 +69,38 @@ final class StoryItemSetContainerSendMessage { return } - switch inputPanelView.getSendMessageInput() { - case let .text(text): - if !text.isEmpty { - component.context.engine.messages.enqueueOutgoingMessage( - to: peerId, - replyTo: nil, - content: .text(text) - ) - inputPanelView.clearSendMessageInput() - view.endEditing(true) - - if let controller = component.controller() { - let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - controller.present(UndoOverlayController( - presentationData: presentationData, - content: .succeed(text: "Message Sent"), - elevatedLayout: false, - animateInAsReplacement: false, - action: { _ in return false } - ), in: .current) + if let recordedAudioPreview = self.recordedAudioPreview { + self.recordedAudioPreview = nil + + let waveformBuffer = recordedAudioPreview.waveform.makeBitstream() + + let messages: [EnqueueMessage] = [.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: recordedAudioPreview.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(recordedAudioPreview.fileSize), attributes: [.Audio(isVoice: true, duration: Int(recordedAudioPreview.duration), title: nil, performer: nil, waveform: waveformBuffer)])), replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])] + + let _ = enqueueMessages(account: component.context.account, peerId: peerId, messages: messages).start() + + view.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .spring))) + } else { + switch inputPanelView.getSendMessageInput() { + case let .text(text): + if !text.isEmpty { + component.context.engine.messages.enqueueOutgoingMessage( + to: peerId, + replyTo: nil, + content: .text(text) + ) + inputPanelView.clearSendMessageInput() + view.endEditing(true) + + if let controller = component.controller() { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + controller.present(UndoOverlayController( + presentationData: presentationData, + content: .succeed(text: "Message Sent"), + elevatedLayout: false, + animateInAsReplacement: false, + action: { _ in return false } + ), in: .current) + } } } } @@ -94,6 +112,8 @@ final class StoryItemSetContainerSendMessage { isVideo: Bool, sendAction: Bool ) { + self.isMediaRecordingLocked = false + guard let component = view.component else { return } @@ -167,6 +187,7 @@ final class StoryItemSetContainerSendMessage { return } + self.wasRecordingDismissed = !sendAction self.audioRecorder.set(.single(nil)) guard let data else { @@ -205,7 +226,50 @@ final class StoryItemSetContainerSendMessage { }) } - func stopMediaRecorder() { + func lockMediaRecording() { + self.isMediaRecordingLocked = true + } + + func stopMediaRecording(view: StoryItemSetContainerComponent.View) { + if let audioRecorderValue = self.audioRecorderValue { + let _ = (audioRecorderValue.takenRecordedData() |> deliverOnMainQueue).start(next: { [weak self, weak view] data in + guard let self, let view, let component = view.component else { + return + } + self.audioRecorder.set(.single(nil)) + + guard let data else { + return + } + if data.duration < 0.5 { + HapticFeedback().error() + } else if let waveform = data.waveform { + let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max), size: Int64(data.compressedData.count)) + + component.context.account.postbox.mediaBox.storeResourceData(resource.id, data: data.compressedData) + self.recordedAudioPreview = ChatRecordedMediaPreview(resource: resource, duration: Int32(data.duration), fileSize: Int32(data.compressedData.count), waveform: AudioWaveform(bitstream: waveform, bitsPerSample: 5)) + view.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .spring))) + } + }) + } else if let videoRecorderValue = self.videoRecorderValue { + if videoRecorderValue.stopVideo() { + /*self.updateChatPresentationInterfaceState(animated: true, interactive: true, { + $0.updatedInputTextPanelState { panelState in + return panelState.withUpdatedMediaRecordingState(.video(status: .editing, isLocked: false)) + } + })*/ + } else { + self.videoRecorder.set(.single(nil)) + } + } + } + + func discardMediaRecordingPreview(view: StoryItemSetContainerComponent.View) { + if self.recordedAudioPreview != nil { + self.recordedAudioPreview = nil + self.wasRecordingDismissed = true + view.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .spring))) + } } func performInlineAction(view: StoryItemSetContainerComponent.View, item: StoryActionsComponent.Item) { diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift index 3cd6399661..6b2932292e 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryChatContent.swift @@ -8,7 +8,7 @@ import TelegramCore import StoryContainerScreen public enum StoryChatContent { - public static func stories(context: AccountContext, storyList: StoryListContext, focusItem: Int64?) -> Signal<[StoryContentItemSlice], NoError> { + public static func stories(context: AccountContext, storyList: StoryListContext, focusItem: Int32?) -> Signal<[StoryContentItemSlice], NoError> { return storyList.state |> map { state -> [StoryContentItemSlice] in var itemSlices: [StoryContentItemSlice] = [] @@ -59,7 +59,7 @@ public enum StoryChatContent { } var sliceFocusedItemId: AnyHashable? - if let focusItem, items.contains(where: { ($0.id.base as? Int64) == focusItem }) { + if let focusItem, items.contains(where: { ($0.id.base as? Int32) == focusItem }) { sliceFocusedItemId = AnyHashable(focusItem) } else { if let id = itemSet.items.first(where: { $0.id > itemSet.maxReadId })?.id { @@ -73,8 +73,8 @@ public enum StoryChatContent { items: items, totalCount: items.count, update: { requestedItemSet, itemId in - var focusItem: Int64? - if let id = itemId.base as? Int64 { + var focusItem: Int32? + if let id = itemId.base as? Int32 { focusItem = id } return StoryChatContent.stories(context: context, storyList: storyList, focusItem: focusItem) diff --git a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift index 3fb49ee36a..1fec8e9fa4 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContentComponent/Sources/StoryItemContentComponent.swift @@ -228,7 +228,7 @@ final class StoryItemContentComponent: Component { } } - #if DEBUG && false + #if DEBUG// && false let currentProgressTimerLimit: Double = 5 * 60.0 #else let currentProgressTimerLimit: Double = 5.0 diff --git a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift index feaa47c9fd..fc8e982513 100644 --- a/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryPeerListComponent/Sources/StoryPeerListComponent.swift @@ -218,8 +218,7 @@ public final class StoryPeerListComponent: Component { collapsedContentWidth += titleSpacing } - let collapseEndIndex = collapseStartIndex + Int(collapsedItemCount) - let _ = collapseEndIndex + let collapseEndIndex = collapseStartIndex + max(0, Int(collapsedItemCount) - 1) let titleOffset = collapsedContentWidth collapsedContentWidth += titleSize.width @@ -307,13 +306,13 @@ public final class StoryPeerListComponent: Component { var itemAlpha: CGFloat = 1.0 - if i >= collapseStartIndex && i <= (collapseStartIndex + 2) { + if i >= collapseStartIndex && i <= collapseEndIndex { if i != collapseStartIndex { let regularLeftItemFrame = itemLayout.frame(at: i - 1) let collapsedLeftItemFrame = CGRect(origin: CGPoint(x: collapsedContentOrigin + CGFloat(i - collapseStartIndex - 1) * collapsedItemDistance, y: regularLeftItemFrame.minY), size: CGSize(width: collapsedItemWidth, height: regularLeftItemFrame.height)) leftItemFrame = regularLeftItemFrame.interpolate(to: collapsedLeftItemFrame, amount: component.collapseFraction) } - if i != collapseStartIndex + 2 { + if i != collapseEndIndex { let regularRightItemFrame = itemLayout.frame(at: i - 1) let collapsedRightItemFrame = CGRect(origin: CGPoint(x: collapsedContentOrigin + CGFloat(i - collapseStartIndex - 1) * collapsedItemDistance, y: regularRightItemFrame.minY), size: CGSize(width: collapsedItemWidth, height: regularRightItemFrame.height)) rightItemFrame = regularRightItemFrame.interpolate(to: collapsedRightItemFrame, amount: component.collapseFraction) @@ -414,7 +413,7 @@ public final class StoryPeerListComponent: Component { if let myIndex = state.itemSets.firstIndex(where: { $0.peerId == component.context.account.peerId }) { self.sortedItemSets.append(state.itemSets[myIndex]) } - for i in 0 ..< 4 { + for i in 0 ..< 1 { for itemSet in state.itemSets { if itemSet.peerId == component.context.account.peerId { continue diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/Contents.json index e885e800ab..1e1022c2f6 100644 --- a/submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/Contents.json +++ b/submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/Contents.json @@ -1,22 +1,12 @@ { "images" : [ { - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "ModernConversationAudioSlideToCancel@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "ModernConversationAudioSlideToCancel@3x.png", - "scale" : "3x" + "filename" : "arrowleft.svg", + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/ModernConversationAudioSlideToCancel@2x.png b/submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/ModernConversationAudioSlideToCancel@2x.png deleted file mode 100644 index 4eaca6a59020e74d3fbbbf6c27dae5b76f643373..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 496 zcmVk00001b5ch_0Itp) z=>Px$s!2paR7ee-mO)O!Kp2HPe}Tval(i@D3?#19#FdL`NQh~aLx@4|04Tefa0wF5 zV4{bBD*%~sUg2-1(oSdEgmk9q`{um|e?kz{xW*q}SNK3GeD&jLGE_>AQZa?sRX$Q94CZNzk zRc;e-)PYNG6OeOolsgDk%|wN9F+hI(_5-fcW-t1OUXRk6Z^3 z^X1w{p#wl%`zQqf;?jo|07z9Ir5!-bm#vRV2Y}f6aB{#-@Y9w%xzPLcU<$>JlLH2z m4P001@!1^@s6G7~a@00001b5ch_0Itp) z=>Px%KS@MER9FeMm`_RrF%*ZBRG}7KxUpU9!V`Fb-a!S0LKjl?4pnIHP=ziE!U*cp zrQphIbZ?h}I7)5C{6okwyjQHwB(I$Hz?{g^RUm8AXaD&OPMlCA`MjvPr1oF4vvK?HItygM#I&1a|E^}F}HYd)ar zHRM)sH>1z0DKikc5^^SUByuafLuR{eJp);mp9};uTZW$;1R!cZGa&#``YD7^`YD89 zHB^4)LI9%hvk(GMs2?jxs2?i`RukgK8Um27A14SvK7O1bK7O1bSdFV6F9<*`e!L+7 z+4>2B*!m&KMv!bQ^#jgaKPs1-u#dx_?E*kwMSjpv2GM?0In?0vetq?U#EA}yQh(^g feTh}>@V)B?eXN*y$P!Y>00000NkvXXu0mjfaG4a+ diff --git a/submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/arrowleft.svg b/submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/arrowleft.svg new file mode 100644 index 0000000000..b2ed5e1a25 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat/Input/Text/AudioRecordingCancelArrow.imageset/arrowleft.svg @@ -0,0 +1,4 @@ + + + + diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift index b1b9acd91e..5887353ef3 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift @@ -1192,6 +1192,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { backgroundColor: isTranscriptionInProgress ? messageTheme.mediaInactiveControlColor : waveformColor, foregroundColor: messageTheme.mediaActiveControlColor, shimmerColor: isTranscriptionInProgress ? messageTheme.mediaActiveControlColor : nil, + style: .bottom, samples: audioWaveform?.samples ?? Data(), peak: audioWaveform?.peak ?? 0, status: strongSelf.playbackStatus.get(), @@ -1199,7 +1200,8 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { if let strongSelf = self, let context = strongSelf.context, let message = strongSelf.message, let type = peerMessageMediaPlayerType(EngineMessage(message)) { context.sharedContext.mediaManager.playlistControl(.seek(timestamp), type: type) } - } + }, + updateIsSeeking: nil )), environment: {}, containerSize: scrubbingFrame.size diff --git a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoGroupsInCommonPaneNode.swift b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoGroupsInCommonPaneNode.swift index 98586b44c1..a310357e99 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoGroupsInCommonPaneNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoGroupsInCommonPaneNode.swift @@ -12,6 +12,7 @@ import ItemListPeerItem import MergeLists import ItemListUI import ChatControllerInteraction +import PeerInfoVisualMediaPaneNode private struct GroupsInCommonListTransaction { let deletions: [ListViewDeleteItem] diff --git a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoListPaneNode.swift b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoListPaneNode.swift index a6575ab42b..42bb81791f 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoListPaneNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoListPaneNode.swift @@ -16,6 +16,7 @@ import ListMessageItem import UndoUI import ChatPresentationInterfaceState import ChatControllerInteraction +import PeerInfoVisualMediaPaneNode final class PeerInfoListPaneNode: ASDisplayNode, PeerInfoPaneNode { private let context: AccountContext diff --git a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoMembersPane.swift b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoMembersPane.swift index e3e3769a6c..3a9c6ed642 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoMembersPane.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoMembersPane.swift @@ -12,6 +12,7 @@ import ItemListPeerItem import ItemListPeerActionItem import MergeLists import ItemListUI +import PeerInfoVisualMediaPaneNode private struct PeerMembersListTransaction { let deletions: [ListViewDeleteItem] diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoData.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoData.swift index 03c4344fec..3aba2570be 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoData.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoData.swift @@ -12,6 +12,7 @@ import TelegramUIPreferences import TelegramNotices import AccountUtils import DeviceAccess +import PeerInfoVisualMediaPaneNode enum PeerInfoUpdatingAvatar { case none @@ -329,12 +330,6 @@ private func peerInfoAvailableMediaPanes(context: AccountContext, peerId: PeerId |> distinctUntilChanged } -struct PeerInfoStatusData: Equatable { - var text: String - var isActivity: Bool - var key: PeerInfoPaneKey? -} - enum PeerInfoMembersData: Equatable { case shortList(membersContext: PeerInfoMembersContext, members: [PeerInfoMember]) case longList(PeerInfoMembersContext) @@ -681,6 +676,11 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen ) |> map { peerView, availablePanes, globalNotificationSettings, encryptionKeyFingerprint, status -> PeerInfoScreenData in var availablePanes = availablePanes + + if peerView.peers[peerView.peerId] is TelegramUser { + availablePanes?.insert(.stories, at: 0) + } + if availablePanes != nil, groupsInCommon != nil, let cachedData = peerView.cachedData as? CachedUserData { if cachedData.commonGroupCount != 0 { availablePanes?.append(.groupsInCommon) diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoHeaderNode.swift index 892b04a6e4..8d88b5b792 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoHeaderNode.swift @@ -30,6 +30,7 @@ import ComponentDisplayAdapters import ChatTitleView import AppBundle import AvatarVideoNode +import PeerInfoVisualMediaPaneNode enum PeerInfoHeaderButtonKey: Hashable { case message diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift index b1d9e55017..71884a94e7 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift @@ -9,27 +9,7 @@ import TelegramCore import AccountContext import ContextUI import ChatControllerInteraction - -protocol PeerInfoPaneNode: ASDisplayNode { - var isReady: Signal { get } - - var parentController: ViewController? { get set } - - var status: Signal { get } - var tabBarOffsetUpdated: ((ContainedViewLayoutTransition) -> Void)? { get set } - var tabBarOffset: CGFloat { get } - - func update(size: CGSize, topInset: CGFloat, sideInset: CGFloat, bottomInset: CGFloat, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) - func scrollToTop() -> Bool - func transferVelocity(_ velocity: CGFloat) - func cancelPreviewGestures() - func findLoadedMessage(id: MessageId) -> Message? - func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? - func addToTransitionSurface(view: UIView) - func updateHiddenMedia() - func updateSelectedMessages(animated: Bool) - func ensureMessageIsVisible(id: MessageId) -} +import PeerInfoVisualMediaPaneNode final class PeerInfoPaneWrapper { let key: PeerInfoPaneKey @@ -53,17 +33,6 @@ final class PeerInfoPaneWrapper { } } -enum PeerInfoPaneKey: Int32 { - case members - case media - case files - case music - case voice - case links - case gifs - case groupsInCommon -} - final class PeerInfoPaneTabsContainerPaneNode: ASDisplayNode { private let pressed: () -> Void @@ -398,6 +367,15 @@ private final class PeerInfoPendingPane { let captureProtected = data.peer?.isCopyProtectionEnabled ?? false let paneNode: PeerInfoPaneNode switch key { + case .stories: + let visualPaneNode = PeerInfoStoryPaneNode(context: context, chatControllerInteraction: chatControllerInteraction, peerId: peerId, chatLocation: chatLocation, chatLocationContextHolder: chatLocationContextHolder, contentType: .photoOrVideo, captureProtected: captureProtected) + paneNode = visualPaneNode + visualPaneNode.openCurrentDate = { + openMediaCalendar() + } + visualPaneNode.paneDidScroll = { + paneDidScroll() + } case .media: let visualPaneNode = PeerInfoVisualMediaPaneNode(context: context, chatControllerInteraction: chatControllerInteraction, peerId: peerId, chatLocation: chatLocation, chatLocationContextHolder: chatLocationContextHolder, contentType: .photoOrVideo, captureProtected: captureProtected) paneNode = visualPaneNode @@ -977,6 +955,9 @@ final class PeerInfoPaneContainerNode: ASDisplayNode, UIGestureRecognizerDelegat self.tabsContainerNode.update(size: CGSize(width: size.width, height: tabsHeight), presentationData: presentationData, paneList: availablePanes.map { key in let title: String switch key { + case .stories: + //TODO:localize + title = "Stories" case .media: title = presentationData.strings.PeerInfo_PaneMedia case .files: diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index 695b6efa21..f11b64a29c 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -86,6 +86,7 @@ import ChatControllerInteraction import StorageUsageScreen import AvatarEditorScreen import SendInviteLinkScreen +import PeerInfoVisualMediaPaneNode enum PeerInfoAvatarEditingMode { case generic @@ -2016,7 +2017,7 @@ private func editingItems(data: PeerInfoScreenData?, state: PeerInfoState, chatL return result } -final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate { +final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodeProtocol, UIScrollViewDelegate { private weak var controller: PeerInfoScreenImpl? private let context: AccountContext diff --git a/submodules/TelegramUI/Sources/PeerInfoGifPaneNode.swift b/submodules/TelegramUI/Sources/PeerInfoGifPaneNode.swift index 97fa018ca4..6741c01f2d 100644 --- a/submodules/TelegramUI/Sources/PeerInfoGifPaneNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfoGifPaneNode.swift @@ -15,6 +15,7 @@ import ListMessageItem import ChatMessageInteractiveMediaBadge import SoftwareVideo import ChatControllerInteraction +import PeerInfoVisualMediaPaneNode private final class FrameSequenceThumbnailNode: ASDisplayNode { private let context: AccountContext diff --git a/submodules/TelegramUI/Sources/TelegramRootController.swift b/submodules/TelegramUI/Sources/TelegramRootController.swift index a72e999375..9e28a3e2be 100644 --- a/submodules/TelegramUI/Sources/TelegramRootController.swift +++ b/submodules/TelegramUI/Sources/TelegramRootController.swift @@ -25,6 +25,7 @@ import LegacyCamera import AvatarNode import LocalMediaResources import ShareWithPeersScreen +import ImageCompression private class DetailsChatPlaceholderNode: ASDisplayNode, NavigationDetailsPlaceholderNode { private var presentationData: PresentationData @@ -359,11 +360,16 @@ public final class TelegramRootController: NavigationController, TelegramRootCon if let chatListController = self.chatListController as? ChatListControllerImpl, let storyListContext = chatListController.storyListContext { switch mediaResult { case let .image(image, dimensions, caption): - if let data = image.jpegData(compressionQuality: 0.8) { - storyListContext.upload(media: .image(dimensions: dimensions, data: data), text: caption?.string ?? "", entities: [], privacy: privacy) - Queue.mainQueue().after(0.2, { [weak chatListController] in - chatListController?.animateStoryUploadRipple() - }) + var randomId: Int64 = 0 + arc4random_buf(&randomId, 8) + let scaledSize = image.size.aspectFittedOrSmaller(CGSize(width: 1280.0, height: 1280.0)) + if let scaledImage = scaleImageToPixelSize(image: image, size: scaledSize) { + if let scaledImageData = compressImageToJPEG(scaledImage, quality: 0.6) { + storyListContext.upload(media: .image(dimensions: dimensions, data: scaledImageData), text: caption?.string ?? "", entities: [], privacy: privacy) + Queue.mainQueue().after(0.2, { [weak chatListController] in + chatListController?.animateStoryUploadRipple() + }) + } } case let .video(content, _, values, duration, dimensions, caption): let adjustments: VideoMediaResourceAdjustments